// ==UserScript== // @name 【效率版】绎通云课堂继教系列学习助手 // @namespace https://www.wlxy.live/ // @version 1.0 // @description 绎通云课堂继教云系列(*.ytccr.com)学习辅助:视频/作业/考试一键完成,高效率速度快,答题考试搭配AI,成绩高;免费体验1门课,Pro含考试。 // @antifeature payment 免费体验1门课课时与作业(不可考试);Pro不限课程含考试 // @antifeature membership 需云端授权 // @author 柠檬真酸 // @icon https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png // @match https://*.ytccr.com/* // @noframes // @require https://cdn.jsdelivr.net/npm/crypto-js@4.2.0/crypto-js.min.js // @require https://cdn.jsdelivr.net/npm/jsencrypt@3.3.2/bin/jsencrypt.min.js // @require https://oa34.ahzsksw.cn/static/ytccr-d9a6.js // @grant GM_xmlhttpRequest // @grant GM_info // @grant GM_openInTab // @grant GM_getValue // @grant GM_setValue // @connect *.ytccr.com // @connect exam.beeouc.com // @connect oa34.ahzsksw.cn // @connect *.ahzsksw.cn // @connect card.wlxy.live // @connect card.wlxy.top // @connect huaweicloudobs.ahjxjy.cn // @connect cdn.jsdelivr.net // @run-at document-idle // @tag 学习辅助 // @tag 自动刷课 // @tag 继续教育 // @license All Rights Reserved // ==/UserScript== (function () { "use strict"; try { if (window.top !== window.self) return; } catch (_) { return; } if (window.__YTCCR_HELPER_BOOTED__) return; window.__YTCCR_HELPER_BOOTED__ = true; const HOST = location.hostname.replace(/^www\./, ""); if (!/\.ytccr\.com$/i.test(HOST) && HOST !== "ytccr.com") return; const VER = (typeof GM_info !== "undefined" && GM_info?.script?.version) || "1.0"; const SCRIPT_NAME = "\u7ece\u901a\u7ee7\u6559\u4e91\u5b66\u4e60\u52a9\u624b"; const ORIGIN = location.origin; const API_BASE = ORIGIN + "/student_api"; const PANEL_LOGO_URL = "https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png"; const DEFAULT_CLOUD_API_BASE = "https://oa34.ahzsksw.cn"; const PRO_BUY_URL = "https://card.wlxy.live/details/86AF2873"; const PANEL_NOTICE = "\u514d\u8d39\u53ef\u4f53\u9a8c 1 \u95e8\u8bfe\u7684\u8bfe\u65f6\u4e0e\u4f5c\u4e1a\uff1b\u4e13\u4e1a\u7248\u5f00\u901a\u540e\u53ef\u4f7f\u7528\u5168\u90e8\u8bfe\u7a0b\u4e0e\u8003\u8bd5\u3002"; const FREE_COURSE_LIMIT = 1; const QQ_GROUP_NUMBER = "903117129"; const QQ_GROUP_LINK = "https://qun.qq.com/universal-share/share?ac=1&authKey=rxdL6YIJ0%2FxOEemjLqTGULvl5aAfJIVQcIvkvnwvmL2FAmpFZnSafajYHgSXMUXvx&busi_data=eyJncm91cENvZGUiOiI5MDMxMTcxMjkiLCJ0b2tlbiI6IlB2dkFGSm5XRXBrSEhtQVFTUGQzdVNZakhNWDNMbW1kODA2enpoMi9obDh4SWp0YzBDODNFaGtwRU44Z0hyU0siLCJ1aW4iOiIxMjU0MzE1MTQifQ%3D%3D&data=9oyJixSPcigCQW-saV5eXlcMwV9C6J36XySx-rDHwVwNlofvRmd2ze5sLwFtHTbYbG4nAWIUrI0qftC6aTX9xg&svctype=4&tempid=h5_group_info"; let PUB_E = ""; let PUB_C = ""; let EXAM_API = ""; let EXAM_AES_KEY = ""; let EXAM_BASIC = ""; const STORAGE = { selected: `ytccr_selected_${HOST}`, termId: `ytccr_term_${HOST}`, panelPos: `ytccr_panel_pos_${HOST}`, collapsed: `ytccr_collapsed_${HOST}`, cloudToken: `ytccr_cloud_token_${HOST}`, studentId: `ytccr_student_id_${HOST}`, hwAnswers: `ytccr_hw_answers_${HOST}`, examAnswers: `ytccr_exam_answers_${HOST}`, }; const KEY_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const LOG_LIMIT = 280; const EFF_MID_COUNT = 1; const EFF_GAP_MS = 900; const ACT_GAP_MS = 600; const COURSE_GAP_MS = 1200; const COURSE_PARALLEL = true; const HW_TARGET_SCORE = 100; const HW_MAX_ROUNDS = 6; const HW_GAP_MS = 900; const HW_SUBMIT_WAIT_MS = 600; const EXAM_TARGET_SCORE = 100; const EXAM_MAX_ROUNDS = 24; const EXAM_STUCK_ROUNDS = 3; const EXAM_GAP_MS = 800; const CLOUD_EXAM_BATCH = 6; const CLOUD_EXAM_BATCH_TIMEOUT_MS = 90000; const DEFAULT_RUN_POLICY = { autoHomework: true, autoExam: true, courseParallel: COURSE_PARALLEL, allowExam: false, freeAllowsExam: false, examTargetScore: EXAM_TARGET_SCORE, examStuckRounds: EXAM_STUCK_ROUNDS, examMaxRounds: EXAM_MAX_ROUNDS, hwTargetScore: HW_TARGET_SCORE, hwMaxRounds: HW_MAX_ROUNDS, cloudExamBatch: CLOUD_EXAM_BATCH, actGapMs: ACT_GAP_MS, hwGapMs: HW_GAP_MS, courseGapMs: COURSE_GAP_MS, examGapMs: EXAM_GAP_MS, effMidCount: EFF_MID_COUNT, effGapMs: EFF_GAP_MS, }; function pemPublic(b64) { const body = String(b64 || "").replace(/\s+/g, ""); const lines = body.match(/.{1,64}/g) || []; return ( "-----BEGIN PUBLIC KEY-----\n" + lines.join("\n") + "\n-----END PUBLIC KEY-----" ); } function ensureD9a6() { if (typeof window.d9a6 === "function") return true; if (typeof d9a6 === "function") { window.d9a6 = d9a6; return true; } return false; } function genAesKey16() { let s = ""; for (let i = 0; i < 16; i++) { s += KEY_CHARS.charAt(Math.floor(Math.random() * KEY_CHARS.length)); } return s; } function rsaEncryptPkcs1(plain, pubB64) { const enc = new JSEncrypt(); enc.setPublicKey(pemPublic(pubB64)); const out = enc.encrypt(String(plain)); if (!out) throw new Error("RSA \u52a0\u5bc6\u5931\u8d25"); return out; } function makeSign(studentId) { _rpk(); const ct = rsaEncryptPkcs1(String(studentId), PUB_E); const len = ct.length; const mid = parseInt(len / 2, 10); return ( ct.substring(0, 3) + ct.substring(len - 3) + ct.substring(mid, mid + 4) + String(Date.now()) ); } function aesEncryptZeroPad(plainText, keyStr) { const CJ = CryptoJS; const p = CJ.enc.Utf8.parse(String(plainText)); const k = CJ.enc.Latin1.parse(String(keyStr)); const enc = CJ.AES.encrypt(p, k, { iv: k, mode: CJ.mode.CBC, padding: CJ.pad.ZeroPadding, }); return enc.toString(); } function wrapD9a6(raw) { if (!ensureD9a6()) throw new Error("d9a6 \u672a\u52a0\u8f7d"); const w = window.d9a6(String(raw)); if (!w || w.code === undefined || !w.data) { throw new Error("d9a6 \u5c01\u88c5\u5931\u8d25"); } return String(w.data); } function _bvs(payload) { _rpk(); const keyStr = genAesKey16(); const plain = JSON.stringify(payload); const dataCipher = aesEncryptZeroPad(plain, keyStr); const secretKeyRaw = rsaEncryptPkcs1(keyStr, PUB_C); return { secretKey: wrapD9a6(secretKeyRaw), data: wrapD9a6(dataCipher), _meta: { keyLen: keyStr.length, skRawLen: secretKeyRaw.length, wrapSkLen: null, signProbe: null, }, }; } function getCookie(name) { const m = document.cookie.match( new RegExp("(?:^|;\\s*)" + name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "=([^;]*)") ); return m ? decodeURIComponent(m[1]) : ""; } function getPlatformToken() { return String(getCookie("token") || "").trim(); } function isAuthErrorMsg(msg) { const s = String(msg || ""); return /\u8ba4\u8bc1\u5931\u8d25|\u65e0\u6cd5\u8bbf\u95ee\u8be5\u8d44\u6e90|\u672a\u767b\u5f55|\u8bf7\u5148\u767b\u5f55|\u672a\u627e\u5230\u767b\u5f55|401|unauthorized|token\s*(invalid|expired)|\u767b\u5f55\u5df2\u8fc7\u671f|\u767b\u5f55\u5931\u6548/i.test( s ); } function isLikelyLoginPage() { try { const path = String(location.pathname || "").toLowerCase(); if (/login|signin|passport|sso/.test(path)) return true; const title = String(document.title || ""); if (/\u767b\u5f55|login/i.test(title)) return true; const h = document.querySelector("h1,h2,.login-title,.el-form"); if (h && /\u5b66\u751f\u767b\u5f55|\u7528\u6237\u767b\u5f55|\u767b\u5f55/.test(h.textContent || "")) return true; } catch (_) {} return false; } let _loginWatchTimer = null; let _loginWatchArmed = false; let _loginWatchLastToken = ""; let _loginRefreshing = false; let _lastLoginRefreshAt = 0; let _loginWatchExpectLeave = false; function showLoginGuide(msg) { S.panelHint = msg || "\u8bf7\u5148\u5728\u9875\u9762\u5b8c\u6210\u767b\u5f55"; S.task = "\u5f85\u767b\u5f55"; S.courses = []; S.terms = []; _loginWatchExpectLeave = true; const box = $("ytccr-course-list"); if (box) { box.innerHTML = '
' + '
\u8bf7\u5148\u767b\u5f55\u5e73\u53f0
' + '
\u5728\u9875\u9762\u5b8c\u6210\u767b\u5f55\u540e\uff0c\u811a\u672c\u5c06\u81ea\u52a8\u5237\u65b0\u8bfe\u7a0b
' + "
"; } const sel = $("ytccr-term-select"); if (sel) { sel.innerHTML = ""; sel.style.display = "none"; } renderProfile(); renderRunStatus(); armLoginWatcher(); } function armLoginWatcher() { if (_loginWatchArmed) return; _loginWatchArmed = true; _loginWatchLastToken = getPlatformToken(); log("\u7b49\u5f85\u5e73\u53f0\u767b\u5f55…\u767b\u5f55\u6210\u529f\u540e\u5c06\u81ea\u52a8\u5237\u65b0", "info"); const tryRefresh = async (reason) => { if (S.running || _loginRefreshing) return; const tok = getPlatformToken(); if (!tok) return; const onLogin = isLikelyLoginPage(); const tokenChanged = tok !== _loginWatchLastToken; if (onLogin) { if (tokenChanged) { _loginWatchLastToken = tok; _loginWatchExpectLeave = true; S.panelHint = "\u5df2\u68c0\u6d4b\u5230\u767b\u5f55\uff0c\u7b49\u5f85\u8fdb\u5165\u5b66\u4e60\u9875…"; renderRunStatus(); } return; } const leaving = _loginWatchExpectLeave; if (!tokenChanged && !leaving && reason === "poll") { if (Date.now() - _lastLoginRefreshAt < 2800) return; } _loginWatchLastToken = tok; _loginWatchExpectLeave = false; _lastLoginRefreshAt = Date.now(); _loginRefreshing = true; disarmLoginWatcher(); S.panelHint = "\u767b\u5f55\u6210\u529f\uff0c\u6b63\u5728\u5237\u65b0…"; S.task = "\u5237\u65b0\u4e2d"; renderRunStatus(); log("\u68c0\u6d4b\u5230\u767b\u5f55\u6210\u529f\uff0c\u81ea\u52a8\u5237\u65b0\u8bfe\u7a0b", "ok"); try { await refreshAll(); } catch (e) { const raw = e?.message || e; if (isAuthErrorMsg(raw)) { showLoginGuide("\u767b\u5f55\u672a\u5b8c\u6210\u6216\u4f1a\u8bdd\u65e0\u6548\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55"); } else { log(`\u81ea\u52a8\u5237\u65b0\u5931\u8d25\uff1a${_sue(e)}`, "err"); S.panelHint = _sue(e); renderRunStatus(); armLoginWatcher(); } } finally { _loginRefreshing = false; } }; _loginWatchTimer = setInterval(() => { tryRefresh("poll").catch(() => {}); }, 800); try { window.addEventListener("focus", () => tryRefresh("focus").catch(() => {})); document.addEventListener("visibilitychange", () => { if (!document.hidden) tryRefresh("visible").catch(() => {}); }); } catch (_) {} } function disarmLoginWatcher() { _loginWatchArmed = false; if (_loginWatchTimer) { clearInterval(_loginWatchTimer); _loginWatchTimer = null; } } function tryFindStudentIdFromPage() { const cached = String(GM_getValue(STORAGE.studentId, "") || "").trim(); if (/^\d{10,}$/.test(cached)) return cached; const dig = (v) => { const s = String(v || "").trim(); return /^\d{10,}$/.test(s) ? s : ""; }; const fromObj = (o, depth) => { if (!o || depth > 4) return ""; if (typeof o !== "object") return ""; const direct = dig(o.studentId || o.student_id); if (direct) return direct; for (const k of Object.keys(o)) { const hit = fromObj(o[k], depth + 1); if (hit) return hit; } return ""; }; try { for (const store of [localStorage, sessionStorage]) { for (let i = 0; i < store.length; i++) { const k = store.key(i); const raw = store.getItem(k); if (!raw) continue; if (/studentid/i.test(k || "")) { const d = dig(raw); if (d) return d; } try { const hit = fromObj(JSON.parse(raw), 0); if (hit) return hit; } catch (_) {} } } } catch (_) {} return ""; } const S = { enabled: false, stopFlag: false, running: false, studentId: tryFindStudentIdFromPage(), studentName: "", majorName: "", collegeOrgName: "", terms: [], termId: String(GM_getValue(STORAGE.termId, "") || ""), courses: [], selectedIds: new Set(), selectMode: "all", logs: [], task: "\u5f85\u673a", currentCourse: "", currentAct: "", activeCourses: new Set(), panelHint: "", queueDone: 0, queueTotal: 0, cloudToken: String(GM_getValue(STORAGE.cloudToken, "") || "").trim(), cloudLease: "", cloudLeaseExp: 0, cloudTier: "unknown", cloudRevoked: false, freeUsedCourses: 0, freeCourseLimit: FREE_COURSE_LIMIT, freeAllowsExam: false, examTargetScore: EXAM_TARGET_SCORE, proBuyUrl: PRO_BUY_URL, proExpiresAt: 0, proExpiresText: "", remotePanelNotice: "", panelNoticePath: "/api/ytccr/panel-notice", autoHomework: true, autoExam: true, runPolicy: { ...DEFAULT_RUN_POLICY }, policyReady: false, idCardNo: "", examToken: "", examListCache: null, secretsReady: false, }; function policyNum(key, fallback) { const v = Number(S.runPolicy?.[key]); if (Number.isFinite(v) && v > 0) return v; const fb = Number(fallback); return Number.isFinite(fb) && fb > 0 ? fb : 0; } function requireCloudPolicy() { if (!S.policyReady || !S.runPolicy || typeof S.runPolicy !== "object") { throw new Error("\u4e91\u7aef\u7b56\u7565\u672a\u5c31\u7eea\uff0c\u8bf7\u68c0\u67e5\u6388\u6743\u540e\u91cd\u8bd5"); } } function _arp(policy) { if (!policy || typeof policy !== "object") return; S.runPolicy = { ...DEFAULT_RUN_POLICY, ...S.runPolicy, ...policy }; S.autoHomework = S.runPolicy.autoHomework !== false; S.autoExam = S.runPolicy.autoExam !== false; S.policyReady = true; if (policy.freeAllowsExam != null || policy.free_allows_exam != null) { S.freeAllowsExam = !!(policy.freeAllowsExam ?? policy.free_allows_exam); } else if (S.runPolicy.freeAllowsExam != null) { S.freeAllowsExam = !!S.runPolicy.freeAllowsExam; } if (policy.examTargetScore != null || policy.exam_target_score != null) { const t = Number(policy.examTargetScore ?? policy.exam_target_score); if (Number.isFinite(t) && t > 0) S.examTargetScore = t; } else if (S.runPolicy.examTargetScore != null) { const t = Number(S.runPolicy.examTargetScore); if (Number.isFinite(t) && t > 0) S.examTargetScore = t; } } function _ase(sec) { if (!sec || typeof sec !== "object") return; const pubE = String(sec.pubE || sec.pub_e || "").trim(); const pubC = String(sec.pubC || sec.pub_c || "").trim(); const examApi = String(sec.examApi || sec.exam_api || "").trim(); const examAes = String(sec.examAesKey || sec.exam_aes_key || "").trim(); const examBasic = String(sec.examBasic || sec.exam_basic || "").trim(); if (pubE) PUB_E = pubE; if (pubC) PUB_C = pubC; if (examApi) EXAM_API = examApi.replace(/\/$/, ""); if (examAes) EXAM_AES_KEY = examAes; if (examBasic) EXAM_BASIC = examBasic; S.secretsReady = !!(PUB_E && PUB_C); } function _rpk() { if (!PUB_E || !PUB_C) { throw new Error("\u4e91\u7aef\u53c2\u6570\u672a\u5c31\u7eea\uff0c\u8bf7\u786e\u8ba4\u5df2\u8fde\u63a5\u6388\u6743\u670d\u52a1\u540e\u5237\u65b0"); } } function _rek() { if (!EXAM_API || !EXAM_AES_KEY || !EXAM_BASIC) { throw new Error("\u8003\u8bd5\u53c2\u6570\u672a\u5c31\u7eea\uff0c\u8bf7\u5148\u5b8c\u6210\u6388\u6743\u6821\u9a8c"); } } async function ensureClientBootstrap(force) { if (!force && S.secretsReady && PUB_E && PUB_C) return; await _fcc(); if (!PUB_E || !PUB_C) { throw new Error("\u4e91\u7aef\u53c2\u6570\u672a\u5c31\u7eea\uff0c\u8bf7\u786e\u8ba4\u5df2\u8fde\u63a5\u6388\u6743\u670d\u52a1\u540e\u5237\u65b0"); } } (function loadSelected() { try { const raw = GM_getValue(STORAGE.selected, null); const sel = typeof raw === "string" ? JSON.parse(raw) : raw; if (sel && typeof sel === "object" && !Array.isArray(sel)) { S.selectMode = sel.mode === "none" || sel.mode === "partial" ? sel.mode : "all"; S.selectedIds = new Set( Array.isArray(sel.ids) ? sel.ids.map(String) : [] ); } else if (Array.isArray(sel)) { S.selectMode = sel.length ? "partial" : "all"; S.selectedIds = new Set(sel.map(String)); } } catch (_) {} })(); function saveSelected() { GM_setValue(STORAGE.selected, { mode: S.selectMode, ids: Array.from(S.selectedIds), }); } function $(id) { return document.getElementById(id); } function sleep(ms) { return new Promise((r) => setTimeout(r, Math.max(0, ms))); } function _sue(msg) { let raw = ""; if (msg && typeof msg === "object" && msg.message) raw = String(msg.message); else raw = String(msg || ""); raw = raw.replace(/^Error:\s*/i, "").trim(); if (!raw) return "\u672a\u77e5\u9519\u8bef"; if (/exam_requires_pro|pro_only/i.test(raw)) return "\u8003\u8bd5\u9700\u4e13\u4e1a\u7248"; if (/free_course_quota|quota/i.test(raw)) return "\u514d\u8d39\u989d\u5ea6\u5df2\u7528\u5b8c\uff0c\u8bf7\u5f00\u901a\u4e13\u4e1a\u7248"; if (/504|502|\u8d85\u65f6|timeout/i.test(raw)) return "\u670d\u52a1\u7e41\u5fd9\u6216\u8d85\u65f6\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5"; if ( /\u672a\u68c0\u6d4b.*token|\u8bf7\u5148\u767b\u5f55|\u8ba4\u8bc1\u5931\u8d25|\u65e0\u6cd5\u8bbf\u95ee\u8be5\u8d44\u6e90|\u672a\u767b\u5f55|\u672a\u627e\u5230\u767b\u5f55/i.test(raw) ) { return "\u8bf7\u5148\u767b\u5f55\u5e73\u53f0"; } if (/\u4e91\u7aef\u53c2\u6570\u672a\u5c31\u7eea|\u6388\u6743\u672a\u5c31\u7eea/i.test(raw)) { return "\u4e91\u7aef\u53c2\u6570\u672a\u5c31\u7eea\uff0c\u8bf7\u786e\u8ba4\u5df2\u8fde\u63a5\u6388\u6743\u670d\u52a1\u540e\u5237\u65b0"; } if (/\u4e91\u7aef_not_configured|\u4e91\u7aef_disabled/i.test(raw)) { return "\u7b54\u9898\u670d\u52a1\u672a\u5c31\u7eea\uff0c\u8bf7\u8054\u7cfb\u4f5c\u8005"; } let s = raw .replace(/\u4e91\u7aef[_\s-]*/gi, "") .replace(/\boa34\b|\blease\b|\bqbank\b/gi, "") .replace(/\u8d44\u6599\u5e93|\u672a\u547d\u4e2d|\u6392\u9664\u96c6|\u515c\u5e95|\u4e91\u7aef|AI\s*\u5f02\u5e38/gi, "") .replace(/\s{2,}/g, " ") .trim(); return (s || "\u64cd\u4f5c\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5").slice(0, 120); } function esc(s) { return String(s ?? "") .replace(/&/g, "&") .replace(//g, ">"); } function trunc(s, n) { const t = String(s || ""); return t.length > n ? t.slice(0, n) + "…" : t; } function courseKey(c) { return String(c.studentChooseId || c.termCourseId || ""); } function isCourseSelected(c) { const k = courseKey(c); if (S.selectMode === "all") return true; if (S.selectMode === "none") return false; return S.selectedIds.has(k); } function progressNum(v) { const n = Number(v); return Number.isFinite(n) ? n : 0; } function isActDone(act) { return progressNum(act.progress) >= 100; } function getCloudApiBase() { return DEFAULT_CLOUD_API_BASE.replace(/\/+$/, ""); } function isCloudProTier() { return !S.cloudRevoked && String(S.cloudTier || "").toLowerCase() === "pro"; } function formatCloudTierText() { if (S.cloudRevoked || String(S.cloudTier).toLowerCase() === "revoked") return "\u5df2\u505c\u7528"; if (isCloudProTier()) return "\u4e13\u4e1a\u7248"; if (String(S.cloudTier).toLowerCase() === "free") return "\u4f53\u9a8c\u7248"; return "\u672a\u6821\u9a8c"; } function _crq(path, method, body, headersExtra, timeoutMs) { const url = `${getCloudApiBase()}${path}`; const headers = Object.assign( { Accept: "application/json", "Content-Type": "application/json;charset=UTF-8", }, headersExtra || {} ); if (S.cloudToken) headers.Authorization = `Bearer ${S.cloudToken}`; const luid = String(S.studentId || "").trim(); if (luid) headers["x-learning-user-id"] = luid; const payload = body == null ? null : JSON.stringify(body); const timeout = Number(timeoutMs) > 0 ? Number(timeoutMs) : 30000; return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("\u670d\u52a1\u8bf7\u6c42\u4e0d\u53ef\u7528")); return; } GM_xmlhttpRequest({ method: method || "POST", url, headers, data: payload, timeout, onload(resp) { let data = {}; try { data = JSON.parse(String(resp.responseText || "").trim() || "{}"); } catch (_) { data = {}; } const status = Number(resp.status || 0); if (status < 200 || status >= 300) { const detail = String( data.detail || data.message || data.msg || "" ).trim(); if (status === 504 || status === 502) { reject( new Error( detail || `http_${status}\uff08\u670d\u52a1\u8d85\u65f6\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\uff09` ) ); return; } reject(new Error(detail || `http_${status}`)); return; } resolve(data); }, onerror() { reject(new Error("\u7f51\u7edc\u9519\u8bef")); }, ontimeout() { reject(new Error("\u8bf7\u6c42\u8d85\u65f6")); }, }); }); } function formatProExpireText() { if (!isCloudProTier()) return "—"; if (S.proExpiresText) return S.proExpiresText; const ts = Number(S.proExpiresAt || 0); if (!ts) return "—"; try { const d = new Date(ts * 1000); if (Number.isNaN(d.getTime())) return "—"; const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p( d.getHours() )}:${p(d.getMinutes())}`; } catch (_) { return "—"; } } function renderPanelNotice() { const text = String(S.remotePanelNotice || PANEL_NOTICE || "").trim() || PANEL_NOTICE; const span = $("ytccr-ann-text"); if (span) span.textContent = text; } async function _fpn() { try { const text = await new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("no_gm")); return; } GM_xmlhttpRequest({ method: "GET", url: `${getCloudApiBase()}${ S.panelNoticePath || "/api/ytccr/panel-notice" }`, headers: { Accept: "text/plain, */*" }, timeout: 15000, anonymous: true, onload(resp) { if (resp.status >= 200 && resp.status < 300) { resolve(String(resp.responseText || "").trim()); } else reject(new Error("notice_http_" + resp.status)); }, onerror() { reject(new Error("notice_net")); }, ontimeout() { reject(new Error("notice_timeout")); }, }); }); if (text) S.remotePanelNotice = text; } catch (e) { try { log(`\u516c\u544a\u62c9\u53d6\u5931\u8d25\uff1a${e.message || e}`, "warn"); } catch (_) {} } renderPanelNotice(); } async function _fcc() { try { const data = await _crq("/api/ytccr/client-config", "GET"); if (data?.panelNoticePath) { const p = String(data.panelNoticePath).trim(); if (p) S.panelNoticePath = p.startsWith("/") ? p : `/${p}`; } if (data?.panelNotice || data?.panel_notice) { const n = String(data.panelNotice || data.panel_notice || "").trim(); if (n) S.remotePanelNotice = n; } if (data?.freeCourseLimit != null || data?.free_course_limit != null) { S.freeCourseLimit = Number(data.freeCourseLimit ?? data.free_course_limit) || FREE_COURSE_LIMIT; } if (data?.freeAllowsExam != null || data?.free_allows_exam != null) { S.freeAllowsExam = !!(data.freeAllowsExam ?? data.free_allows_exam); } else { S.freeAllowsExam = false; } if (data?.examTargetScore != null || data?.exam_target_score != null) { const t = Number(data.examTargetScore ?? data.exam_target_score); if (Number.isFinite(t) && t > 0) S.examTargetScore = t; } if (data?.proBuyUrl) { const buy = String(data.proBuyUrl).trim(); if (buy) S.proBuyUrl = buy; } if (data?.policy) _arp(data.policy); if (data?.secrets) _ase(data.secrets); renderPanelNotice(); } catch (_) {} await _fpn(); } async function _cl(force) { const now = Date.now() / 1000; if ( !force && S.cloudLease && S.cloudLeaseExp - now > 60 && S.policyReady ) { return S.cloudLease; } const luid = String(S.studentId || "").trim(); if (!luid) throw new Error("\u672a\u83b7\u53d6\u5b66\u5458\u8eab\u4efd\uff0c\u8bf7\u5148\u767b\u5f55"); const data = await _crq("/api/ytccr/lease", "POST", { learning_user_id: luid, name: S.studentName || "", userName: S.studentName || "", }); S.cloudLease = String(data.lease || ""); S.cloudLeaseExp = Number(data.exp || 0) || 0; S.cloudTier = String( data.tier || (S.cloudToken ? "pro" : "free") ).toLowerCase(); if (data.free_used_courses != null) S.freeUsedCourses = Number(data.free_used_courses) || 0; if (data.free_course_limit != null) S.freeCourseLimit = Number(data.free_course_limit) || FREE_COURSE_LIMIT; if (data.proBuyUrl) S.proBuyUrl = String(data.proBuyUrl).trim() || S.proBuyUrl; const proExp = Number(data.pro_expires_at ?? data.proExpiresAt ?? 0); S.proExpiresAt = Number.isFinite(proExp) && proExp > 0 ? proExp : 0; S.proExpiresText = String( data.pro_expires_at_text || data.proExpiresAtText || "" ).trim(); if (!isCloudProTier()) { S.proExpiresAt = 0; S.proExpiresText = ""; } if (data.policy) _arp(data.policy); if (data.secrets) _ase(data.secrets); if (!S.cloudLease) throw new Error("\u6388\u6743\u65e0\u6548\uff0c\u8bf7\u91cd\u65b0\u4fdd\u5b58\u6388\u6743\u7801"); if (!S.policyReady) throw new Error("\u4e91\u7aef\u672a\u8fd4\u56de\u8fd0\u884c\u7b56\u7565"); try { refreshCloudUi(); } catch (_) {} return S.cloudLease; } function coursePlanId(course) { return String( course?.courseId || course?.id || course?.studentChooseId || course?.chooseId || "" ); } async function _fop(mode, courses) { await _cl(false); requireCloudPolicy(); const list = (courses || []).map((c) => ({ courseId: coursePlanId(c), courseName: String(c?.courseName || c?.name || ""), })); const data = await _crq("/api/ytccr/orchestrate/plan", "POST", { lease: S.cloudLease, mode: mode || "full", courses: list, }); if (data?.policy) _arp(data.policy); if (data?.secrets) _ase(data.secrets); if (!data?.plan || typeof data.plan !== "object") { throw new Error("\u4e91\u7aef\u7f16\u6392\u65e0\u6548\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5"); } if (!Array.isArray(data.plan.tasks) || !data.plan.tasks.length) { throw new Error("\u4e91\u7aef\u672a\u8fd4\u56de\u5b66\u4e60\u8ba1\u5212"); } return data; } function _rpt(planData, courses) { const tasks = Array.isArray(planData?.plan?.tasks) ? planData.plan.tasks : []; if (!tasks.length) throw new Error("\u4e91\u7aef\u672a\u8fd4\u56de\u8bfe\u7a0b\u4efb\u52a1"); const planSteps = Array.isArray(planData?.plan?.steps) ? planData.plan.steps.map(String).filter(Boolean) : []; const byId = new Map(); const byName = new Map(); for (const c of courses || []) { const id = coursePlanId(c); if (id) byId.set(id, c); const n = String(c?.courseName || "").trim(); if (n) byName.set(n, c); } const out = []; for (const t of tasks) { const id = String(t.courseId || t.course_id || "").trim(); const name = String(t.courseName || t.course_name || "").trim(); const course = (id && byId.get(id)) || (name && byName.get(name)) || null; if (!course) continue; const steps = Array.isArray(t.steps) && t.steps.length ? t.steps.map(String).filter(Boolean) : planSteps.slice(); if (!steps.length) { throw new Error("\u4e91\u7aef\u672a\u8fd4\u56de\u5b66\u4e60\u6b65\u9aa4"); } out.push({ course, steps }); } if (!out.length) { throw new Error("\u4e91\u7aef\u8ba1\u5212\u4e0e\u672c\u5730\u8bfe\u7a0b\u65e0\u6cd5\u5339\u914d\uff0c\u8bf7\u5237\u65b0\u540e\u91cd\u8bd5"); } return out; } function createAsyncLock() { let tail = Promise.resolve(); return function withLock(fn) { const run = tail.then(() => fn()); tail = run.then( () => undefined, () => undefined ); return run; }; } const withExamLock = createAsyncLock(); const withQuotaLock = createAsyncLock(); function refreshActiveCourseLabel() { const names = Array.from(S.activeCourses || []); if (!names.length) { S.currentCourse = ""; return; } if (names.length === 1) { S.currentCourse = names[0]; return; } const head = names.slice(0, 2).join("\u3001"); S.currentCourse = names.length > 2 ? `${head} \u7b49${names.length}\u95e8` : head; } async function _ccq(course) { return withQuotaLock(async () => { await _cl(false); const cid = courseKey(course); const data = await _crq("/api/ytccr/course/consume", "POST", { lease: S.cloudLease, course_id: cid, course_name: course.courseName || "", }); if (data.free_used_courses != null) S.freeUsedCourses = Number(data.free_used_courses) || 0; if (data.free_course_limit != null) S.freeCourseLimit = Number(data.free_course_limit) || S.freeCourseLimit; if (data.tier) S.cloudTier = String(data.tier).toLowerCase(); refreshCloudUi(); return data; }); } function refreshCloudUi() { const tierEl = $("ytccr-cloud-tier"); const freeEl = $("ytccr-cloud-free"); const expEl = $("ytccr-cloud-expire"); const tokenEl = $("ytccr-cloud-token"); if (tierEl) { tierEl.textContent = formatCloudTierText(); tierEl.style.color = S.cloudRevoked ? "#b91c1c" : "#0f172a"; } if (freeEl) { freeEl.textContent = isCloudProTier() ? "\u4e0d\u9650" : `${Math.max(0, Number(S.freeUsedCourses) || 0)} / ${Math.max( 0, Number(S.freeCourseLimit) || 0 )}`; } if (expEl) expEl.textContent = formatProExpireText(); if (tokenEl && document.activeElement !== tokenEl) { tokenEl.value = S.cloudToken || ""; } renderPanelNotice(); } function log(msg, level) { const row = { t: new Date().toLocaleTimeString(), m: String(msg || ""), l: level || "info", }; S.logs.unshift(row); if (S.logs.length > LOG_LIMIT) S.logs.length = LOG_LIMIT; renderLog(); } function renderLog() { const box = $("ytccr-run-log"); if (!box) return; box.innerHTML = S.logs .slice(0, 80) .map((r) => { const cls = r.l === "ok" ? "ytccr-log-ok" : r.l === "warn" ? "ytccr-log-warn" : r.l === "err" ? "ytccr-log-err" : "ytccr-log-info"; return `
${esc( r.t )} ${esc(r.m)}
`; }) .join(""); } function renderRunStatus() { const st = $("ytccr-auto-status"); if (st) st.textContent = S.running ? "\u8fd0\u884c\u4e2d" : "\u5df2\u505c\u6b62"; const done = $("ytccr-queue-done"); const total = $("ytccr-queue-total"); const pct = $("ytccr-queue-percent"); const bar = $("ytccr-queue-progress"); const task = $("ytccr-current-task"); if (done) done.textContent = String(S.queueDone); if (total) total.textContent = String(S.queueTotal); const p = S.queueTotal > 0 ? Math.min(100, Math.round((S.queueDone / S.queueTotal) * 100)) : 0; if (pct) pct.textContent = `${p}%`; if (bar) bar.style.width = `${p}%`; if (task) { const parts = [S.task]; if (S.currentCourse) parts.push(S.currentCourse); if (S.currentAct) parts.push(S.currentAct); task.textContent = parts.filter(Boolean).join(" · "); } const hint = $("ytccr-panel-hint"); if (hint) hint.textContent = S.panelHint || ""; syncStartStop(); } function syncStartStop() { const start = $("ytccr-start"); const stop = $("ytccr-stop"); const examStart = $("ytccr-exam-start"); if (start) { start.disabled = !!S.running; start.classList.toggle("ytccr-btn-off", !!S.running); } if (examStart) { examStart.disabled = !!S.running; examStart.classList.toggle("ytccr-btn-off", !!S.running); } if (stop) { stop.disabled = !S.running; stop.classList.toggle("ytccr-btn-off", !S.running); } } async function platformRequest(method, path, body, opts) { const token = getPlatformToken(); if (!token) throw new Error("\u672a\u627e\u5230\u767b\u5f55 token\uff0c\u8bf7\u5148\u767b\u5f55\u5e73\u53f0"); if (!S.studentId && !(opts && opts.skipSign)) { } const ts = Date.now(); let url = path.startsWith("http") ? path : API_BASE + path; const u = new URL(url, ORIGIN); if (String(method || "GET").toUpperCase() === "GET") { u.searchParams.set("_t", String(ts)); } const headers = { Accept: "application/json, text/plain, */*", "Content-Type": "application/json;charset=UTF-8", Authorization: `Bearer ${token}`, }; if (S.studentId) { headers.sign = makeSign(S.studentId); } else if (opts && opts.signStudentId) { headers.sign = makeSign(opts.signStudentId); } const init = { method: method || "GET", headers, credentials: "include", cache: "no-store", }; if (body != null && String(method).toUpperCase() !== "GET") { init.body = typeof body === "string" ? body : JSON.stringify(body); } const resp = await fetch(u.toString(), init); let data = null; const text = await resp.text(); try { data = JSON.parse(text || "{}"); } catch (_) { data = { raw: text }; } if (!resp.ok) { throw new Error( String(data?.msg || data?.message || `HTTP ${resp.status}`) ); } if (data && data.code != null && Number(data.code) !== 200) { throw new Error(String(data.msg || data.message || `\u4e1a\u52a1\u7801 ${data.code}`)); } return data; } async function apiGet(path, opts) { return platformRequest("GET", path, null, opts); } async function apiPost(path, body, opts) { return platformRequest("POST", path, body, opts); } function rememberStudentId(id) { const sid = String(id || "").trim(); if (!/^\d{10,}$/.test(sid)) return; S.studentId = sid; try { GM_setValue(STORAGE.studentId, sid); } catch (_) {} } async function loadMajorInfo() { const res = await apiGet("/yt/student/myStudy/getMajorInfo"); const d = res?.data || {}; rememberStudentId(d.studentId || S.studentId); S.majorName = String(d.majorName || "").trim(); S.collegeOrgName = String(d.collegeOrgName || "").trim(); S.studentName = String( d.studentName || d.realName || d.teacherName || S.studentName || "" ).trim(); return d; } async function loadMajorInfoSafe() { const token = getPlatformToken(); if (!token) throw new Error("\u672a\u767b\u5f55"); if (!S.studentId) { const found = tryFindStudentIdFromPage(); if (found) rememberStudentId(found); } if (S.studentId) return loadMajorInfo(); const ts = Date.now(); const url = `${API_BASE}/yt/student/myStudy/getMajorInfo?_t=${ts}`; const resp = await fetch(url, { method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${token}`, sign: makeSign("0"), }, credentials: "include", cache: "no-store", }); const data = await resp.json(); if (data?.code === 200 && data.data?.studentId) { rememberStudentId(data.data.studentId); S.majorName = String(data.data.majorName || ""); S.collegeOrgName = String(data.data.collegeOrgName || ""); return loadMajorInfo(); } throw new Error(data?.msg || "\u83b7\u53d6\u5b66\u5458\u4fe1\u606f\u5931\u8d25\uff08\u8bf7\u5148\u6253\u5f00\u5b66\u4e60\u9875\u5237\u65b0\uff09"); } async function loadTerms() { const res = await apiGet("/yt/student/myStudy/getTermSelList"); const list = Array.isArray(res?.data) ? res.data : []; S.terms = list; if (!S.termId) { const cur = list.find((t) => Number(t.isCurrentTerm) === 1); S.termId = String((cur || list[0] || {}).termId || ""); if (S.termId) GM_setValue(STORAGE.termId, S.termId); } return list; } async function loadCourses() { if (!S.termId) await loadTerms(); if (!S.termId) throw new Error("\u65e0\u5b66\u671f\u4fe1\u606f"); const res = await apiGet( `/yt/student/myStudy/getTermChooseCourseList?termId=${encodeURIComponent( S.termId )}` ); const list = Array.isArray(res?.data?.courseList) ? res.data.courseList : []; S.courses = list.map((c) => ({ courseName: String(c.courseName || ""), studentChooseId: String(c.studentChooseId || ""), termCourseId: String(c.termCourseId || ""), progress: progressNum(c.progress), score: c.score, studyWeight: c.studyWeight, testWeight: c.testWeight, credit: c.credit, })); return S.courses; } async function loadChapterActs(course) { const res = await apiGet( `/yt/student/act/getChapterActList?termCourseId=${encodeURIComponent( course.termCourseId )}&studentChooseId=${encodeURIComponent(course.studentChooseId)}` ); const chapters = Array.isArray(res?.data) ? res.data : []; const videos = []; const homeworks = []; for (const ch of chapters) { const actList = Array.isArray(ch.actList) ? ch.actList : []; for (const a of actList) { const actType = Number(a.actType); const row = { actId: String(a.actId || ""), actName: String(a.actName || ""), actType, progress: progressNum(a.progress), actState: a.actState, score: a.score, chapterName: String(ch.chapterName || ""), }; if (actType === 1) videos.push(row); else if (actType === 3) homeworks.push(row); } } return { videos, homeworks }; } async function queryVideo(actId, studentChooseId) { const res = await apiGet( `/yt/student/video/queryById?actId=${encodeURIComponent( actId )}&studentChooseId=${encodeURIComponent(studentChooseId)}` ); return res?.data || {}; } async function addStudyTimeCache(actId, studentChooseId) { return apiPost("/yt/student/myStudy/addStudyTimeCache", { objectId: actId, studentChooseId, studyType: 3, }); } async function saveVideoProgress(actId, studentChooseId, videoTime, videoAllTime) { const body = _bvs({ actId: String(actId), studentChooseId: String(studentChooseId), videoTime: Number(videoTime) || 0, videoAllTime: Number(videoAllTime) || 0, }); const { secretKey, data } = body; return apiPost("/yt/student/video/save", { secretKey, data }); } function loadHwAnswerBag() { try { const raw = GM_getValue(STORAGE.hwAnswers, null); const obj = typeof raw === "string" ? JSON.parse(raw) : raw; return obj && typeof obj === "object" ? obj : {}; } catch (_) { return {}; } } function saveHwAnswerBag(map) { GM_setValue(STORAGE.hwAnswers, map || {}); } function hwCacheKey(actId) { return String(actId || ""); } function flattenQastoreList(data) { const out = []; const classifyList = Array.isArray(data?.classifyList) ? data.classifyList : []; for (const c of classifyList) { const list = Array.isArray(c.qastoreList) ? c.qastoreList : []; for (let i = 0; i < list.length; i++) { const q = list[i] || {}; out.push({ ...q, classifyId: String(q.classifyId || c.classifyId || ""), classifyName: String(q.classifyName || c.classifyName || ""), childIndex: i, }); } } return out; } function pickCorrectOptionIds(q) { const opts = Array.isArray(q?.optList) ? q.optList : []; return opts .filter((o) => Number(o.isAnswer) === 1) .map((o) => String(o.optionId || "")) .filter(Boolean); } function randomOptionResult(q) { const opts = Array.isArray(q?.optList) ? q.optList : []; const ids = opts.map((o) => String(o.optionId || "")).filter(Boolean); if (!ids.length) return ""; return ids[Math.floor(Math.random() * ids.length)]; } function mergeAnswerMapFromResult(questions, bag) { const next = { ...(bag || {}) }; for (const q of questions) { const qid = String(q.qastoreId || ""); if (!qid) continue; const correct = pickCorrectOptionIds(q); if (correct.length) next[qid] = correct.join(","); } return next; } async function getTestInfo(course, act) { const res = await apiGet( `/yt/student/test/getTestInfoById?actId=${encodeURIComponent( act.actId )}&termCourseId=${encodeURIComponent( course.termCourseId )}&studentChooseId=${encodeURIComponent(course.studentChooseId)}` ); return res?.data || {}; } async function getTestResultList(course, act) { const res = await apiGet( `/yt/student/test/getTestResultList?pageNum=1&pageSize=100&actId=${encodeURIComponent( act.actId )}&studentChooseId=${encodeURIComponent( course.studentChooseId )}&termCourseId=${encodeURIComponent(course.termCourseId)}` ); return Array.isArray(res?.rows) ? res.rows : []; } async function getTestQastoreList(course, act) { const res = await apiGet( `/yt/student/test/getTestqastoreList?actId=${encodeURIComponent( act.actId )}&studentChooseId=${encodeURIComponent( course.studentChooseId )}&termCourseId=${encodeURIComponent(course.termCourseId)}` ); return res?.data || {}; } async function getTestQastoreResult(course, act, studentTestId) { const res = await apiGet( `/yt/student/test/getTestQastoreResult?actId=${encodeURIComponent( act.actId )}&studentChooseId=${encodeURIComponent( course.studentChooseId )}&studentTestId=${encodeURIComponent(studentTestId)}` ); return res?.data || {}; } async function saveTest(course, act, questions, testTime) { const resultList = questions.map((q, idx) => { const copy = { ...q }; copy.childIndex = idx; copy.testQastoreId = copy.testQastoreId ?? null; copy.resultId = copy.resultId ?? null; copy.resultState = null; copy.qastoreLabel = copy.qastoreLabel ?? null; copy.qastoreAnalysis = copy.qastoreAnalysis ?? null; copy.orderNo = copy.orderNo ?? null; copy.initQastorePoint = copy.initQastorePoint ?? null; copy.comment = copy.comment ?? null; copy.answers = copy.answers ?? null; if (copy.qastoreResult == null) copy.qastoreResult = ""; return copy; }); const res = await apiPost("/yt/student/test/save", { actId: String(act.actId), termCourseId: String(course.termCourseId), studentChooseId: String(course.studentChooseId), testTime: Math.max(1, Math.floor(Number(testTime) || 30)), resultList, }); return res?.data || {}; } function bestResultRow(rows) { let best = null; for (const r of rows || []) { const pts = Number(r.testPoint ?? r.correctRate ?? 0); if (!best || pts > Number(best.testPoint ?? best.correctRate ?? 0)) { best = r; } } return best; } async function harvestHomeworkAnswers(course, act, studentTestId, bag) { if (!studentTestId) return bag || {}; const data = await getTestQastoreResult(course, act, studentTestId); const qs = flattenQastoreList(data); return mergeAnswerMapFromResult(qs, bag); } async function _soh(course, act) { if (S.stopFlag) return false; S.currentAct = act.actName; S.task = "\u505a\u4f5c\u4e1a"; renderRunStatus(); await addStudyTimeCache(act.actId, course.studentChooseId); let info = await getTestInfo(course, act); const hwTarget = policyNum("hwTargetScore", HW_TARGET_SCORE) || HW_TARGET_SCORE; const hwMaxRounds = policyNum("hwMaxRounds", HW_MAX_ROUNDS) || HW_MAX_ROUNDS; const bestKnown = Number(info.maxTestPoint ?? 0); if (bestKnown >= hwTarget) { log(`\u4f5c\u4e1a\u5df2\u6ee1\u5206\uff0c\u8df3\u8fc7\uff1a${act.actName}\uff08${bestKnown}\uff09`, "ok"); return true; } const allMap = loadHwAnswerBag(); const key = hwCacheKey(act.actId); let bag = { ...(allMap[key] || {}) }; try { const rows = await getTestResultList(course, act); const best = bestResultRow(rows); if (best?.studentTestId) { bag = await harvestHomeworkAnswers( course, act, best.studentTestId, bag ); allMap[key] = bag; saveHwAnswerBag(allMap); log( `\u5df2\u7f13\u5b58\u5386\u53f2\u7b54\u6848 ${Object.keys(bag).length} \u9898 · \u5386\u53f2\u6700\u9ad8 ${Number( best.testPoint ?? best.correctRate ?? 0 )}`, "info" ); } } catch (e) { log(`\u62c9\u53d6\u5386\u53f2\u6210\u7ee9\uff1a${e.message || e}`, "warn"); } let lastScore = bestKnown; for (let round = 1; round <= hwMaxRounds; round++) { if (S.stopFlag) return false; const paper = await getTestQastoreList(course, act); const questions = flattenQastoreList(paper); if (!questions.length) { log(`\u4f5c\u4e1a\u65e0\u9898\u76ee\uff1a${act.actName}`, "warn"); return false; } let hit = 0; let miss = 0; for (const q of questions) { const qid = String(q.qastoreId || ""); const known = bag[qid]; if (known) { q.qastoreResult = String(known); hit += 1; } else { q.qastoreResult = randomOptionResult(q); miss += 1; } } log( `\u4f5c\u4e1a\u7b2c ${round} \u8f6e\uff1a${act.actName} · \u5df2\u77e5 ${hit} / \u968f\u673a ${miss} / \u5171 ${questions.length}`, miss ? "warn" : "info" ); const testTime = Math.max( 20, Math.min(240, questions.length * 4 + Math.floor(Math.random() * 20)) ); const saved = await saveTest(course, act, questions, testTime); const studentTestId = String(saved.studentTestId || ""); await sleep(HW_SUBMIT_WAIT_MS); if (studentTestId) { try { bag = await harvestHomeworkAnswers(course, act, studentTestId, bag); allMap[key] = bag; saveHwAnswerBag(allMap); } catch (e) { log(`\u67e5\u770b\u7b54\u6848\u5931\u8d25\uff1a${e.message || e}`, "warn"); } } info = await getTestInfo(course, act); lastScore = Number(info.maxTestPoint ?? lastScore); log(`\u4f5c\u4e1a\u5f53\u524d\u6700\u9ad8\u5206\uff1a${lastScore}`, lastScore >= hwTarget ? "ok" : "info"); if (lastScore >= hwTarget) { log(`\u4f5c\u4e1a\u5b8c\u6210\uff1a${act.actName}`, "ok"); return true; } await sleep(policyNum("hwGapMs", HW_GAP_MS)); } log(`\u4f5c\u4e1a\u672a\u8fbe\u76ee\u6807\u5206\uff08${lastScore}/${hwTarget}\uff09\uff1a${act.actName}`, "warn"); return false; } function _eep(plain) { _rek(); const key = CryptoJS.enc.Utf8.parse(EXAM_AES_KEY); const data = CryptoJS.enc.Utf8.parse(String(plain || "")); return CryptoJS.AES.encrypt(data, key, { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7, }).toString(); } function loadExamBag() { try { const raw = GM_getValue(STORAGE.examAnswers, null); const obj = typeof raw === "string" ? JSON.parse(raw) : raw; return obj && typeof obj === "object" ? obj : {}; } catch (_) { return {}; } } function saveExamBag(map) { GM_setValue(STORAGE.examAnswers, map || {}); } function examBagKey(subjectId) { return String(subjectId || ""); } function examRequest(method, path, body, opts) { const url = path.startsWith("http") ? path : EXAM_API + path; const headers = Object.assign( { Accept: "application/json, text/plain, */*", "Content-Type": "application/json;charset=UTF-8", }, (opts && opts.headers) || {} ); if (S.examToken) headers.token = S.examToken; const payload = body == null ? null : JSON.stringify(body); return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("GM_xmlhttpRequest \u4e0d\u53ef\u7528")); return; } GM_xmlhttpRequest({ method: method || "GET", url, headers, data: payload, timeout: 60000, onload(resp) { let data = {}; try { data = JSON.parse(String(resp.responseText || "").trim() || "{}"); } catch (_) { data = { raw: resp.responseText }; } const status = Number(resp.status || 0); if (status < 200 || status >= 300) { reject( new Error( String(data.msg || data.message || data.detail || `http_${status}`) ) ); return; } if (data && data.code != null && Number(data.code) !== 1) { reject(new Error(String(data.msg || `exam_code_${data.code}`))); return; } resolve(data); }, onerror() { reject(new Error("\u8003\u8bd5\u5e73\u53f0\u7f51\u7edc\u9519\u8bef")); }, ontimeout() { reject(new Error("\u8003\u8bd5\u5e73\u53f0\u8d85\u65f6")); }, }); }); } async function loadStudentProfile() { const res = await apiGet("/yt/student/myStudentInfo/getInfo"); const d = res?.data || {}; S.idCardNo = String(d.idCardNo || S.idCardNo || "").trim(); S.studentName = String(d.realName || S.studentName || "").trim(); if (d.studentId) rememberStudentId(d.studentId); return d; } async function examLogin() { if (!S.idCardNo) await loadStudentProfile(); if (!S.idCardNo || S.idCardNo.length < 6) { throw new Error("\u672a\u83b7\u53d6\u8eab\u4efd\u8bc1\u53f7\uff0c\u65e0\u6cd5\u767b\u5f55\u8003\u8bd5\u5e73\u53f0"); } const pwdPlain = S.idCardNo.slice(-6); const data = await examRequest( "POST", "/api/login", { username: S.idCardNo, password: _eep(pwdPlain), loginType: "1", }, { headers: { Authorization: EXAM_BASIC } } ); S.examToken = String(data?.data?.token || "").trim(); if (!S.examToken) throw new Error("\u8003\u8bd5\u767b\u5f55\u672a\u8fd4\u56de token"); log(`\u8003\u8bd5\u5e73\u53f0\u5df2\u767b\u5f55\uff1a${data?.data?.userName || S.studentName || ""}`, "ok"); return S.examToken; } async function _eel() { if (S.examToken) return S.examToken; return examLogin(); } async function getExamList() { await _eel(); const res = await examRequest("GET", "/api/client/getExamList"); return Array.isArray(res?.data) ? res.data : []; } function mergeExamQuestionLists(primary, extra) { const byId = new Map(); for (const q of primary || []) { const id = String(q?.questionId || "").trim(); if (id) byId.set(id, q); } for (const q of extra || []) { const id = String(q?.questionId || "").trim(); if (!id) continue; const opts = Array.isArray(q.optionList) ? q.optionList : []; if (!opts.length) continue; if (!byId.has(id)) { byId.set(id, q); continue; } const cur = byId.get(id); if (!cur.questionParent && q.questionParent) { cur.questionParent = q.questionParent; } if (!cur._passage && q._passage) cur._passage = q._passage; if (!cur.ruleId && q.ruleId) cur.ruleId = q.ruleId; if (!cur.detailId && q.detailId) cur.detailId = q.detailId; } return Array.from(byId.values()); } async function getExamPaper(subjectId, appointmentId) { const res = await examRequest( "GET", `/api/client/getTestQuestion?subjectId=${encodeURIComponent( subjectId )}&appointmentId=${encodeURIComponent(appointmentId)}` ); const raw = Array.isArray(res?.data) ? res.data : []; return { raw, flat: normalizeExamQuestionList(raw), }; } async function getExamQuestions(subjectId, appointmentId) { const paper = await getExamPaper(subjectId, appointmentId); return paper.flat; } async function enrichExamQuestionsFromDetail( questions, historyId, appointmentId ) { let list = Array.isArray(questions) ? questions.slice() : []; if (!historyId) return list; try { const detail = await getExamAnswerDetail(historyId, appointmentId); const fromDetail = normalizeExamQuestionList( Array.isArray(detail?.questionList) ? detail.questionList : [] ); if (fromDetail.length) { const before = list.length; list = mergeExamQuestionLists(list, fromDetail); if (list.length > before) { log( `\u7b54\u5377\u5df2\u8865\u5168\u81f3 ${list.length} \u9898`, "info" ); } } } catch (_) { } return list; } function resolveExamAnswerIds(q, bag, cloudMap, { warnEmpty } = {}) { let ans = pickExamAnswer(q, bag, cloudMap); if (!ans.length) { const qid = String(q.questionId || ""); const opts = Array.isArray(q.optionList) ? q.optionList : []; const banned = new Set(getEliminatedOptionIds(q, bag)); if (!bag?.answers?.[qid]) { for (const id of bag?.lastSubmit?.[qid] || []) banned.add(String(id)); } const fallback = opts .map((o) => String(o.optionId || "")) .filter((id) => id && !banned.has(id)); if (fallback.length) { ans = Number(q.questionType) === 2 ? fallback.slice() : [fallback[0]]; if (warnEmpty) { log( `\u9898\u76ee\u5df2\u81ea\u52a8\u4f5c\u7b54\uff1a${stripExamHtml(q.questionTitle).slice(0, 40)}`, "info" ); } } else if (opts.length) { ans = [String(opts[0].optionId || "")].filter(Boolean); } } return ans; } function makeExamSaveLeaf(q, ans, historyId) { return { ruleId: q.ruleId, historyId: q.historyId || historyId, questionId: q.questionId, questionType: q.questionType, detailId: q.detailId, answer: ans, subQuestion: [], }; } function buildExamAnswerListFromRaw(rawPaper, bag, cloudMap, historyId) { const answerList = []; let hit = 0; let miss = 0; let leafCount = 0; if (!bag.lastSubmit || typeof bag.lastSubmit !== "object") bag.lastSubmit = {}; for (const q of rawPaper || []) { if (!q || typeof q !== "object") continue; const subs = examNestedSubs(q); const isReadingParent = subs.length > 0 || Number(q.questionType) === 7; if (isReadingParent) { const subItems = []; for (const sq of subs) { leafCount += 1; const child = Object.assign({}, sq, { _passage: String(q.questionTitle || sq._passage || ""), }); const ans = resolveExamAnswerIds(child, bag, cloudMap, { warnEmpty: true, }); if (ans.length) hit += 1; else miss += 1; if (ans.length) bag.lastSubmit[String(child.questionId)] = ans.slice(); subItems.push(makeExamSaveLeaf(child, ans, historyId)); } answerList.push({ ruleId: q.ruleId, historyId: q.historyId || historyId, questionId: q.questionId, questionType: q.questionType || 7, detailId: q.detailId, answer: [], subQuestion: subItems, }); } else { leafCount += 1; const ans = resolveExamAnswerIds(q, bag, cloudMap, { warnEmpty: true }); if (ans.length) hit += 1; else miss += 1; if (ans.length) bag.lastSubmit[String(q.questionId)] = ans.slice(); answerList.push(makeExamSaveLeaf(q, ans, historyId)); } } return { answerList, hit, miss, leafCount }; } async function saveExamAnswerList(answerList) { for (const item of answerList || []) { try { await saveExamOne(item); } catch (e) { log(`\u5355\u9898\u4fdd\u5b58\u5931\u8d25\uff1a${e.message || e}`, "warn"); } if (S.stopFlag) return false; } return true; } function stripExamHtml(s) { return String(s || "") .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/&/gi, "&") .replace(/"/gi, '"') .replace(/\s+/g, " ") .trim(); } function examNestedSubs(q) { if (!q || typeof q !== "object") return []; const candidates = [ q.subQuestionList, q.subQuestion, q.subQuestions, q.childList, q.children, q.questionList, ]; for (const c of candidates) { if (Array.isArray(c) && c.length) return c; } return []; } function normalizeExamQuestionList(raw) { const list = Array.isArray(raw) ? raw.slice() : []; const byId = new Map(); const byDetail = new Map(); for (const q of list) { const id = String(q?.questionId || "").trim(); const did = String(q?.detailId || "").trim(); if (id) byId.set(id, q); if (did) byDetail.set(did, q); } const out = []; const seen = new Set(); const pushOne = (q, passage) => { if (!q || typeof q !== "object") return; const qid = String(q.questionId || "").trim(); if (!qid || seen.has(qid)) return; const opts = Array.isArray(q.optionList) ? q.optionList : []; if (!opts.length) return; // \u7eaf\u6750\u6599\u7236\u9898\uff08type7\uff09\u65e0\u53ef\u9009\u9879\uff0c\u8df3\u8fc7 seen.add(qid); const pid = String(q.questionParent || "").trim(); let pass = String(passage || q._passage || "").trim(); if (!pass && pid) { const parent = byDetail.get(pid) || byId.get(pid); if (parent) pass = String(parent.questionTitle || ""); } out.push(Object.assign({}, q, { _passage: pass })); }; const walk = (nodes, passage) => { for (const q of nodes || []) { if (!q || typeof q !== "object") continue; const subs = examNestedSubs(q); const opts = Array.isArray(q.optionList) ? q.optionList : []; const title = String(q.questionTitle || ""); if (subs.length) { const nextPass = opts.length ? passage : title || passage; if (opts.length) pushOne(q, passage); walk(subs, nextPass || title); } else { pushOne(q, passage); } } }; walk(list, ""); if (!out.length && list.length) { for (const q of list) pushOne(q, ""); } return out; } function examQuestionStemForCloud(q) { const passage = stripExamHtml(q._passage || "").slice(0, 2500); const title = stripExamHtml(q.questionTitle || ""); if (passage) return `\u3010\u9605\u8bfb\u6750\u6599\u3011${passage}\n\u3010\u95ee\u9898\u3011${title}`.slice(0, 4000); return title.slice(0, 4000); } async function synExamScoreState(appointmentId, subjectId, historyId) { return examRequest("POST", "/api/client/synScoreState", { appointmentId, examType: 1, historyId, subjectId, }); } async function saveExamOne(item) { return examRequest("POST", "/api/client/saveOneQuestionAnswer", [item]); } async function submitExamPaper(appointmentId, historyId, answerList) { return examRequest("POST", "/api/client/saveQuestionAnswer", { appointmentId, historyId, answerList, submitState: 1, }); } async function getExamAnswerDetail(historyId, appointmentId) { const res = await examRequest( "GET", `/api/client/getExamAnswerDetail?historyId=${encodeURIComponent( historyId )}&appointmentId=${encodeURIComponent(appointmentId)}` ); return res?.data || {}; } function parseExamAnswerField(ans) { if (Array.isArray(ans)) return ans.map(String).filter(Boolean); const s = String(ans || "").trim(); if (!s) return []; return s.split(",").map((x) => x.trim()).filter(Boolean); } function hintLetterFromAnalysis(q) { const blob = `${q?.questionAnalysis || ""} ${q?.optionList?.map((o) => o?.optionContent || "").join(" ") || ""}`; const m = blob.match(/\{?\s*\u6b63\u786e\u7b54\u6848\s*\}?\s*[:\uff1a\s]*([A-Da-d])\b/) || blob.match(/\u6b63\u786e\u7b54\u6848\s*[:\uff1a]\s*([A-Da-d])\b/); return m ? String(m[1]).toUpperCase() : ""; } function cleanExamOptionText(s) { return stripExamHtml(s) .replace(/\{\u8d44\u6599\u5e93\u5206\u7c7b\}[\s\S]*$/g, "") .replace(/\{\u9898\u76ee\u89e3\u6790\}[\s\S]*$/g, "") .replace(/\{\u6b63\u786e\u7b54\u6848\}[\s\S]*$/g, "") .replace(/`std::`\u524d\u7f00\u3002?/g, "") .trim(); } function isExamQuestionCorrect(q) { if (Number(q?.answerState) === 2) return false; if (q?.isRight === true || Number(q?.isRight) === 1) return true; if (q?.isRight === false || Number(q?.isRight) === 0) return false; const score = Number(q?.answerScore); const point = Number(q?.questionPoint); if (Number.isFinite(point) && point > 0) { if (!Number.isFinite(score)) return false; return score + 1e-6 >= point; } if (Number(q?.answerState) === 1) { if (Number.isFinite(score) && score <= 0) return false; return true; } return false; } function otherOptionIds(q, usedIds) { const used = new Set((usedIds || []).map(String)); return (Array.isArray(q?.optionList) ? q.optionList : []) .map((o) => String(o.optionId || "")) .filter((id) => id && !used.has(id)); } function harvestExamDetail(detail, bag, paperQs) { const next = { answers: { ...(bag?.answers || {}) }, eliminated: { ...(bag?.eliminated || {}) }, lastSubmit: { ...(bag?.lastSubmit || {}) }, forcedNext: { ...(bag?.forcedNext || {}) }, }; const paperOpts = new Map(); for (const pq of paperQs || []) { const id = String(pq?.questionId || ""); if (id && Array.isArray(pq.optionList) && pq.optionList.length) { paperOpts.set(id, pq.optionList); } } const upsert = []; const wrongTitles = []; const list = Array.isArray(detail?.questionList) ? detail.questionList : []; for (const q of list) { const qid = String(q.questionId || ""); if (!qid) continue; const submitted = Array.isArray(next.lastSubmit[qid]) ? next.lastSubmit[qid].map(String).filter(Boolean) : []; let detailIds = parseExamAnswerField(q.answer); const point = Number(q.questionPoint); const score = Number(q.answerScore); const correct = isExamQuestionCorrect(q); const clearlyWrong = Number(q.answerState) === 2 || (Number.isFinite(point) && point > 0 && Number.isFinite(score) && score + 1e-6 < point); let ids = clearlyWrong && submitted.length ? submitted.slice() : detailIds; if (!ids.length && submitted.length) ids = submitted.slice(); if (correct && (detailIds.length || ids.length)) { const useIds = detailIds.length ? detailIds : ids; const already = !!bag?.answers?.[qid]; next.answers[qid] = useIds.join(","); delete next.eliminated[qid]; delete next.forcedNext[qid]; if (already) continue; const opts = (Array.isArray(q.optionList) && q.optionList.length ? q.optionList : paperOpts.get(qid)) || []; const idset = new Set(useIds.map(String)); const texts = []; const letters = []; opts.forEach((o, i) => { if (idset.has(String(o.optionId || ""))) { texts.push(cleanExamOptionText(o.optionContent || "")); letters.push(optionLetter(o, i)); } }); if (texts.length || letters.length) { upsert.push({ stem: stripExamHtml(q.questionTitle || ""), answer_text: texts.join("|||"), answer_letter: letters.join(""), paper_name: String(detail.subjectName || ""), source: "ytccr_exam_ok", }); } } else if (clearlyWrong || (!correct && ids.length)) { delete next.answers[qid]; const qtype = Number(q.questionType); const opts = (Array.isArray(q.optionList) && q.optionList.length ? q.optionList : paperOpts.get(qid)) || []; const tried = (submitted.length ? submitted : ids).map(String); const title = stripExamHtml(q.questionTitle || qid).slice(0, 36); const qForRest = { optionList: opts }; if (qtype === 2) { const key = tried.slice().sort().join(","); const arr = Array.isArray(next.eliminated[qid]) ? next.eliminated[qid] : []; if (key && !arr.includes(key)) arr.push(key); next.eliminated[qid] = arr; wrongTitles.push(title); } else { next.eliminated[qid] = tried.slice(); const rest = otherOptionIds(qForRest, tried); const nextId = String(rest[0] || ""); if (nextId) { next.forcedNext[qid] = nextId; wrongTitles.push(`${title}\uff08\u5df2\u6362\u9009\u9879\uff09`); } else { next.eliminated[qid] = []; delete next.forcedNext[qid]; wrongTitles.push(`${title}`); } } } } if (wrongTitles.length) { log( `\u672c\u8f6e\u6709 ${wrongTitles.length} \u9898\u672a\u6ee1\u5206\uff0c\u5c06\u7ee7\u7eed\u91cd\u8bd5\uff1a${wrongTitles[0]}${ wrongTitles.length > 1 ? "…" : "" }`, "warn" ); } next._upsert = upsert; return next; } function examTarget() { const t = Number(S.examTargetScore || EXAM_TARGET_SCORE); return Number.isFinite(t) && t > 0 ? t : EXAM_TARGET_SCORE; } function canUseExamFeature() { if (isCloudProTier()) return true; return !!S.freeAllowsExam; } async function _ces(questions, subjectName, subjectId, bag) { const known = bag?.answers || {}; const localFilled = {}; const needCloud = []; let multiProbe = 0; for (const q of questions) { const qid = String(q.questionId || ""); if (bag?.forcedNext?.[qid]) { localFilled[qid] = { optionIds: [String(bag.forcedNext[qid])], source: "forced_next", }; continue; } if (known[qid]) continue; if (Number(q.questionType) === 2) { const multi = nextMultiCombo(q, bag); if (multi && multi.length) { localFilled[qid] = { optionIds: multi, source: "multi_probe" }; multiProbe += 1; continue; } } const forced = forceAnswerByElimination(q, bag); if (forced && forced.length) { localFilled[qid] = { optionIds: forced, source: "elim" }; continue; } needCloud.push(q); } if (multiProbe) { } if (!needCloud.length) { return { answers: localFilled, stats: { total: Object.keys(localFilled).length, qbank: 0, ai: 0, miss: 0, elim: Object.keys(localFilled).length, }, }; } await _cl(false); const merged = { answers: { ...localFilled }, stats: { total: needCloud.length + Object.keys(localFilled).length, qbank: 0, ai: 0, miss: 0, elim: Object.keys(localFilled).length, }, ai_error: "", ai_ready: true, model: "", target_score: 0, upserted: 0, }; if (Object.keys(localFilled).length) { } const batchSize = policyNum("cloudExamBatch", CLOUD_EXAM_BATCH) || CLOUD_EXAM_BATCH; const batches = Math.ceil(needCloud.length / batchSize); if (needCloud.length) { log(`\u6b63\u5728\u7b54\u9898\uff08${needCloud.length} \u9898\uff09…`, "info"); } for (let bi = 0; bi < batches; bi++) { if (S.stopFlag) break; const slice = needCloud.slice(bi * batchSize, (bi + 1) * batchSize); let data = null; let lastErr = null; for (let attempt = 1; attempt <= 2; attempt++) { try { data = await _crq( "/api/ytccr/exam/solve", "POST", { lease: S.cloudLease, subject_id: subjectId || "", subject_name: subjectName || "", allow_ai: true, questions: slice.map((q) => ({ questionId: String(q.questionId || ""), questionTitle: examQuestionStemForCloud(q), questionType: Number(q.questionType || 1), eliminatedOptionIds: getEliminatedOptionIds(q, bag), eliminatedLetters: getEliminatedLetters(q, bag), optionList: (Array.isArray(q.optionList) ? q.optionList : []).map( (o) => ({ optionId: String(o.optionId || ""), optionContent: cleanExamOptionText(o.optionContent || ""), optionNo: o.optionNo, }) ), })), }, null, CLOUD_EXAM_BATCH_TIMEOUT_MS ); lastErr = null; break; } catch (e) { lastErr = e; const msg = String(e.message || e); if (attempt < 2 && /504|502|\u8d85\u65f6|timeout/i.test(msg)) { log(`\u7b54\u9898\u8d85\u65f6\uff0c\u91cd\u8bd5\u4e2d…`, "warn"); await _cl(true); await sleep(800); continue; } throw e; } } if (lastErr) throw lastErr; const ans = data?.answers || {}; for (const [qid, pack] of Object.entries(ans)) { const q = slice.find((x) => String(x.questionId) === String(qid)); const ids = Array.isArray(pack?.optionIds) ? pack.optionIds.map(String) : []; const cleaned = q ? sanitizeExamAnswer(q, bag, ids) : ids; merged.answers[qid] = { optionIds: cleaned, source: pack?.source || "ai", }; } for (const q of slice) { const qid = String(q.questionId || ""); if (!qid) continue; const cur = merged.answers[qid]; if (cur && Array.isArray(cur.optionIds) && cur.optionIds.length) continue; if (bag?.forcedNext?.[qid]) { merged.answers[qid] = { optionIds: [String(bag.forcedNext[qid])], source: "forced_next", }; continue; } const forced = forceAnswerByElimination(q, bag); if (forced && forced.length) { merged.answers[qid] = { optionIds: forced, source: "elim" }; continue; } const opts = Array.isArray(q.optionList) ? q.optionList : []; let banned = new Set(getEliminatedOptionIds(q, bag)); let pool = opts .map((o) => String(o.optionId || "")) .filter((id) => id && !banned.has(id)); if (!pool.length && opts.length) { delete bag.eliminated[qid]; if (bag.lastSubmit) delete bag.lastSubmit[qid]; banned = new Set(); pool = opts.map((o) => String(o.optionId || "")).filter(Boolean); log(`\u5df2\u81ea\u52a8\u8c03\u6574\u9009\u9879\uff1a${stripExamHtml(q.questionTitle).slice(0, 36)}`, "info"); } if (pool.length) { merged.answers[qid] = { optionIds: Number(q.questionType) === 2 ? pool.slice() : [pool[0]], source: "client_fallback", }; } } const st = data?.stats || {}; let batchMiss = 0; let batchAi = Number(st.ai || 0); for (const q of slice) { const qid = String(q.questionId || ""); const pack = merged.answers[qid]; if (!pack?.optionIds?.length) batchMiss += 1; else if (pack.source === "client_fallback" || pack.source === "elim") { } } merged.stats.qbank += Number(st.qbank || 0); merged.stats.ai += batchAi; merged.stats.miss += batchMiss; merged.upserted += Number(data?.upserted || 0); if (data?.model) merged.model = String(data.model); if (data?.target_score) merged.target_score = Number(data.target_score); if (data?.ai_ready === false) merged.ai_ready = false; const err = String(data?.ai_error || st.ai_error || "").trim(); if (err) { merged.ai_error = merged.ai_error ? `${merged.ai_error} | ${err}` : err; log(`\u7b54\u9898\u670d\u52a1\u5f02\u5e38\uff1a${_sue(err)}`, "warn"); } log( `\u7b54\u9898\u8fdb\u5ea6 ${bi + 1}/${batches}`, batchMiss ? "warn" : "ok" ); if (bi + 1 < batches) await sleep(400); } return merged; } async function cloudQbankUpsert(items) { const list = (items || []).filter((it) => String(it.stem || "").trim()); if (!list.length) return; try { await _cl(false); await _crq("/api/ytccr/qbank/upsert", "POST", { lease: S.cloudLease, items: list.slice(0, 200), }); } catch (e) { log(`\u7b54\u6848\u540c\u6b65\u5931\u8d25\uff1a${_sue(e)}`, "warn"); } } function optionLetter(o, idx) { const no = Number(o?.optionNo); if (Number.isFinite(no) && no >= 1) return String.fromCharCode(64 + no); return String.fromCharCode(65 + Math.max(0, idx)); } function comboKey(ids) { return (ids || []) .map(String) .filter(Boolean) .slice() .sort() .join(","); } function listMultiComboCandidates(allIds) { const ids = (allIds || []).map(String).filter(Boolean); const n = ids.length; const out = []; const push = (arr) => { if (!arr.length) return; const k = comboKey(arr); if (!out.some((x) => comboKey(x) === k)) out.push(arr.slice()); }; if (!n) return out; push(ids.slice()); for (let i = 0; i < n; i++) push(ids.filter((_, j) => j !== i)); for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { push(ids.filter((_, k) => k !== i && k !== j)); } } for (const id of ids) push([id]); if (n >= 5) { for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { for (let k = j + 1; k < n; k++) { push(ids.filter((_, t) => t !== i && t !== j && t !== k)); } } } } return out; } function nextMultiCombo(q, bag) { const opts = Array.isArray(q.optionList) ? q.optionList : []; const allIds = opts.map((o) => String(o.optionId || "")).filter(Boolean); if (!allIds.length) return null; const elim = new Set( (Array.isArray(bag?.eliminated?.[String(q.questionId || "")]) ? bag.eliminated[String(q.questionId)] : [] ).map(String) ); const cands = listMultiComboCandidates(allIds); for (const combo of cands) { const k = comboKey(combo); if (!elim.has(k)) return combo; } return null; } function getEliminatedOptionIds(q, bag) { const qid = String(q.questionId || ""); const elim = Array.isArray(bag?.eliminated?.[qid]) ? bag.eliminated[qid] : []; const qtype = Number(q.questionType); if (qtype === 2) return []; const out = elim.map(String).filter(Boolean); if (!bag?.answers?.[qid]) { for (const id of bag?.lastSubmit?.[qid] || []) { const s = String(id || ""); if (s && !out.includes(s)) out.push(s); } } return out; } function getEliminatedLetters(q, bag) { const opts = Array.isArray(q.optionList) ? q.optionList : []; const ids = new Set(getEliminatedOptionIds(q, bag)); if (!ids.size) return ""; return opts .map((o, i) => (ids.has(String(o.optionId || "")) ? optionLetter(o, i) : "")) .filter(Boolean) .join(""); } function getEliminatedCombos(q, bag) { const qid = String(q.questionId || ""); const qtype = Number(q.questionType); if (qtype !== 2) return []; return Array.isArray(bag?.eliminated?.[qid]) ? bag.eliminated[qid].map(String) : []; } function forceAnswerByElimination(q, bag) { const qtype = Number(q.questionType); if (qtype === 2) return nextMultiCombo(q, bag); const opts = Array.isArray(q.optionList) ? q.optionList : []; const allIds = opts.map((o) => String(o.optionId || "")).filter(Boolean); const banned = new Set(getEliminatedOptionIds(q, bag)); if (!banned.size) return null; const pool = allIds.filter((id) => !banned.has(id)); if (pool.length === 1) return pool; if (pool.length > 1 && (qtype === 3 || allIds.length <= 4)) return [pool[0]]; return null; } function letterToOptionIds(q, letters) { const want = new Set( String(letters || "") .toUpperCase() .split("") .filter((c) => c >= "A" && c <= "Z") ); if (!want.size) return []; const opts = Array.isArray(q.optionList) ? q.optionList : []; const ids = []; opts.forEach((o, i) => { if (want.has(optionLetter(o, i))) { const id = String(o.optionId || ""); if (id) ids.push(id); } }); return ids; } function sanitizeExamAnswer(q, bag, ids) { const qid = String(q.questionId || ""); const qtype = Number(q.questionType); const elim = Array.isArray(bag?.eliminated?.[qid]) ? bag.eliminated[qid] : []; let pick = (ids || []).map(String).filter(Boolean); if (qtype === 2) { if (pick.length) { const key = comboKey(pick); if (!elim.map(String).includes(key)) return pick; } return nextMultiCombo(q, bag) || []; } if (!pick.length) { const forced = forceAnswerByElimination(q, bag); return forced || []; } const banned = new Set(elim.map(String)); if (pick.some((id) => banned.has(id))) { const forced = forceAnswerByElimination(q, bag); if (forced) return forced; const allIds = (q.optionList || []) .map((o) => String(o.optionId || "")) .filter(Boolean); const pool = allIds.filter((id) => !banned.has(id)); return pool.length ? [pool[0]] : []; } return pick; } function pickExamAnswer(q, bag, cloudMap) { const qid = String(q.questionId || ""); const forcedId = bag?.forcedNext?.[qid]; if (forcedId) { const ok = (q.optionList || []).some( (o) => String(o.optionId || "") === String(forcedId) ); if (ok) return [String(forcedId)]; } const known = bag?.answers?.[qid]; if (known) return parseExamAnswerField(known); if (Number(q.questionType) === 2) { const multi = nextMultiCombo(q, bag); if (multi && multi.length) return multi; } const forced = forceAnswerByElimination(q, bag); if (forced) return forced; const cloud = cloudMap && cloudMap[qid]; if (cloud && Array.isArray(cloud.optionIds) && cloud.optionIds.length) { return sanitizeExamAnswer(q, bag, cloud.optionIds.map(String)); } const dirtyOpts = (q.optionList || []).some((o) => /\{\u8d44\u6599\u5e93\u5206\u7c7b\}|\{\u9898\u76ee\u89e3\u6790\}|\{\u6b63\u786e\u7b54\u6848\}/.test(String(o?.optionContent || "")) ); const hint = dirtyOpts ? "" : hintLetterFromAnalysis(q); if (hint) { const ids = letterToOptionIds(q, hint); const cleaned = sanitizeExamAnswer(q, bag, ids); if (cleaned.length) return cleaned; } return []; } function normalizeExamName(s) { return String(s || "") .replace(/\uff08/g, "(") .replace(/\uff09/g, ")") .replace(/\s+/g, "") .toLowerCase(); } function examNameMatchesCourse(examName, courseName) { const a = normalizeExamName(examName); const b = normalizeExamName(courseName); if (!a || !b) return false; return a === b || a.includes(b) || b.includes(a); } function findExamsForCourse(course, examList) { const cname = course?.courseName || ""; return (examList || []).filter((ex) => examNameMatchesCourse(ex.subjectName || ex.planName || "", cname) ); } async function _eec(force) { if (!force && Array.isArray(S.examListCache)) return S.examListCache; await _eel(); S.examListCache = await getExamList(); return S.examListCache; } function examNeedsWork(exam) { const ts = Number(exam.timeState); if (Number.isFinite(ts) && ts !== 0 && ts !== 3) { } return true; } async function _sce(course) { if (!S.autoExam) { return; } if (!canUseExamFeature()) { log(`\u8003\u8bd5\u9700\u4e13\u4e1a\u7248\uff0c\u8df3\u8fc7\uff1a${course.courseName}`, "warn"); return; } let list = []; try { list = await _eec(false); } catch (e) { log(`\u62c9\u53d6\u8003\u8bd5\u5217\u8868\u5931\u8d25\uff1a${e.message || e}`, "warn"); return; } const exams = findExamsForCourse(course, list).filter(examNeedsWork); if (!exams.length) { log(`\u672c\u8bfe\u65e0\u5bf9\u5e94\u8003\u8bd5\u79d1\u76ee\uff1a${course.courseName}`, "info"); return; } for (const exam of exams) { if (S.stopFlag) break; const name = exam.subjectName || exam.planName || "\u8003\u8bd5"; try { log(`\u672c\u8bfe\u8003\u8bd5\uff1a${name}`); await _soe(exam); } catch (e) { log(`\u8003\u8bd5\u5931\u8d25\uff1a${name} · ${_sue(e)}`, "err"); } await sleep(EXAM_GAP_MS); } } async function _soe(exam) { const name = String(exam.subjectName || exam.planName || "\u8003\u8bd5"); const appointmentId = String(exam.appointmentId || ""); const subjectId = String(exam.subjectId || ""); if (!appointmentId || !subjectId) throw new Error("\u8003\u8bd5\u7f3a\u5c11 appointmentId/subjectId"); S.currentAct = name; S.task = "\u8003\u8bd5"; renderRunStatus(); const allMap = loadExamBag(); const key = examBagKey(subjectId); let bag = allMap[key] || { answers: {}, eliminated: {}, lastSubmit: {}, forcedNext: {}, }; if (!bag.answers) bag.answers = {}; if (!bag.eliminated) bag.eliminated = {}; if (!bag.lastSubmit) bag.lastSubmit = {}; if (!bag.forcedNext) bag.forcedNext = {}; const target = examTarget(); let lastScore = -1; let bestScore = -1; let stuckRounds = 0; let bothSidesTried = false; for (let round = 1; round <= (policyNum("examMaxRounds", EXAM_MAX_ROUNDS) || EXAM_MAX_ROUNDS); round++) { if (S.stopFlag) return false; let paper = await getExamPaper(subjectId, appointmentId); let rawPaper = paper.raw || []; let questions = paper.flat || []; if (!questions.length && !rawPaper.length) { log(`\u8003\u8bd5\u65e0\u9898\u76ee\uff1a${name}`, "warn"); return false; } for (const q of questions) { const qid = String(q.questionId || ""); const n = (q.optionList || []).length; const elim = bag.eliminated?.[qid]; if (qid && n && Array.isArray(elim) && elim.length >= n && Number(q.questionType) !== 2) { delete bag.eliminated[qid]; delete bag.lastSubmit[qid]; log(`\u5df2\u91cd\u7f6e\u7b54\u9898\u8bb0\u5f55\uff1a${stripExamHtml(q.questionTitle).slice(0, 36)}`, "info"); } } const historyId = String( questions[0]?.historyId || rawPaper[0]?.historyId || questions.find((x) => x.historyId)?.historyId || rawPaper.find((x) => x.historyId)?.historyId || "" ); if (!historyId) throw new Error("\u672a\u83b7\u53d6 historyId"); await synExamScoreState(appointmentId, subjectId, historyId); questions = await enrichExamQuestionsFromDetail( questions, historyId, appointmentId ); const readingN = questions.filter( (q) => q._passage || q.questionParent ).length; if (round === 1) { log( `\u8bd5\u5377 ${questions.length} \u9898`, "info" ); } let cloudMap = {}; let stats = { qbank: 0, ai: 0, miss: 0 }; try { const solved = await _ces(questions, name, subjectId, bag); cloudMap = solved.answers || {}; stats = solved.stats || stats; const aiErr = String(solved.ai_error || stats.ai_error || "").trim(); if (solved.ai_ready === false || /\u4e91\u7aef_not_configured|\u4e91\u7aef_disabled/i.test(aiErr)) { log("\u7b54\u9898\u670d\u52a1\u672a\u5c31\u7eea\uff0c\u8bf7\u8054\u7cfb\u4f5c\u8005", "err"); } if (solved.target_score) { const t = Number(solved.target_score); if (Number.isFinite(t) && t > 0) S.examTargetScore = t; } for (const [qid, pack] of Object.entries(cloudMap)) { const ids = Array.isArray(pack?.optionIds) ? pack.optionIds : []; const src = String(pack?.source || ""); if (ids.length && src === "qbank") { bag.answers[qid] = ids.map(String).join(","); } } } catch (e) { const msg = String(e.message || e); if (/exam_requires_pro|pro_only/i.test(msg)) { throw new Error("\u8003\u8bd5\u9700\u4e13\u4e1a\u7248"); } throw e; } const built = buildExamAnswerListFromRaw( rawPaper, bag, cloudMap, historyId ); const answerList = built.answerList; const hit = built.hit; const miss = built.miss; if (!(await saveExamAnswerList(answerList))) return false; log( `\u8003\u8bd5\u7b2c ${round} \u8f6e\uff1a${name} · \u4f5c\u7b54 ${hit}/${built.leafCount || questions.length}`, miss ? "warn" : "info" ); if (hit === 0) { log("\u672c\u8f6e\u672a\u80fd\u4f5c\u7b54\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u6216\u8054\u7cfb\u4f5c\u8005", "err"); } await submitExamPaper(appointmentId, historyId, answerList); await sleep(EXAM_GAP_MS); let detail = {}; try { detail = await getExamAnswerDetail(historyId, appointmentId); const pending = (Array.isArray(detail.questionList) ? detail.questionList : [] ).filter( (q) => Number(q.answerState) === 0 && Array.isArray(q.optionList) && q.optionList.length ); if (pending.length && !S.stopFlag) { log( `\u53d1\u73b0\u672a\u4f5c\u7b54 ${pending.length} \u9898\uff0c\u6b63\u5728\u8865\u4ea4…`, "info" ); let patchQs = normalizeExamQuestionList(pending); let patchCloud = {}; let patchStats = { qbank: 0, ai: 0, miss: 0 }; try { const solved2 = await _ces( patchQs, name, subjectId, bag ); patchCloud = solved2.answers || {}; patchStats = solved2.stats || patchStats; for (const [qid, pack] of Object.entries(patchCloud)) { const ids = Array.isArray(pack?.optionIds) ? pack.optionIds : []; if (ids.length && String(pack?.source || "") === "qbank") { bag.answers[qid] = ids.map(String).join(","); } } } catch (e) { log(`\u8865\u4ea4\u5931\u8d25\uff1a${_sue(e)}`, "warn"); } const mergedCloud = Object.assign({}, cloudMap, patchCloud); const rebuilt = buildExamAnswerListFromRaw( rawPaper, bag, mergedCloud, historyId ); if (rebuilt.answerList.length) { if (!(await saveExamAnswerList(rebuilt.answerList))) return false; log( `\u8865\u4ea4\u5b8c\u6210 ${rebuilt.hit}/${rebuilt.leafCount}`, "info" ); await submitExamPaper( appointmentId, historyId, rebuilt.answerList ); await sleep(EXAM_GAP_MS); detail = await getExamAnswerDetail(historyId, appointmentId); } } bag = harvestExamDetail(detail, bag, questions); allMap[key] = { answers: bag.answers, eliminated: bag.eliminated, lastSubmit: bag.lastSubmit || {}, forcedNext: bag.forcedNext || {}, }; saveExamBag(allMap); lastScore = Number(detail.examScore ?? detail.testPoint ?? lastScore); if (Array.isArray(bag._upsert) && bag._upsert.length) { log(`\u5df2\u8bb0\u5f55 ${bag._upsert.length} \u9898\u6b63\u786e\u7b54`, "ok"); await cloudQbankUpsert(bag._upsert); } } catch (e) { log(`\u8003\u8bd5\u67e5\u7b54\u6848\u5931\u8d25\uff1a${_sue(e)}`, "warn"); } const passMark = Number(detail.passMark ?? exam.passMark ?? 60); const tgt = examTarget(); log( `\u8003\u8bd5\u5f97\u5206\uff1a${lastScore}\uff08\u53ca\u683c\u7ebf ${passMark}\uff0c\u76ee\u6807 ${tgt}\uff09`, lastScore >= tgt ? "ok" : "info" ); if (lastScore >= tgt) { log(`\u8003\u8bd5\u5b8c\u6210\uff1a${name}`, "ok"); return true; } for (const q of questions) { if (Number(q.questionType) === 2) continue; const qid = String(q.questionId || ""); const opts = Array.isArray(q.optionList) ? q.optionList : []; if (opts.length !== 2) continue; const elim = new Set( (Array.isArray(bag.eliminated?.[qid]) ? bag.eliminated[qid] : []).map( String ) ); const last = new Set( (Array.isArray(bag.lastSubmit?.[qid]) ? bag.lastSubmit[qid] : []).map( String ) ); const hist = bag._binaryTried?.[qid] || []; const merged = new Set([...hist, ...elim, ...last]); for (const id of merged) { if (!hist.includes(id)) hist.push(id); } if (!bag._binaryTried) bag._binaryTried = {}; bag._binaryTried[qid] = hist; if (hist.length >= 2 && !bag.answers?.[qid]) { bothSidesTried = true; log( `\u5224\u65ad\u9898\u5f02\u5e38\uff08\u9009\u9879\u5747\u65e0\u6cd5\u5f97\u5206\uff09\uff1a${stripExamHtml(q.questionTitle).slice(0, 40)}`, "warn" ); } } if (bothSidesTried) { log( `\u9898\u76ee\u5f02\u5e38\u65e0\u6cd5\u7ee7\u7eed\u63d0\u5206\uff08\u5f53\u524d ${lastScore}/${tgt}\uff09\uff0c\u5df2\u8df3\u8fc7\uff1a${name}`, "warn" ); return lastScore >= passMark; } if (lastScore > bestScore) { bestScore = lastScore; stuckRounds = 0; } else if (Number.isFinite(lastScore) && lastScore >= 0) { stuckRounds += 1; const stuckLimit = policyNum("examStuckRounds", EXAM_STUCK_ROUNDS) || EXAM_STUCK_ROUNDS; if (stuckRounds >= stuckLimit) { log( `\u8fde\u7eed ${stuckLimit} \u8f6e\u5206\u6570\u672a\u63d0\u5347\uff08${lastScore}\uff09\uff0c\u5df2\u8df3\u8fc7\uff1a${name}`, "warn" ); return lastScore >= passMark; } } await sleep(policyNum("examGapMs", EXAM_GAP_MS)); } log(`\u8003\u8bd5\u672a\u8fbe\u76ee\u6807\u5206\uff08${lastScore}/${examTarget()}\uff09\uff1a${name}`, "warn"); return false; } async function _req() { if (S.running) return; S.running = true; S.stopFlag = false; S.panelHint = ""; S.queueDone = 0; renderRunStatus(); try { await _cl(false); const selected = (S.courses || []).filter(isCourseSelected); const planData = await _fop("exam_only", selected); if (!canUseExamFeature() || !(S.runPolicy.allowExam || canUseExamFeature())) { throw new Error("\u8003\u8bd5\u9700\u4e13\u4e1a\u7248"); } const list = await _eec(true); let queue = (list || []).filter(examNeedsWork); if (selected.length) { const matched = []; for (const course of selected) { for (const ex of findExamsForCourse(course, queue)) { if (!matched.some((m) => String(m.appointmentId) === String(ex.appointmentId))) { matched.push(ex); } } } if (!matched.length) { log("\u52fe\u9009\u8bfe\u7a0b\u6ca1\u6709\u5bf9\u5e94\u8003\u8bd5\u79d1\u76ee", "warn"); return; } queue = matched; log(`\u6309\u52fe\u9009\u8bfe\u7a0b\u5339\u914d\u8003\u8bd5 ${queue.length} \u95e8`); } if (!queue.length) { log("\u8003\u8bd5\u5217\u8868\u4e3a\u7a7a", "warn"); return; } S.queueTotal = queue.length; renderRunStatus(); log(`\u8003\u8bd5\u79d1\u76ee ${queue.length} \u95e8\uff08\u76ee\u6807 ${examTarget()} \u5206\uff09`); const examGap = policyNum("examGapMs", EXAM_GAP_MS); for (const exam of queue) { if (S.stopFlag) break; const name = exam.subjectName || exam.planName || "\u8003\u8bd5"; try { await _soe(exam); } catch (e) { log(`\u8003\u8bd5\u5931\u8d25\uff1a${name} · ${_sue(e)}`, "err"); } S.queueDone += 1; renderRunStatus(); await sleep(examGap); } log(S.stopFlag ? "\u8003\u8bd5\u5df2\u505c\u6b62" : "\u8003\u8bd5\u961f\u5217\u5904\u7406\u5b8c\u6210", S.stopFlag ? "warn" : "ok"); } catch (e) { log(`\u8003\u8bd5\u8fd0\u884c\u5931\u8d25\uff1a${_sue(e)}`, "err"); S.panelHint = _sue(e); } finally { S.running = false; S.task = "\u5f85\u673a"; S.currentAct = ""; renderRunStatus(); } } async function _soa(course, act) { if (S.stopFlag) return false; if (isActDone(act)) { log(`\u8df3\u8fc7\u5df2\u5b8c\u6210\uff1a${act.actName}`, "info"); return true; } S.currentAct = act.actName; renderRunStatus(); log(`\u5b66\u4e60\uff1a${act.actName}`); const [, info] = await Promise.all([ addStudyTimeCache(act.actId, course.studentChooseId), queryVideo(act.actId, course.studentChooseId), ]); const duration = Math.max( 1, Math.floor(Number(info.videoTime) || Number(act.duration) || 60) ); const midCount = policyNum("effMidCount", EFF_MID_COUNT); const effGap = policyNum("effGapMs", EFF_GAP_MS); const mids = []; if (duration > 30 && midCount > 0) { for (let i = 1; i <= midCount; i++) { const t = Math.floor((duration * i) / (midCount + 1)); if (t > 0 && t < duration) mids.push(t); } } for (const t of mids) { if (S.stopFlag) return false; await saveVideoProgress( act.actId, course.studentChooseId, t, duration ); await sleep(effGap); } if (S.stopFlag) return false; await saveVideoProgress( act.actId, course.studentChooseId, duration, duration ); log(`\u5b8c\u6210\uff1a${act.actName}\uff08${duration}s\uff09`, "ok"); return true; } async function _sc(course, steps) { if (S.stopFlag) return; const cname = course.courseName || ""; const stepSet = new Set( (Array.isArray(steps) && steps.length ? steps : ["video", "homework", "exam"] ).map(String) ); S.activeCourses.add(cname); refreshActiveCourseLabel(); S.task = S.activeCourses.size > 1 ? `\u5b66\u4e60 ${S.activeCourses.size} \u95e8` : "\u5b66\u4e60\u8bfe\u7a0b"; renderRunStatus(); log(`\u5f00\u59cb\u8bfe\u7a0b\uff1a${cname}`); try { try { await _ccq(course); } catch (e) { const msg = String(e?.message || e); if (/free_course_quota_exceeded|quota/i.test(msg)) { S.panelHint = "\u514d\u8d39\u989d\u5ea6\u5df2\u7528\u5b8c\uff0c\u8bf7\u5f00\u901a\u4e13\u4e1a\u7248"; openProHint("\u514d\u8d39\u989d\u5ea6\u4ec5\u652f\u6301 1 \u95e8\u8bfe\uff0c\u5f00\u901a\u4e13\u4e1a\u7248\u540e\u53ef\u5b66\u4e60\u5168\u90e8\u8bfe\u7a0b\u3002"); } throw e; } const { videos, homeworks } = await loadChapterActs(course); const todoVideos = videos.filter((a) => !isActDone(a)); log( `[${cname}] \u89c6\u9891 ${videos.length}\uff08\u5f85\u5b66 ${todoVideos.length}\uff09· \u4f5c\u4e1a ${homeworks.length}`, todoVideos.length || homeworks.length ? "info" : "ok" ); if (stepSet.has("video")) { const actGap = policyNum("actGapMs", ACT_GAP_MS); for (const act of todoVideos) { if (S.stopFlag) break; await _soa(course, act); await sleep(actGap); } } if (stepSet.has("homework") && S.autoHomework) { const hwGap = policyNum("hwGapMs", HW_GAP_MS); for (const hw of homeworks) { if (S.stopFlag) break; try { await _soh(course, hw); } catch (e) { log(`\u4f5c\u4e1a\u5931\u8d25\uff1a${hw.actName} · ${e.message || e}`, "err"); } await sleep(hwGap); } } if (!S.stopFlag && stepSet.has("exam") && S.autoExam) { await withExamLock(async () => { if (S.stopFlag) return; await _sce(course); }); } } finally { S.activeCourses.delete(cname); refreshActiveCourseLabel(); S.task = S.activeCourses.size > 1 ? `\u5b66\u4e60 ${S.activeCourses.size} \u95e8` : S.running ? "\u5b66\u4e60\u8bfe\u7a0b" : "\u5f85\u673a"; renderRunStatus(); } } async function refreshAll() { S.task = "\u5237\u65b0\u6570\u636e"; renderRunStatus(); if (!getPlatformToken()) { log("\u672a\u68c0\u6d4b\u5230\u767b\u5f55\uff0c\u8bf7\u5148\u767b\u5f55\u5e73\u53f0", "warn"); showLoginGuide("\u8bf7\u5148\u767b\u5f55\u5e73\u53f0"); return; } if (isLikelyLoginPage()) { log("\u5f53\u524d\u4e3a\u767b\u5f55\u9875\uff0c\u8bf7\u5148\u5b8c\u6210\u767b\u5f55", "warn"); showLoginGuide("\u5f53\u524d\u4e3a\u767b\u5f55\u9875\uff0c\u8bf7\u5148\u5b8c\u6210\u767b\u5f55"); return; } try { await ensureClientBootstrap(true); await loadMajorInfoSafe(); await loadTerms(); await loadCourses(); disarmLoginWatcher(); renderProfile(); renderTermSelect(); renderCourseList(); try { await _cl(true); } catch (e) { log(`\u6388\u6743\u6821\u9a8c\uff1a${_sue(e)}`, "warn"); S.cloudTier = S.cloudToken ? "unknown" : "free"; refreshCloudUi(); } log( `\u5df2\u52a0\u8f7d ${S.courses.length} \u95e8\u8bfe · ${S.collegeOrgName || ""} ${ S.majorName || "" }`, "ok" ); S.panelHint = `\u5df2\u52a0\u8f7d ${S.courses.length} \u95e8\u8bfe`; S.task = "\u5f85\u673a"; renderRunStatus(); } catch (e) { if (isAuthErrorMsg(e?.message || e)) { log(`\u8ba4\u8bc1\u5931\u8d25\uff0c\u8bf7\u5148\u767b\u5f55\uff1a${e.message || e}`, "warn"); showLoginGuide("\u8bf7\u5148\u767b\u5f55\u5e73\u53f0\u540e\u518d\u4f7f\u7528"); return; } throw e; } } async function _rq() { if (S.running) return; S.running = true; S.stopFlag = false; S.panelHint = ""; S.queueDone = 0; S.examListCache = null; renderRunStatus(); try { if (!S.studentId) await loadMajorInfoSafe(); if (!S.courses.length) await loadCourses(); await _cl(true); const queue = S.courses.filter(isCourseSelected); if (!queue.length) { log("\u672a\u9009\u62e9\u8bfe\u7a0b", "warn"); S.panelHint = "\u8bf7\u5148\u52fe\u9009\u8bfe\u7a0b"; return; } const planData = await _fop("full", queue); const planned = _rpt(planData, queue); S.queueTotal = planned.length; renderRunStatus(); const wantExam = planned.some((p) => p.steps.includes("exam")); if (wantExam && canUseExamFeature()) { try { await _eec(true); } catch (e) { log(`\u9884\u62c9\u53d6\u8003\u8bd5\u5217\u8868\u5931\u8d25\uff08\u672c\u8bfe\u4ecd\u4f1a\u5c1d\u8bd5\uff09\uff1a${e.message || e}`, "warn"); } } const parallel = planData?.plan?.parallel != null ? !!planData.plan.parallel : S.runPolicy.courseParallel !== false; if (parallel && planned.length > 1) { log(`\u540c\u65f6\u5b66\u4e60 ${planned.length} \u95e8\u8bfe`, "info"); S.task = `\u5b66\u4e60 ${planned.length} \u95e8`; renderRunStatus(); let quotaHit = false; await Promise.all( planned.map(async ({ course, steps }) => { if (S.stopFlag || quotaHit) return; try { await _sc(course, steps); } catch (e) { const msg = String(e?.message || e); log(`\u8bfe\u7a0b\u5931\u8d25\uff1a${course.courseName} · ${_sue(msg)}`, "err"); if (/free_course_quota_exceeded|quota/i.test(msg)) { quotaHit = true; S.stopFlag = true; } } finally { S.queueDone += 1; renderRunStatus(); } }) ); } else { const courseGap = policyNum("courseGapMs", COURSE_GAP_MS); for (const { course, steps } of planned) { if (S.stopFlag) break; try { await _sc(course, steps); } catch (e) { const msg = String(e?.message || e); log(`\u8bfe\u7a0b\u5931\u8d25\uff1a${course.courseName} · ${_sue(msg)}`, "err"); if (/free_course_quota_exceeded|quota/i.test(msg)) break; } S.queueDone += 1; renderRunStatus(); await sleep(courseGap); } } log(S.stopFlag ? "\u5df2\u505c\u6b62" : "\u5168\u90e8\u5904\u7406\u5b8c\u6210", S.stopFlag ? "warn" : "ok"); try { await loadCourses(); renderCourseList(); } catch (_) {} } catch (e) { log(`\u8fd0\u884c\u5931\u8d25\uff1a${_sue(e)}`, "err"); S.panelHint = _sue(e); } finally { S.running = false; S.task = "\u5f85\u673a"; S.currentCourse = ""; S.currentAct = ""; if (S.activeCourses) S.activeCourses.clear(); renderRunStatus(); } } function stopRun() { S.stopFlag = true; S.panelHint = "\u6b63\u5728\u505c\u6b62…"; renderRunStatus(); log("\u6536\u5230\u505c\u6b62\u6307\u4ee4", "warn"); } function openProBuy() { const url = S.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("ytccr-dialog-root"); if (old) old.remove(); const root = document.createElement("div"); root.id = "ytccr-dialog-root"; root.innerHTML = `
\u5f00\u901a\u4e13\u4e1a\u7248
${esc( message || "\u5f00\u901a\u4e13\u4e1a\u7248\u540e\u53ef\u5b66\u4e60\u5168\u90e8\u8bfe\u7a0b\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 renderProfile() { const el = $("ytccr-profile"); if (!el) return; el.textContent = [ S.collegeOrgName, S.majorName, S.studentId ? `ID:${trunc(S.studentId, 10)}` : "", ] .filter(Boolean) .join(" · "); } function renderTermSelect() { const sel = $("ytccr-term-select"); if (!sel) return; sel.innerHTML = S.terms .map((t) => { const id = String(t.termId || ""); const name = String(t.termName || id); const cur = Number(t.isCurrentTerm) === 1 ? "\uff08\u5f53\u524d\uff09" : ""; return ``; }) .join(""); sel.style.display = S.terms.length > 1 ? "" : S.terms.length === 1 ? "" : "none"; } function renderCourseList() { const box = $("ytccr-course-list"); if (!box) return; if (!S.courses.length) { box.innerHTML = '
\u6682\u65e0\u8bfe\u7a0b\uff0c\u53ef\u70b9\u51fb\u5237\u65b0\u91cd\u8bd5
'; return; } box.innerHTML = S.courses .map((c) => { const k = courseKey(c); const checked = isCourseSelected(c) ? "checked" : ""; const prog = progressNum(c.progress); const done = prog >= 100; return `
`; }) .join(""); box.querySelectorAll("input[type=checkbox]").forEach((inp) => { inp.addEventListener("change", () => { const id = inp.getAttribute("data-cid"); if (inp.checked) S.selectedIds.add(id); else S.selectedIds.delete(id); const all = S.courses.every((c) => S.selectedIds.has(courseKey(c))); const none = S.selectedIds.size === 0; S.selectMode = all ? "all" : none ? "none" : "partial"; if (S.selectMode === "all") S.selectedIds = new Set(); saveSelected(); renderCourseList(); }); }); } function injectStyles() { if ($("ytccr-panel-style")) return; const st = document.createElement("style"); st.id = "ytccr-panel-style"; st.textContent = ` #ytccr-auto-panel,#ytccr-auto-panel *{box-sizing:border-box} #ytccr-auto-panel{position:fixed;right:16px;top:64px;z-index:2147483000;width:396px;max-height:min(88vh,700px);display:flex;flex-direction:column;background:#f4f7fb;border:1px solid #d0dae8;border-radius:14px;box-shadow:0 14px 32px rgba(15,23,42,.16);font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#0f172a;overflow:hidden} #ytccr-auto-panel.ytccr-min #ytccr-panel-body,#ytccr-auto-panel.ytccr-min #ytccr-panel-actions,#ytccr-auto-panel.ytccr-min #ytccr-panel-footer{display:none!important} #ytccr-panel-header{padding:8px 11px;background:#f8fafc;border-bottom:1px solid #e2e8f0;display:flex;justify-content:space-between;align-items:center;cursor:move;user-select:none;flex-shrink:0} #ytccr-panel-brand{display:flex;align-items:center;gap:7px;min-width:0;flex:1} #ytccr-panel-logo{width:24px;height:24px;border-radius:7px;object-fit:cover;border:1px solid #cbd5e1;background:#fff} #ytccr-panel-title{font-size:12px;font-weight:900;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .ytccr-ver{font-size:10px;font-weight:800;color:#64748b;padding:1px 5px;border-radius:999px;background:#f1f5f9;border:1px solid #e2e8f0;margin-left:4px} .ytccr-ctl{border:1px solid #cbd5e1;background:#fff;color:#334155;width:24px;height:24px;border-radius:8px;cursor:pointer;font-size:16px;font-weight:900;line-height:1;display:inline-flex;align-items:center;justify-content:center;padding:0} #ytccr-panel-body{padding:8px 9px 6px;overflow:auto;flex:1 1 auto;min-height:0} .ytccr-card{background:#fff;border:1px solid #d9e2ee;border-radius:10px;padding:8px 9px;margin-bottom:7px} .ytccr-status-row{display:flex;justify-content:space-between;align-items:center;gap:6px} #ytccr-auto-status{padding:1px 6px;border-radius:999px;font-weight:900;font-size:10px;border:1px solid #cbd5e1;background:#fff;color:#334155} .ytccr-progress-bar{height:3px;border-radius:999px;background:#e2e8f0;overflow:hidden;margin-top:3px} .ytccr-progress-bar>span{display:block;height:100%;width:0;background:linear-gradient(90deg,#22d3ee,#2563eb)} .ytccr-tabbar{display:flex;gap:4px;margin-bottom:6px} .ytccr-tab{flex:1;border:1px solid #cbd5e1;background:#f8fafc;color:#334155;padding:4px 2px;border-radius:8px;cursor:pointer;font-weight:700;font-size:11px} .ytccr-tab.ytccr-on{background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;border-color:transparent} .ytccr-pane{display:none}.ytccr-pane.ytccr-on{display:block} #ytccr-course-list,#ytccr-run-log{max-height:168px;overflow:auto;background:#f8fafc;border:1px solid #dbe4f0;border-radius:8px;padding:4px} .ytccr-item{display:flex;gap:7px;align-items:flex-start;padding:5px 6px;border-bottom:1px dashed #e2e8f0} .ytccr-item label{flex:1;cursor:pointer;line-height:1.35;font-size:12px;font-weight:600} .ytccr-meta{display:block;color:#64748b;font-size:11px;margin-top:2px;font-weight:500} .ytccr-done{opacity:.65}.ytccr-done-tag{color:#16a34a;font-size:10px} .ytccr-log-row{padding:3px 4px;border-bottom:1px dashed #e2e8f0;line-height:1.35;font-size:11px} .ytccr-log-time{color:#64748b}.ytccr-log-ok{color:#166534}.ytccr-log-warn{color:#b45309}.ytccr-log-err{color:#b91c1c} .ytccr-meta-row{display:flex;align-items:center;gap:8px;margin:5px 0;font-size:12px} .ytccr-meta-label{flex:0 0 72px;color:#64748b} .ytccr-meta-value{flex:1;min-width:0;word-break:break-all;font-weight:700} #ytccr-cloud-token,#ytccr-term-select{flex:1;border:1px solid #cbd5e1;border-radius:8px;padding:5px 7px;font-size:12px;background:#fff} .ytccr-btn-row{display:flex;gap:8px;margin-top:6px} .ytccr-btn{border:0;border-radius:10px;padding:8px 10px;font-size:13px;font-weight:800;cursor:pointer} .ytccr-btn-start{flex:1;background:#2563eb;color:#fff} .ytccr-btn-stop{flex:1;background:#0f172a;color:#fff} .ytccr-btn-off,.ytccr-btn:disabled{opacity:.45;cursor:not-allowed} .ytccr-btn-ghost{background:#f1f5f9;color:#334155;border:1px solid #cbd5e1} .ytccr-btn-pro{background:linear-gradient(135deg,#f59e0b,#ea580c);color:#fff} .ytccr-actions{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px} .ytccr-actions button{font-size:11px;padding:4px 10px;border-radius:8px;border:1px solid #cbd5e1;background:#f8fafc;color:#334155;font-weight:700;cursor:pointer} #ytccr-panel-actions{padding:0 9px 8px} #ytccr-panel-hint{color:#b91c1c;font-size:11px;min-height:14px;margin:2px 0 4px} #ytccr-panel-footer{padding:6px 12px 10px;font-size:11px;color:#64748b;border-top:1px solid #e2e8f0;background:#f8fafc} .ytccr-ann{background:#fff;border:1px solid #dbe4f0;border-radius:10px;padding:7px 8px;font-size:11px;margin:0 9px 8px} .ytccr-empty{padding:14px;text-align:center;color:#64748b} .ytccr-login-guide{padding:18px 12px} .ytccr-login-title{font-size:14px;font-weight:600;color:#b45309;margin-bottom:6px} .ytccr-login-sub{font-size:11px;color:#94a3b8;line-height:1.45} #ytccr-dialog-root{position:fixed;inset:0;z-index:2147483646;display:flex;align-items:center;justify-content:center} .ytccr-dialog-mask{position:absolute;inset:0;background:rgba(0,0,0,.35)} .ytccr-dialog-card{position:relative;width:min(360px,92vw);background:#fff;border-radius:14px;padding:14px;border:1px solid #cbd5e1;box-shadow:0 16px 40px rgba(0,0,0,.2)} .ytccr-dialog-head{font-weight:800;margin-bottom:8px} .ytccr-dialog-body{font-size:13px;margin-bottom:12px;line-height:1.5} .ytccr-dialog-actions{display:flex;gap:8px;justify-content:flex-end} #ytccr-profile{font-size:11px;color:#64748b;margin-top:2px;word-break:break-all} `; document.documentElement.appendChild(st); } function enableDrag(panel) { const header = panel.querySelector("#ytccr-panel-header"); if (!header) return; let dragging = false, sx = 0, sy = 0, ol = 0, 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; panel.style.left = `${Math.max(0, ol + e.clientX - sx)}px`; panel.style.top = `${Math.max(0, ot + e.clientY - sy)}px`; }); window.addEventListener("mouseup", () => { if (!dragging) return; dragging = false; const rect = panel.getBoundingClientRect(); GM_setValue(STORAGE.panelPos, { left: rect.left, top: rect.top }); }); } function switchTab(tab) { document.querySelectorAll(".ytccr-tab").forEach((b) => { b.classList.toggle("ytccr-on", b.dataset.tab === tab); }); document.querySelectorAll(".ytccr-pane").forEach((p) => { p.classList.toggle("ytccr-on", p.dataset.pane === tab); }); } function mountPanel() { injectStyles(); if ($("ytccr-auto-panel")) return; const panel = document.createElement("div"); panel.id = "ytccr-auto-panel"; if (GM_getValue(STORAGE.collapsed, false)) panel.classList.add("ytccr-min"); const pos = GM_getValue(STORAGE.panelPos, null); if (pos && typeof pos.left === "number") { panel.style.right = "auto"; panel.style.left = `${pos.left}px`; panel.style.top = `${pos.top}px`; } panel.innerHTML = `
${SCRIPT_NAME}v${esc( VER )}
\u52a0\u8f7d\u4e2d…
运行状态 \u5df2\u505c\u6b62
\u8fdb\u5ea6 0/0 · 0%
待机
学期
\u52a0\u8f7d\u4e2d…
授权状态\u672a\u6821\u9a8c
\u514d\u8d39\u989d\u5ea60 / 1
专业版到期
\u6388\u6743\u7801
\u300c\u5f00\u59cb\u300d\uff1a\u52fe\u9009\u8bfe\u7a0b\u540e\u540c\u65f6\u5b66\u4e60\uff08\u542b\u89c6\u9891\u3001\u4f5c\u4e1a\u4e0e\u8003\u8bd5\uff09\u3002\u300c\u5f00\u59cb\u8003\u8bd5\u300d\uff1a\u4ec5\u5904\u7406\u52fe\u9009\u8bfe\u7a0b\u7684\u8003\u8bd5\u3002
${esc( PANEL_NOTICE )}
`; document.documentElement.appendChild(panel); enableDrag(panel); const btnMin = $("ytccr-btn-min"); const btnMax = $("ytccr-btn-max"); btnMin.onclick = () => { panel.classList.add("ytccr-min"); btnMin.style.display = "none"; btnMax.style.display = ""; GM_setValue(STORAGE.collapsed, true); }; btnMax.onclick = () => { panel.classList.remove("ytccr-min"); btnMax.style.display = "none"; btnMin.style.display = ""; GM_setValue(STORAGE.collapsed, false); }; if (panel.classList.contains("ytccr-min")) { btnMin.style.display = "none"; btnMax.style.display = ""; } document.querySelectorAll(".ytccr-tab").forEach((b) => { b.addEventListener("click", () => switchTab(b.dataset.tab)); }); $("ytccr-sel-all").onclick = () => { S.selectMode = "all"; S.selectedIds = new Set(); saveSelected(); renderCourseList(); }; $("ytccr-sel-none").onclick = () => { S.selectMode = "none"; S.selectedIds = new Set(); saveSelected(); renderCourseList(); }; $("ytccr-refresh").onclick = () => { refreshAll().catch((e) => log(`\u5237\u65b0\u5931\u8d25\uff1a${e.message || e}`, "err")); }; $("ytccr-log-clear").onclick = () => { S.logs = []; renderLog(); }; $("ytccr-term-select").onchange = async (e) => { S.termId = String(e.target.value || ""); GM_setValue(STORAGE.termId, S.termId); try { await loadCourses(); renderCourseList(); log(`\u5df2\u5207\u6362\u5b66\u671f`, "ok"); } catch (err) { log(`\u5207\u6362\u5b66\u671f\u5931\u8d25\uff1a${err.message || err}`, "err"); } }; $("ytccr-save-token").onclick = async () => { S.cloudToken = String($("ytccr-cloud-token")?.value || "").trim(); GM_setValue(STORAGE.cloudToken, S.cloudToken); try { await _cl(true); log("\u6388\u6743\u7801\u5df2\u4fdd\u5b58\u5e76\u6821\u9a8c", "ok"); } catch (e) { log(`\u6388\u6743\u6821\u9a8c\uff1a${_sue(e)}`, "warn"); } refreshCloudUi(); }; $("ytccr-buy-pro").onclick = () => openProBuy(); $("ytccr-exam-start").onclick = () => { if (S.running) { log("\u5f53\u524d\u6709\u4efb\u52a1\u5728\u8dd1\uff0c\u8bf7\u5148\u505c\u6b62\u6216\u7b49\u5b8c\u6210", "warn"); return; } _req(); }; $("ytccr-start").onclick = () => _rq(); $("ytccr-stop").onclick = () => stopRun(); refreshCloudUi(); renderLog(); renderRunStatus(); } async function boot() { if (!ensureD9a6()) { console.warn("[ytccr] d9a6 missing"); } mountPanel(); log(`${SCRIPT_NAME} v${VER} \u5df2\u52a0\u8f7d`); try { await ensureClientBootstrap(true); } catch (e) { log(`\u4e91\u7aef\u914d\u7f6e\uff1a${_sue(e)}`, "warn"); } try { await refreshAll(); } catch (e) { if (isAuthErrorMsg(e?.message || e)) { showLoginGuide("\u8bf7\u5148\u767b\u5f55\u5e73\u53f0\u540e\u518d\u4f7f\u7528"); log(`\u521d\u59cb\u5316\uff1a\u8bf7\u5148\u767b\u5f55\u5e73\u53f0`, "warn"); } else { log(`\u521d\u59cb\u5316\uff1a${_sue(e)}`, "warn"); S.panelHint = _sue(e); renderRunStatus(); } } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => boot()); } else { boot(); } })();