// ==UserScript== // @name 【效率版】成教云华富教育(edu-xl.com、edu-edu.com.cn)学习助手 // @namespace https://cws.edu-edu.com/ // @version 1.0 // @author 柠檬真酸 // @icon https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png // @description 华富教育成教云(edu-xl.com、edu-edu.com.cn):自动课件学习、平时作业与期末考试;免费体验 3 小节,会员不限 // @antifeature payment 免费体验3个视频小节,开通会员不限;作业/考试需会员 // @antifeature membership 需云端授权 // @match *://*.edu-edu.com.cn/* // @match *://*.edu-xl.com/* // @match *://cws.edu-edu.com/* // @match *://cjexamnew.edu-edu.com/* // @match *://cje*.edu-edu.com/* // @run-at document-idle // @noframes // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_openInTab // @grant GM_info // @grant unsafeWindow // @connect csustcj.edu-edu.com.cn // @connect gdskd.edu-xl.com // @connect *.edu-edu.com.cn // @connect *.edu-xl.com // @connect cws.edu-edu.com // @connect *.edu-edu.com // @connect cjexamnew.edu-edu.com // @connect cje6.edu-edu.com // @connect cje7.edu-edu.com // @connect oa38.ahzsksw.cn // @connect *.ahzsksw.cn // @connect huaweicloudobs.ahjxjy.cn // @connect card.wlxy.live // @tag 成教 // @tag 华富教育 // @tag 长沙理工 // @tag 广科 // @license All Rights Reserved // ==/UserScript== (function () { "use strict"; try { if (window.top !== window.self) return; } catch (_) { return; } const WIN = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const BOOT = "__cjEduAssistantBooted__"; try { if (WIN[BOOT]) return; Object.defineProperty(WIN, BOOT, { value: true, writable: false, configurable: false, enumerable: false }); } catch (_) { return; } const host = String(location.hostname || "").toLowerCase(); const pathName = String(location.pathname || ""); const isCws = /(^|\.)cws\.edu-edu\.com$/i.test(host) || host === "cws.edu-edu.com"; const isEduEduCn = /(^|\.)edu-edu\.com\.cn$/i.test(host); const isEduXl = /(^|\.)edu-xl\.com$/i.test(host); const looksChengJiao = /StudentSiteNew|MyOnlineCourseNew|CourseList\/LearnReturnUrl|OnlineLearningNew/i.test(pathName) || /^(csustcj|gdskd)(\.|$)/i.test(host); const isPortal = isEduEduCn || (isEduXl && looksChengJiao); if (!isPortal && !isCws) return; function readVersion() { try { if (typeof GM_info !== "undefined" && GM_info.script && GM_info.script.version) { return String(GM_info.script.version).trim(); } } catch (_) {} return "1.0"; } const VERSION = readVersion(); const CWS = "https://cws.edu-edu.com"; const CJ_EXAM_ADMIN = "https://cjexamnew.edu-edu.com"; const CJ_EXAM_ROOM_DEFAULT = "https://cje6.edu-edu.com"; const PORTAL_CACHE_KEY = "cj_edu_portal_v1"; const SALT = "14daab0a-4aff-4f6e-b303-c85f09c39f42"; const DO_HOMEWORK_KEY = "cj_edu_do_homework_v1"; const PANEL_ID = "cj-edu-panel"; const STYLE_ID = "cj-edu-panel-style"; const LOGO = "https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png"; const CLOUD_API_BASE = "https://oa38.ahzsksw.cn"; const CLOUD_TOKEN_KEY = "cj_edu_cloud_token_v1"; const CLOUD_LEASE_KEY = "cj_edu_cloud_lease_v1"; const LEARNING_UID_KEY = "cj_edu_learning_uid_v2"; const TOKEN_BOUND_UID_KEY = "cj_edu_token_bound_uid_v1"; const PRO_BUY_URL_DEFAULT = "https://card.wlxy.live/details/99872A2E"; const LS = { gap: "cj_edu_gap_ms", step: "cj_edu_step_sec", collapsed: "cj_edu_panel_collapsed", }; function schoolAbbFromHost(h) { const m = String(h || "").toLowerCase().match(/^([a-z0-9-]+)\./i); return m ? m[1] : "school"; } const SCHOOL_DISPLAY_FALLBACK = { gdskd: "\u5e7f\u4e1c\u79d1\u6280\u5b66\u9662", csustcj: "\u957f\u6c99\u7406\u5de5\u5927\u5b66", }; function cleanSchoolDisplayName(raw) { let t = String(raw || "") .replace(/<[^>]+>/g, "") .replace(/[\s\u00A0\u3000\u2000-\u200B\uFEFF]+/g, ""); if (!t) return ""; t = t .replace(/\u5b66\u751f\u7aef|\u5728\u7ebf\u5b66\u4e60|\u6210\u6559\u5e73\u53f0|\u767b\u5f55|\u9996\u9875/g, "") .replace(/\u7f51\u7edc\u6559\u80b2\u5e73\u53f0|\u7ee7\u7eed\u6559\u80b2\u5e73\u53f0|\u5b66\u4e60\u5e73\u53f0/g, "\u5e73\u53f0") .replace(/\u7ee7\u7eed\u6559\u80b2\u5b66\u9662|\u7ee7\u7eed\u6559\u80b2/g, "") .replace(/[\s\u00A0\u3000\u2000-\u200B\uFEFF]+/g, ""); if (t.length < 2 || t.length > 40) return ""; if (/^[a-z0-9._-]+$/i.test(t)) return ""; return t; } function readSchoolDisplayFromPage() { const sels = [ "#schoolName", ".school-name", ".schoolName", "#SchoolName", ".logo-title", ".sys-title", ".system-title", ".header-title", ".main-title", ".navbar-brand", ".top-school", ".schoolTitle", "#spSchoolName", ".logo-text", ".login-school", ]; for (const s of sels) { try { const el = document.querySelector(s); const t = cleanSchoolDisplayName(el && (el.textContent || el.getAttribute("title") || "")); if (t) return t; } catch (_) {} } try { const imgs = document.querySelectorAll("img[alt], img[title]"); for (const img of imgs) { const t = cleanSchoolDisplayName(img.getAttribute("alt") || img.getAttribute("title") || ""); if (t && /\u5927\u5b66|\u5b66\u9662|\u5b66\u6821|\u804c\u9662|\u804c\u4e1a\u6280\u672f/.test(t)) return t; } } catch (_) {} try { const title = String(document.title || ""); const parts = title.split(/[-_|—–·]/).map((x) => cleanSchoolDisplayName(x)).filter(Boolean); for (const p of parts) { if (/\u5927\u5b66|\u5b66\u9662|\u5b66\u6821|\u804c\u9662|\u804c\u4e1a\u6280\u672f/.test(p)) return p; } if (parts[0] && !/\u5728\u7ebf|\u5b66\u4e60|\u5b66\u751f|\u8bfe\u7a0b/.test(parts[0])) return parts[0]; } catch (_) {} return ""; } function resolveSchoolDisplay(abb) { const fromPage = readSchoolDisplayFromPage(); if (fromPage) return fromPage; const key = String(abb || SCHOOL || schoolAbbFromHost(host) || "").toLowerCase(); if (SCHOOL_DISPLAY_FALLBACK[key]) return SCHOOL_DISPLAY_FALLBACK[key]; return key || "\u534e\u5bcc\u6559\u80b2"; } function resolvePortal() { if (isPortal) { const portal = location.origin.replace(/\/$/, ""); const school = schoolAbbFromHost(host); try { if (typeof GM_setValue === "function") { GM_setValue(PORTAL_CACHE_KEY, { portal, school, host }); } } catch (_) {} return { portal, school, host }; } try { const cached = typeof GM_getValue === "function" ? GM_getValue(PORTAL_CACHE_KEY, null) : null; if (cached && cached.portal) { return { portal: String(cached.portal).replace(/\/$/, ""), school: String(cached.school || schoolAbbFromHost(cached.host) || "school"), host: String(cached.host || ""), }; } } catch (_) {} return { portal: "https://csustcj.edu-edu.com.cn", school: "csustcj", host: "csustcj.edu-edu.com.cn", }; } const portalInfo = resolvePortal(); let PORTAL = portalInfo.portal; let SCHOOL = portalInfo.school; let SCHOOL_DISPLAY = resolveSchoolDisplay(SCHOOL); function backUrlOf() { return `${PORTAL}/CourseList/LearnReturnUrl/LearnReturnUrlIndex?ename=${encodeURIComponent(SCHOOL)}`; } function siteLabel() { return `${SCHOOL_DISPLAY || SCHOOL} · \u534e\u5bcc\u6559\u80b2`; } function refreshSchoolDisplayUI() { SCHOOL_DISPLAY = resolveSchoolDisplay(SCHOOL); const sub = document.querySelector(`#${PANEL_ID} .cj-sub`); if (sub) sub.textContent = `${SCHOOL_DISPLAY} · \u514d\u8d39 3 \u5c0f\u8282\uff1b\u4f5c\u4e1a/\u8003\u8bd5\u9700\u4f1a\u5458`; const schoolEl = document.getElementById("csustcj-school-name"); if (schoolEl) schoolEl.textContent = SCHOOL_DISPLAY || SCHOOL || "—"; } function gmGet(k, d) { try { if (typeof GM_getValue === "function") return GM_getValue(k, d); } catch (_) {} try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch (_) { return d; } } function gmSet(k, v) { try { if (typeof GM_setValue === "function") { GM_setValue(k, v); return; } } catch (_) {} try { localStorage.setItem(k, JSON.stringify(v)); } catch (_) {} } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function esc(s) { return String(s || "") .replace(/&/g, "&") .replace(//g, ">"); } function formatDuration(sec) { const s = Math.max(0, Math.floor(Number(sec) || 0)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const ss = s % 60; if (h > 0) return `${h}\u65f6${String(m).padStart(2, "0")}\u5206`; return `${m}\u5206${String(ss).padStart(2, "0")}\u79d2`; } function getGapMs() { const n = Number(gmGet(LS.gap, 2000)); return Number.isFinite(n) && n >= 500 ? n : 2000; } function getStepSec() { const n = Number(gmGet(LS.step, 60)); if (!Number.isFinite(n) || n < 10) return 60; return Math.min(60, Math.floor(n)); } function md5(str) { function cm(x, y) { const l = (x & 0xffff) + (y & 0xffff); return (((x >> 16) + (y >> 16) + (l >> 16)) << 16) | (l & 0xffff); } function rl(n, c) { return (n << c) | (n >>> (32 - c)); } function cmn(q, a, b, x, s, t) { return cm(rl(cm(cm(a, q), cm(x, t)), s), b); } function ff(a, b, c, d, x, s, t) { return cmn((b & c) | (~b & d), a, b, x, s, t); } function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & ~d), a, b, x, s, t); } function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t); } function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | ~d), a, b, x, s, t); } function toBytes(s) { const utf8 = unescape(encodeURIComponent(s)); const out = []; for (let i = 0; i < utf8.length; i++) out.push(utf8.charCodeAt(i)); return out; } const bytes = toBytes(String(str)); const n = bytes.length; const words = []; for (let i = 0; i < n; i++) words[i >> 2] |= bytes[i] << ((i % 4) * 8); words[n >> 2] |= 0x80 << ((n % 4) * 8); const bitLen = n * 8; words[(((bitLen + 64) >>> 9) << 4) + 14] = bitLen; let a = 1732584193; let b = -271733879; let c = -1732584194; let d = 271733878; for (let i = 0; i < words.length; i += 16) { const oa = a, ob = b, oc = c, od = d; const w = []; for (let j = 0; j < 16; j++) w[j] = words[i + j] || 0; a = ff(a, b, c, d, w[0], 7, -680876936); d = ff(d, a, b, c, w[1], 12, -389564586); c = ff(c, d, a, b, w[2], 17, 606105819); b = ff(b, c, d, a, w[3], 22, -1044525330); a = ff(a, b, c, d, w[4], 7, -176418897); d = ff(d, a, b, c, w[5], 12, 1200080426); c = ff(c, d, a, b, w[6], 17, -1473231341); b = ff(b, c, d, a, w[7], 22, -45705983); a = ff(a, b, c, d, w[8], 7, 1770035416); d = ff(d, a, b, c, w[9], 12, -1958414417); c = ff(c, d, a, b, w[10], 17, -42063); b = ff(b, c, d, a, w[11], 22, -1990404162); a = ff(a, b, c, d, w[12], 7, 1804603682); d = ff(d, a, b, c, w[13], 12, -40341101); c = ff(c, d, a, b, w[14], 17, -1502002290); b = ff(b, c, d, a, w[15], 22, 1236535329); a = gg(a, b, c, d, w[1], 5, -165796510); d = gg(d, a, b, c, w[6], 9, -1069501632); c = gg(c, d, a, b, w[11], 14, 643717713); b = gg(b, c, d, a, w[0], 20, -373897302); a = gg(a, b, c, d, w[5], 5, -701558691); d = gg(d, a, b, c, w[10], 9, 38016083); c = gg(c, d, a, b, w[15], 14, -660478335); b = gg(b, c, d, a, w[4], 20, -405537848); a = gg(a, b, c, d, w[9], 5, 568446438); d = gg(d, a, b, c, w[14], 9, -1019803690); c = gg(c, d, a, b, w[3], 14, -187363961); b = gg(b, c, d, a, w[8], 20, 1163531501); a = gg(a, b, c, d, w[13], 5, -1444681467); d = gg(d, a, b, c, w[2], 9, -51403784); c = gg(c, d, a, b, w[7], 14, 1735328473); b = gg(b, c, d, a, w[12], 20, -1926607734); a = hh(a, b, c, d, w[5], 4, -378558); d = hh(d, a, b, c, w[8], 11, -2022574463); c = hh(c, d, a, b, w[11], 16, 1839030562); b = hh(b, c, d, a, w[14], 23, -35309556); a = hh(a, b, c, d, w[1], 4, -1530992060); d = hh(d, a, b, c, w[4], 11, 1272893353); c = hh(c, d, a, b, w[7], 16, -155497632); b = hh(b, c, d, a, w[10], 23, -1094730640); a = hh(a, b, c, d, w[13], 4, 681279174); d = hh(d, a, b, c, w[0], 11, -358537222); c = hh(c, d, a, b, w[3], 16, -722521979); b = hh(b, c, d, a, w[6], 23, 76029189); a = hh(a, b, c, d, w[9], 4, -640364487); d = hh(d, a, b, c, w[12], 11, -421815835); c = hh(c, d, a, b, w[15], 16, 530742520); b = hh(b, c, d, a, w[2], 23, -995338651); a = ii(a, b, c, d, w[0], 6, -198630844); d = ii(d, a, b, c, w[7], 10, 1126891415); c = ii(c, d, a, b, w[14], 15, -1416354905); b = ii(b, c, d, a, w[5], 21, -57434055); a = ii(a, b, c, d, w[12], 6, 1700485571); d = ii(d, a, b, c, w[3], 10, -1894986606); c = ii(c, d, a, b, w[10], 15, -1051523); b = ii(b, c, d, a, w[1], 21, -2054922799); a = ii(a, b, c, d, w[8], 6, 1873313359); d = ii(d, a, b, c, w[15], 10, -30611744); c = ii(c, d, a, b, w[6], 15, -1560198380); b = ii(b, c, d, a, w[13], 21, 1309151649); a = ii(a, b, c, d, w[4], 6, -145523070); d = ii(d, a, b, c, w[11], 10, -1120210379); c = ii(c, d, a, b, w[2], 15, 718787259); b = ii(b, c, d, a, w[9], 21, -343485551); a = cm(a, oa); b = cm(b, ob); c = cm(c, oc); d = cm(d, od); } function hex(n) { let s = ""; for (let i = 0; i < 4; i++) s += ((n >> (i * 8)) & 0xff).toString(16).padStart(2, "0"); return s; } return hex(a) + hex(b) + hex(c) + hex(d); } function signLearnRecord(rec) { const ts = Date.now(); const raw = String(rec.clientCode ?? "") + String(rec.userId ?? "") + String(rec.coursewareCode ?? "") + String(rec.catalogId ?? "") + String(rec.accumulativeTime ?? "") + SALT + String(ts); return { md5: md5(raw), md5Timestamp: ts }; } function gmRequest(opt) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("\u9700\u8981 GM_xmlhttpRequest\uff08Tampermonkey / ScriptCat\uff09")); return; } const req = { method: opt.method || "GET", url: opt.url, headers: opt.headers || {}, data: opt.data, responseType: opt.responseType || "text", withCredentials: opt.withCredentials !== false, timeout: opt.timeout || 45000, onload: resolve, onerror: () => reject(new Error("\u7f51\u7edc\u9519\u8bef " + (opt.url || ""))), ontimeout: () => reject(new Error("\u8d85\u65f6 " + (opt.url || ""))), }; if (opt.redirect) req.redirect = opt.redirect; GM_xmlhttpRequest(req); }); } function headerLocation(headers) { if (!headers) return ""; if (typeof headers === "object" && !Array.isArray(headers)) { for (const k of Object.keys(headers)) { if (/^location$/i.test(k)) return String(headers[k] || "").trim(); } } const m = String(headers).match(/(?:^|[\r\n])Location:\s*(\S+)/i); return m ? m[1].trim() : ""; } async function gmJson(opt) { const res = await gmRequest(opt); const text = res.responseText || ""; let j = null; try { j = text ? JSON.parse(text) : null; } catch (_) {} return { res, text, j, status: res.status, finalUrl: res.finalUrl || opt.url }; } const state = { running: false, stopFlag: false, allCourses: [], courses: [], termFilter: "current", currentTermIndex: 0, termTabs: [], chapters: [], session: null, logs: [], status: "\u5c31\u7eea", catalogTitle: "", lastTime: 0, videoTime: 0, sofar: 0, tick: 0, stall: 0, courseTitle: "", cloudApiBase: CLOUD_API_BASE, cloudToken: "", cloudTier: "free", cloudLease: "", cloudLeaseExp: 0, cloudProExpireAt: 0, freeVideoLimit: 3, freeUsedVideos: 0, tokenBoundBlocked: false, proBuyUrl: PRO_BUY_URL_DEFAULT, panelNotice: "", serverHasRebind: false, serverBuild: "", }; const CJ_DEBUG = (() => { try { return String(gmGet("cj_debug", "") || localStorage.getItem("cj_debug") || "") === "1"; } catch (_) { return false; } })(); function dbg(msg) { if (!CJ_DEBUG) return; try { console.log(`[CJ] ${msg}`); } catch (_) {} } function log(msg, level) { const text = String(msg || "").trim(); if (!text) return; const line = `${new Date().toLocaleTimeString()} ${text}`; state.logs.unshift(line); if (state.logs.length > 120) state.logs.length = 120; renderLog(); if (level === "err") setStatus(text); } function setStatus(s) { state.status = s; const t = String(s || ""); const el = document.getElementById("csustcj-status"); if (el) { el.textContent = s; el.classList.remove( "cj-status-running", "cj-status-starting", "cj-status-stopped", "cj-status-error" ); if (/\u51fa\u9519|\u5931\u8d25|\u5f02\u5e38/.test(t)) el.classList.add("cj-status-error"); else if (/\u505c\u6b62/.test(t)) el.classList.add("cj-status-stopped"); else if (/\u5f00\u59cb|\u5b66\u4e60|\u4f5c\u4e1a|\u8003\u8bd5|\u52a0\u8f7d|\u5207\u6362/.test(t)) el.classList.add("cj-status-starting"); else if (/\u5b8c\u6210|\u5c31\u7eea|\u5df2\u52a0\u8f7d/.test(t)) el.classList.add("cj-status-running"); } const foot = document.getElementById("csustcj-queue-text"); if (foot) foot.textContent = t || "\u672a\u9009\u62e9"; } function getCloudToken() { const t = String(gmGet(CLOUD_TOKEN_KEY, "") || state.cloudToken || "").trim(); state.cloudToken = t; return t; } function setCloudToken(token) { const t = String(token || "").trim(); state.cloudToken = t; gmSet(CLOUD_TOKEN_KEY, t); } function isProUser() { return String(state.cloudTier || "").toLowerCase() === "pro" && !state.tokenBoundBlocked; } function clearProMembershipState() { state.cloudTier = "free"; state.cloudProExpireAt = 0; state.tokenBoundBlocked = false; state.cloudLease = ""; state.cloudLeaseExp = 0; cacheCloudLease(); } async function fallBackToFreeLease() { clearProMembershipState(); try { await ensureCloudLease(true); } catch (_) {} } function readCookie(name) { try { const m = String(document.cookie || "").match( new RegExp(`(?:^|;\\s*)${String(name).replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}=([^;]*)`, "i") ); return m ? decodeURIComponent(String(m[1] || "").trim()) : ""; } catch (_) { return ""; } } function isSharedAnonId(id) { const s = String(id || "").trim(); if (!/^anon_/i.test(s)) return false; if (/\.edu-|edu-xl\.com|edu-edu\.com/i.test(s)) return true; const parts = s.split("_"); if (parts.length >= 3 && String(parts.slice(2).join("_")).includes(".")) return true; return false; } function isBrowserUniqueAnonId(id) { const s = String(id || "").trim(); return /^anon_[a-z0-9]+_[a-z0-9]+$/i.test(s) && !isSharedAnonId(s); } function scrapePortalUserId() { try { if (state.session && state.session.userId) return String(state.session.userId).trim(); } catch (_) {} const html = String((document.documentElement && document.documentElement.innerHTML) || ""); const patterns = [ /userid\s*=\s*['"](\d{15,18})['"]/i, /userId\s*[:=]\s*['"](\d{15,18})['"]/i, /['"]userId['"]\s*[:=]\s*['"](\d{15,18})['"]/i, /examineeNo\s*[:=]\s*['"]([A-Za-z0-9_+/=-]{6,})['"]/i, /studentNo\s*[:=]\s*['"]([A-Za-z0-9_+/=-]{6,})['"]/i, ]; for (const re of patterns) { const m = html.match(re); if (m && m[1]) return String(m[1]).trim(); } const cookieKeys = ["student_id", "personId", "examineeNo", "studentNo", "QztcXwStudent_id"]; for (const k of cookieKeys) { const v = readCookie(k); if (v && v.length >= 4) return `${k}:${v}`; } return ""; } function getPortalProfile() { const html = String((document.documentElement && document.documentElement.innerHTML) || ""); let name = ""; let userName = ""; const idM = html.match(/userid\s*=\s*['"](\d{15,18})['"]/i) || html.match(/userId\s*[:=]\s*['"](\d{15,18})['"]/i); if (idM) userName = idM[1]; const nameM = html.match(/\u59d3\u540d[\uff1a:]\s*([^\s<]{2,20})/) || html.match(/stuname['"]?\s*[:=]\s*['"]([^'"]{2,40})['"]/i); if (nameM) { const raw = String(nameM[1] || "").trim(); if (raw && !/[=+/]/.test(raw) && !/^[a-z0-9]{8,}$/i.test(raw)) name = raw; } return { name, userName, phone: "", school_name: SCHOOL_DISPLAY || SCHOOL, }; } function rememberTokenBoundUid(uid) { const tk = getCloudToken(); const id = String(uid || "").trim(); if (!tk || !id) return; gmSet(TOKEN_BOUND_UID_KEY, { token: tk, uid: id }); } function readTokenBoundUid() { try { const row = gmGet(TOKEN_BOUND_UID_KEY, null); if (!row || typeof row !== "object") return ""; const tk = getCloudToken(); if (!tk || String(row.token || "") !== tk) return ""; const uid = String(row.uid || "").trim(); if (!uid || isSharedAnonId(uid)) return ""; return uid; } catch (_) { return ""; } } function getLearningUserId() { const portal = scrapePortalUserId(); if (portal) { state.learningUserId = portal; gmSet(LEARNING_UID_KEY, portal); rememberTokenBoundUid(portal); return portal; } const boundUid = readTokenBoundUid(); if (boundUid) { state.learningUserId = boundUid; gmSet(LEARNING_UID_KEY, boundUid); return boundUid; } let stored = String(gmGet(LEARNING_UID_KEY, "") || state.learningUserId || "").trim(); if (stored && !isSharedAnonId(stored)) { state.learningUserId = stored; return stored; } const id = `anon_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; state.learningUserId = id; gmSet(LEARNING_UID_KEY, id); return id; } function getCloudSite() { try { return String(location.hostname || SCHOOL || "").toLowerCase(); } catch (_) { return String(SCHOOL || "school").toLowerCase(); } } function formatProExpireAt(ts) { const n = Number(ts) || 0; if (!n) return ""; const ms = n > 1e12 ? n : n * 1000; const d = new Date(ms); if (Number.isNaN(d.getTime())) return ""; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } function cacheCloudLease() { gmSet(CLOUD_LEASE_KEY, { lease: state.cloudLease, exp: state.cloudLeaseExp, tier: state.cloudTier, freeVideoLimit: state.freeVideoLimit, freeUsedVideos: state.freeUsedVideos, proExpireAt: state.cloudProExpireAt, tokenBoundBlocked: state.tokenBoundBlocked, }); } function applyCloudLeaseData(data) { if (!data || typeof data !== "object") return; if (data.lease) state.cloudLease = String(data.lease); if (data.lease_exp != null || data.exp != null) { state.cloudLeaseExp = Number(data.lease_exp ?? data.exp) || 0; } if (data.tier) state.cloudTier = String(data.tier); if (data.free_video_limit != null || data.freeVideoLimit != null) { state.freeVideoLimit = Number(data.free_video_limit ?? data.freeVideoLimit) || 3; } if (data.free_used_videos != null || data.freeUsedVideos != null) { state.freeUsedVideos = Number(data.free_used_videos ?? data.freeUsedVideos) || 0; } if (data.pro_expires_at != null || data.proExpireAt != null) { state.cloudProExpireAt = Number(data.pro_expires_at ?? data.proExpireAt) || 0; } if (data.token_bound_blocked != null || data.tokenBoundBlocked != null) { state.tokenBoundBlocked = !!(data.token_bound_blocked ?? data.tokenBoundBlocked); } if (data.proBuyUrl) state.proBuyUrl = String(data.proBuyUrl).trim() || PRO_BUY_URL_DEFAULT; if (data.panelNotice) state.panelNotice = String(data.panelNotice); if (data.cloudApiBase) state.cloudApiBase = String(data.cloudApiBase).replace(/\/$/, "") || CLOUD_API_BASE; cacheCloudLease(); updateCloudPanelUI(); } async function cloudRequest(path, method, payload) { const base = (state.cloudApiBase || CLOUD_API_BASE).replace(/\/$/, ""); const url = `${base}${path.startsWith("/") ? path : "/" + path}`; const isGet = String(method || "POST").toUpperCase() === "GET"; const headers = { Accept: "application/json, text/plain, */*", ...(isGet ? {} : { "Content-Type": "application/json" }), "x-learning-user-id": getLearningUserId(), }; const tk = getCloudToken(); if (tk) headers.Authorization = `Bearer ${tk}`; let bodyObj = payload; if (bodyObj && typeof bodyObj === "object") { bodyObj = Object.assign({}, bodyObj); if (state.cloudLease && bodyObj.lease == null && !/\/lease(\?|$)/.test(path)) { bodyObj.lease = state.cloudLease; } } const body = isGet || bodyObj == null ? undefined : JSON.stringify(bodyObj); const res = await gmRequest({ method: isGet ? "GET" : method || "POST", url, headers, data: body, timeout: 45000, withCredentials: false, }); let data = {}; try { data = res.responseText ? JSON.parse(res.responseText) : {}; } catch (_) { data = {}; } if (res.status < 200 || res.status >= 300) { const detail = data.detail; if (detail && typeof detail === "object") { applyCloudLeaseData(detail); const err = new Error(String(detail.message || detail.code || "cloud_error")); err.code = String(detail.code || detail.message || ""); err.detail = detail; throw err; } const raw = String(data.detail || data.message || data.code || `http_${res.status}`); const err = new Error(raw); err.code = String(data.code || data.message || raw || ""); if (/free_quota/i.test(raw)) err.code = "free_quota_exhausted"; throw err; } return data; } function isQuotaError(e) { const msg = String((e && e.message) || e || ""); const code = String((e && e.code) || ""); return code === "free_quota_exhausted" || /free_quota_exhausted|free_quota|\u514d\u8d39\u4f53\u9a8c\u5df2\u7528\u5b8c/i.test(msg); } function friendlyCloudMessage(raw) { const msg = String((raw && raw.message) || raw || "").trim(); if (isQuotaError(raw) || /free_quota|\u514d\u8d39\u4f53\u9a8c\u5df2\u7528\u5b8c/i.test(msg)) { return `\u514d\u8d39\u6b21\u6570\u7528\u5b8c\u4e86\uff08${state.freeUsedVideos}/${state.freeVideoLimit}\uff09\uff0c\u5f00\u901a\u4f1a\u5458\u540e\u53ef\u7ee7\u7eed\u5b66\u3002`; } if (/token.?bound|\u5df2\u7ed1\u5b9a\u5176\u4ed6/i.test(msg)) return "\u8fd9\u5f20\u4f1a\u5458\u5361\u5df2\u5728\u522b\u7684\u8d26\u53f7\u7528\u8fc7\uff0c\u8fd9\u8fb9\u4ecd\u6309\u514d\u8d39\u8bd5\u7528\u3002"; if (/revoked|\u5df2\u7981\u7528/i.test(msg)) return "\u8fd9\u5f20\u4f1a\u5458\u5361\u4e0d\u53ef\u7528\u4e86\uff0c\u8bf7\u6362\u4e00\u5f20\u6216\u91cd\u65b0\u5f00\u901a\u3002"; if (/expired_after_activation/i.test(msg)) return "\u4f1a\u5458\u5df2\u5230\u671f\uff08\u81ea\u5f00\u901a\u8d77 30 \u5929\uff09\uff0c\u8bf7\u91cd\u65b0\u5f00\u901a\u3002"; if (/expired|\u5df2\u8fc7\u671f/i.test(msg)) return "\u4f1a\u5458\u5df2\u8fc7\u671f\uff0c\u8bf7\u91cd\u65b0\u5f00\u901a\u3002"; if (/invalid token/i.test(msg)) return "\u4f1a\u5458\u5361\u5bc6\u4e0d\u5bf9\uff0c\u8bf7\u68c0\u67e5\u540e\u91cd\u65b0\u7c98\u8d34\u3002"; if (/token_bound|\u5df2\u7ed1\u5b9a\u5176\u4ed6/i.test(msg) || (raw && raw.code === "token_bound")) { return "\u6b64\u5361\u5bc6\u5df2\u7ed1\u5b9a\u5176\u4ed6\u5b66\u4e60\u8d26\u53f7\u3002\u8bf7\u66f4\u65b0\u4e91\u7aef\u540e\u91cd\u8bd5\uff1b\u82e5\u540e\u53f0\u4ecd\u662f anon_\u6821\u540d_\u57df\u540d\uff0c\u628a\u8be5\u5361\u300c\u7ed1\u5b9a\u8d26\u53f7\u300d\u6e05\u7a7a\u518d\u4fdd\u5b58\u5361\u5bc6\u3002"; } if (/invalid_lease|cloud_unavailable|\u6682\u65f6\u65e0\u6cd5\u786e\u8ba4/i.test(msg)) return "\u6682\u65f6\u8fde\u4e0d\u4e0a\u6388\u6743\u670d\u52a1\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5\u3002"; return msg || "\u6682\u65f6\u6ca1\u6cd5\u7ee7\u7eed\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5\u3002"; } async function ensureCloudLease(force) { const now = Math.floor(Date.now() / 1000); const hasToken = !!getCloudToken(); if (hasToken && String(state.cloudTier || "").toLowerCase() !== "pro") force = true; if (!force && state.cloudLease && state.cloudLeaseExp - now > 60) return true; if (!force) { const cached = gmGet(CLOUD_LEASE_KEY, null); if (cached && Number(cached.exp) - now > 60) { const cachedTier = String(cached.tier || "").toLowerCase(); if (!(hasToken && cachedTier !== "pro")) { state.cloudLease = cached.lease || ""; state.cloudLeaseExp = Number(cached.exp) || 0; state.cloudTier = String(cached.tier || "free"); if (cached.freeVideoLimit != null) state.freeVideoLimit = Number(cached.freeVideoLimit) || 3; if (cached.freeUsedVideos != null) state.freeUsedVideos = Number(cached.freeUsedVideos) || 0; if (cached.proExpireAt != null) state.cloudProExpireAt = Number(cached.proExpireAt) || 0; if (cached.tokenBoundBlocked != null) state.tokenBoundBlocked = !!cached.tokenBoundBlocked; updateCloudPanelUI(); return !!state.cloudLease; } } } const profile = getPortalProfile(); const luid = getLearningUserId(); const data = await cloudRequest("/api/csustcj/lease", "POST", { token: getCloudToken(), learning_user_id: luid, userid: luid, site: getCloudSite(), site_name: SCHOOL_DISPLAY || SCHOOL, school_name: profile.school_name || SCHOOL_DISPLAY || SCHOOL, name: profile.name || "", shortName: profile.name || "", userName: profile.userName || "", user_name: profile.userName || "", phone: profile.phone || "", }); applyCloudLeaseData(data); if (data.token_bound_blocked || data.tokenBoundBlocked) { state.tokenBoundBlocked = true; state.cloudTier = "free"; updateCloudPanelUI(); const err = new Error("token_bound_to_other_learning_user"); err.code = "token_bound"; throw err; } if (String(state.cloudTier || "").toLowerCase() === "pro") { rememberTokenBoundUid(luid); } dbg(`\u79df\u7ea6\u6210\u529f tier=${state.cloudTier} uid=${luid}`); return !!state.cloudLease; } async function cloudConsumeChapter(chapter, course) { await ensureCloudLease(!!getCloudToken()); if (isProUser()) return { tier: "pro" }; if (!state.cloudLease) { const err = new Error("\u6682\u65f6\u65e0\u6cd5\u786e\u8ba4\u5b66\u4e60\u6b21\u6570\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5"); err.code = "cloud_unavailable"; throw err; } const payload = { cs_id: String((chapter && chapter.catalogId) || ""), res_id: String((chapter && chapter.catalogId) || ""), hour_id: String((chapter && chapter.catalogId) || ""), course_id: String((course && course.termCourseID) || (course && course.courseCode) || ""), site: getCloudSite(), }; try { const data = await cloudRequest("/api/csustcj/video/consume", "POST", payload); applyCloudLeaseData(data); return data; } catch (e) { if (isQuotaError(e) && getCloudToken()) { await ensureCloudLease(true); if (isProUser()) { const data = await cloudRequest("/api/csustcj/video/consume", "POST", payload); applyCloudLeaseData(data); return data; } } throw e; } } function buildCoursePlanPayload(courses) { return (courses || []).map((c) => ({ termCourseID: String(c.termCourseID || ""), title: String(c.title || ""), lessonScore: c.lessonScore, homeworkScore: c.homeworkScore, examScore: c.examScore, homeworkFull: c.homeworkFull === true, examFull: c.examFull === true, needMin: c.needMin, learnedMin: c.learnedMin, hasHomework: !!c.hasHomework, hasExam: !!c.hasExam, quotaMet: !!courseQuotaMet(c, c.learnedMin), })); } async function cloudStudyPlan(courses, mode) { await ensureCloudLease(!!getCloudToken()); if (!state.cloudLease) throw new Error("\u6682\u65f6\u65e0\u6cd5\u786e\u8ba4\u5b66\u4e60\u6743\u9650\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5"); const data = await cloudRequest("/api/csustcj/study/plan", "POST", { mode: mode || "all", courses: buildCoursePlanPayload(courses), }); applyCloudLeaseData(data); return Array.isArray(data.actions) ? data.actions : []; } function updateCloudPanelUI() { const tierEl = document.getElementById("csustcj-cloud-tier"); const quotaEl = document.getElementById("csustcj-cloud-quota"); const tokenEl = document.getElementById("csustcj-cloud-token"); const annEl = document.getElementById("csustcj-ann-text"); const pro = isProUser(); if (tierEl) { if (pro) { const exp = formatProExpireAt(state.cloudProExpireAt); tierEl.textContent = exp ? `\u4f1a\u5458 · \u81f3 ${exp}` : "\u4f1a\u5458"; } else { tierEl.textContent = "\u514d\u8d39\u8bd5\u7528"; } } if (quotaEl) { quotaEl.textContent = pro ? "\u4e0d\u9650\u6b21\u6570" : `\u5df2\u7528 ${state.freeUsedVideos}/${state.freeVideoLimit} \u5c0f\u8282`; } if (tokenEl && document.activeElement !== tokenEl) tokenEl.value = getCloudToken(); if (annEl) { const n = String(state.panelNotice || PANEL_NOTICE || "") .replace(/oa\d+\.?ahzsksw\.cn/gi, "") .replace(/oa\d+/gi, "") .replace(/Token/gi, "\u5361\u5bc6") .replace(/\bPro\b/g, "\u4f1a\u5458") .replace(/\s{2,}/g, " ") .trim(); annEl.textContent = n || PANEL_NOTICE; } } async function refreshCloudClientConfig() { try { const data = await cloudRequest("/api/csustcj/client-config", "GET"); applyCloudLeaseData(data); const feat = (data && (data.serverFeatures || data.server_features)) || {}; state.serverHasRebind = !!(feat.shared_anon_rebind || feat.sharedAnonRebind); if (data && data.server_build) state.serverBuild = String(data.server_build); } catch (_) {} } async function initCloudAuth() { getCloudToken(); await refreshCloudClientConfig(); try { await ensureCloudLease(true); } catch (e) { const raw = String((e && e.message) || e || ""); const tip = friendlyCloudMessage(e); dbg(`\u4e91\u7aef\u6388\u6743\uff1a${tip}`); if (/invalid token|revoked|expired/i.test(raw + " " + tip)) { setCloudToken(""); await fallBackToFreeLease(); } else if (/token_bound/i.test(raw) || (e && e.code === "token_bound")) { state.cloudTier = "free"; state.cloudProExpireAt = 0; state.tokenBoundBlocked = true; cacheCloudLease(); } } updateCloudPanelUI(); } function openProBuy() { const url = state.proBuyUrl || PRO_BUY_URL_DEFAULT; try { if (typeof GM_openInTab === "function") GM_openInTab(url, { active: true }); else window.open(url, "_blank"); } catch (_) { window.open(url, "_blank"); } } function parseQuery(url) { try { const u = new URL(url, location.href); const o = {}; u.searchParams.forEach((v, k) => { o[k] = v; }); return o; } catch (_) { const o = {}; const q = String(url).split("?")[1] || ""; q.split("&").forEach((p) => { const i = p.indexOf("="); if (i < 0) return; o[decodeURIComponent(p.slice(0, i))] = decodeURIComponent(p.slice(i + 1) || ""); }); return o; } } function looksLikeCourseCode(s) { return /^[a-z]{1,10}_[a-z0-9_]+$/i.test(String(s || "").trim()); } function cleanCourseTitle(raw) { const t = String(raw || "") .replace(/<[^>]+>/g, "") .replace(/ /gi, " ") .replace(/&/gi, "&") .replace(/\s+/g, " ") .trim(); if (!t || looksLikeCourseCode(t)) return ""; return t; } function extractTitleNear(chunk, courseCode) { if (!chunk) return ""; const plains = [...chunk.matchAll(/]*)?>\s*([^<]{2,80}?)\s*<\/h3>/gi)]; for (let i = plains.length - 1; i >= 0; i--) { const t = cleanCourseTitle(plains[i][1]); if (t) return t; } const withA = [...chunk.matchAll(/]*>\s*]*>\s*([^<]{2,80}?)\s*<\/a>\s*<\/h3>/gi)]; for (let i = withA.length - 1; i >= 0; i--) { const t = cleanCourseTitle(withA[i][1]); if (t) return t; } const anyH = [...chunk.matchAll(/]*>\s*([\s\S]*?)\s*<\/h[1-4]>/gi)]; for (let i = anyH.length - 1; i >= 0; i--) { const t = cleanCourseTitle(anyH[i][1]); if (t) return t; } return looksLikeCourseCode(courseCode) ? "" : cleanCourseTitle(courseCode); } function collectTermSections(text) { const sections = []; const re = /\u7b2c\s*\s*([\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u96f6\d]+)\s*<\/span>\s*\u5b66\u671f|\u7b2c([\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u96f6\d]+)\u5b66\u671f/g; let m; while ((m = re.exec(text))) { const num = m[1] || m[2]; const label = `\u7b2c${num}\u5b66\u671f`; const last = sections[sections.length - 1]; if (last && last.label === label && m.index - last.pos < 80) continue; if (last && last.label === label) continue; sections.push({ pos: m.index, label, index: sections.length }); } return sections; } function termSectionAt(sections, pos) { let hit = null; for (let i = 0; i < sections.length; i++) { if (sections[i].pos <= pos) hit = sections[i]; else break; } return hit; } function parseCoursesFromHtml(html) { const text = String(html || ""); const termSections = collectTermSections(text); let currentTermIndex = Math.max(0, termSections.length - 1); const dqxqPos = text.search(/id=["']dqxqTerm["']/i); if (dqxqPos >= 0) { const sec = termSectionAt(termSections, dqxqPos); if (sec) currentTermIndex = sec.index; else { const dqxqM = text.match(/id=["']dqxqTerm["'][^>]*\bvalue=["'](\d+)["']/i) || text.match(/\bvalue=["'](\d+)["'][^>]*id=["']dqxqTerm["']/i); if (dqxqM) currentTermIndex = parseInt(dqxqM[1], 10) || currentTermIndex; } } else { const huoPos = text.search(/title=["']\u5f53\u524d\u5b66\u671f["']/i); if (huoPos >= 0) { const sec = termSectionAt(termSections, huoPos); if (sec) currentTermIndex = sec.index; } } const courses = []; const re = /kjxx_click\(\s*'([^']+)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']*)'\s*,\s*'([^']+)'\s*\)/g; let m; while ((m = re.exec(text))) { const courseCode = m[1]; const timeStart = m[2]; const timeEnd = m[3]; const stemcode = m[4]; const isFaceKJ = m[5]; const termCourseID = m[6]; const before = text.slice(Math.max(0, m.index - 5000), m.index); const cardStart = Math.max(before.lastIndexOf("single-lists"), before.lastIndexOf("list-content")); const cardChunk = cardStart >= 0 ? before.slice(cardStart) : before.slice(-1600); let title = extractTitleNear(cardChunk, courseCode); if (!title) title = extractTitleNear(before.slice(-2200), courseCode); if (!title) title = courseCode; const sec = termSectionAt(termSections, m.index); const termIndex = sec ? sec.index : currentTermIndex; const termLabel = sec ? sec.label : ""; let portalScore = null; const scoreM = before.match(new RegExp(`id=['"]lblcurcj${termCourseID}['"][^>]*>\\s*([^<]+)`, "i")); if (scoreM) { const n = parseFloat(String(scoreM[1]).trim()); if (Number.isFinite(n)) portalScore = n; } let hasHomework = false; let hasExam = false; let zyPortalCode = ""; const zyChunk = text.slice(Math.max(0, m.index - 200), Math.min(text.length, m.index + 1600)); const zyM = zyChunk.match( /pszy_click\(\s*'([^']+)'\s*,\s*'[^']*'\s*,\s*'[^']*'\s*,\s*'[^']*'\s*,\s*'[^']*'\s*,\s*'[^']*'\s*,\s*'([^']+)'\s*\)/ ); if (zyM && String(zyM[2]) === String(termCourseID)) { hasHomework = true; zyPortalCode = zyM[1]; } else if (/pszy_click\(/i.test(zyChunk) && new RegExp(`['"]${termCourseID}['"]`).test(zyChunk)) { hasHomework = true; } if ( /qmks_click\(/i.test(zyChunk) && new RegExp(`['"]${termCourseID}['"]`).test(zyChunk) ) { hasExam = true; } if (!courses.some((c) => c.termCourseID === termCourseID && c.courseCode === courseCode)) { courses.push({ courseCode, termCourseID, title, termLabel, termIndex, isCurrentTerm: termIndex === currentTermIndex, timeStart, timeEnd, stemcode, isFaceKJ: String(isFaceKJ || "0"), portalScore, lessonScore: null, homeworkScore: null, examScore: null, needMin: null, learnedMin: null, hasHomework, hasExam, zyPortalCode, checked: true, }); } } return { courses, currentTermIndex, termSections }; } function buildTermTabs(allCourses, currentTermIndex) { const map = new Map(); for (const c of allCourses) { const idx = Number(c.termIndex); if (!Number.isFinite(idx)) continue; if (!map.has(idx)) map.set(idx, c.termLabel || `\u7b2c${idx + 1}\u5b66\u671f`); } const tabs = [{ key: "current", label: "\u5f53\u524d\u5b66\u671f" }]; [...map.keys()] .sort((a, b) => a - b) .forEach((idx) => { const lab = map.get(idx); const curMark = idx === currentTermIndex ? "\uff08\u5f53\u524d\uff09" : ""; tabs.push({ key: String(idx), label: `${lab}${curMark}` }); }); tabs.push({ key: "all", label: "\u5168\u90e8\u5b66\u671f" }); return tabs; } function applyTermFilter() { const all = state.allCourses || []; const key = state.termFilter || "current"; let list; if (key === "all") { list = all.slice(); } else if (key === "current") { list = all.filter((c) => c.isCurrentTerm || c.termIndex === state.currentTermIndex); if (!list.length && all.length) { const maxIdx = all.reduce((m, c) => Math.max(m, Number(c.termIndex) || 0), 0); list = all.filter((c) => c.termIndex === maxIdx); } } else { const idx = parseInt(key, 10); list = Number.isFinite(idx) ? all.filter((c) => c.termIndex === idx) : all.slice(); } state.courses = list; return list; } function sectionBlockForLabel(text, label) { const re = new RegExp( `class=["']titleSec["'][^>]*>\\s*${label}[\\s\\S]*?(?=]*class=["']titleSec["']|$)`, "i" ); const m = String(text || "").match(re); return m ? m[0] : ""; } function parseWeightPct(text, label) { const src = String(text || ""); const re = new RegExp(`${label}[\\s\\S]{0,400}?(\\d{1,3})\\s*[%\uff05]`, "i"); const m = src.match(re); if (!m) return null; const n = parseInt(m[1], 10); return Number.isFinite(n) && n > 0 && n <= 100 ? n : null; } function parseRawScoresInBlock(block, weightPct) { const src = String(block || ""); const out = []; const pairRe = /\u6ee1\u5206\s*[\uff1a:]\s*([\d.]+)[\s\S]{0,80}?\u5f97\u5206\s*[\uff1a:]\s*([\d.]+)/gi; let pm; while ((pm = pairRe.exec(src))) { const n = parseFloat(pm[2]); if (!Number.isFinite(n)) continue; if (n > 0 && n <= 100) out.push(n); } if (out.length) return out; const re = /(?:\u5b9e)?\u5f97\u5206\s*[\uff1a:]\s*([\d.]+)/gi; let m; while ((m = re.exec(src))) { const n = parseFloat(m[1]); if (!Number.isFinite(n)) continue; if (weightPct != null && weightPct > 0 && n <= Number(weightPct) + 0.5) continue; if (n > 0 && n <= 100) out.push(n); } return out; } function isPartScoreFull(score, weightPct, rawScores) { const w = weightPct != null && weightPct > 0 ? Number(weightPct) : null; const papers = (rawScores || []) .map((s) => Number(s)) .filter((n) => Number.isFinite(n) && n > 0 && n <= 100) .filter((n) => (w == null ? n > 60 || n >= 99.5 : n > w + 0.5)); if (papers.length) { return papers.every((s) => s >= 99.5); } if (score == null || !Number.isFinite(Number(score))) return false; const n = Number(score); if (w != null && n > w + 1) return n >= 99.5; if (n >= 99.5) return true; if (w != null && n <= w + 0.5) return n >= w * 0.995; for (const mx of [10, 20, 30, 40, 50, 60]) { if (n <= mx + 0.05 && n >= mx * 0.995) return true; } return false; } function parseStudyQuotaHtml(html) { const detail = { learnedMin: null, needMin: null, lessonScore: null, homeworkScore: null, examScore: null, homeworkFull: false, examFull: false, lessonFull: false, }; const text = String(html || ""); const timeM = text.match( /class=["']time["'][^>]*>\s*([^<]+)\s*<\/em>\s*\/\s*]*class=["']all["'][^>]*>\s*([^<]+)/i ); if (timeM) { detail.learnedMin = parseInt(String(timeM[1]).trim(), 10) || 0; detail.needMin = parseInt(String(timeM[2]).trim(), 10) || 0; } else { const m1 = text.match(/\u5df2\u5b66\u65f6\u957f\s*[\uff1a:]\s*(\d+)\s*\u5206\u949f[\s\S]{0,40}?\u5e94\u5b66\u65f6\u957f\s*[\uff1a:]\s*(\d+)\s*\u5206\u949f/i); if (m1) { detail.learnedMin = parseInt(m1[1], 10) || 0; detail.needMin = parseInt(m1[2], 10) || 0; } else { const m2 = text.match(/\u5e94\u5b66\u65f6\u957f\s*[\uff1a:]\s*(\d+)\s*\u5206\u949f[\s\S]{0,40}?\u5df2\u5b66\u65f6\u957f\s*[\uff1a:]\s*(\d+)\s*\u5206\u949f/i); if (m2) { detail.needMin = parseInt(m2[1], 10) || 0; detail.learnedMin = parseInt(m2[2], 10) || 0; } else { const needOnly = text.match(/\u5e94\u5b66\u65f6\u957f\s*[\uff1a:]\s*(\d+)\s*\u5206?\u949f?/i); const learnedOnly = text.match(/\u5df2\u5b66\u65f6\u957f\s*[\uff1a:]\s*(\d+)\s*\u5206?\u949f?/i); if (needOnly) detail.needMin = parseInt(needOnly[1], 10) || 0; if (learnedOnly) detail.learnedMin = parseInt(learnedOnly[1], 10) || 0; if (detail.needMin == null || detail.learnedMin == null) { const m3 = text.match(/(\d+)\s*\/\s*(\d+)\s*\(?\s*\u5206\u949f/); if (m3) { if (detail.learnedMin == null) detail.learnedMin = parseInt(m3[1], 10) || 0; if (detail.needMin == null) detail.needMin = parseInt(m3[2], 10) || 0; } } } } } const wLesson = parseWeightPct(text, "\u8bfe\u4ef6\u5b66\u4e60"); const wHw = parseWeightPct(text, "\u5e73\u65f6\u4f5c\u4e1a"); const wEx = parseWeightPct(text, "\u671f\u672b\u8003\u8bd5"); const pickPart = (label, weightPct) => { const block = sectionBlockForLabel(text, label); if (!block) return { score: null, raw: [] }; const rowspan = block.match(/rowspan\s*=\s*["']?\d+["']?[^>]*>\s*\u5f97\u5206\s*[\uff1a:]\s*([\d.]+)/i); let score = null; if (rowspan) { const n = parseFloat(rowspan[1]); if (Number.isFinite(n)) score = n; } else { const all = [...block.matchAll(/\u5f97\u5206\s*[\uff1a:]\s*([\d.]+)/g)].map((x) => parseFloat(x[1])).filter(Number.isFinite); if (all.length) { if (weightPct != null && weightPct > 0) { const folded = all.filter((n) => n <= weightPct + 0.5); score = folded.length ? folded[folded.length - 1] : all[all.length - 1]; } else { score = /\u671f\u672b/.test(label) && all.length > 1 ? all[all.length - 1] : all[0]; } } } return { score, raw: parseRawScoresInBlock(block, weightPct) }; }; const lesson = pickPart("\u8bfe\u4ef6\u5b66\u4e60", wLesson); const hw = pickPart("\u5e73\u65f6\u4f5c\u4e1a", wHw); const ex = pickPart("\u671f\u672b\u8003\u8bd5", wEx); detail.lessonScore = lesson.score; detail.homeworkScore = hw.score; detail.examScore = ex.score; detail.lessonFull = isPartScoreFull(lesson.score, wLesson, lesson.raw); detail.homeworkFull = isPartScoreFull(hw.score, wHw, hw.raw); detail.examFull = isPartScoreFull(ex.score, wEx, ex.raw); const nameM = text.match(/\u8bfe\u7a0b\u540d\u79f0\s*[\uff1a:]\s*([^<]{2,80})/i) || text.match(/\s*([^<]{2,80}?)\s*<\/title>/i) || text.match(/class=["'][^"']*courseName[^"']*["'][^>]*>\s*([^<]{2,80})/i); if (nameM) { const n = cleanCourseTitle(nameM[1]); if (n) detail.courseName = n; } return detail; } function formatScorePart(n) { if (n == null || !Number.isFinite(Number(n))) return "—"; const v = Number(n); return Number.isInteger(v) ? String(v) : String(Math.round(v * 10) / 10); } function courseScoreLine(c) { const parts = [ `\u8bfe\u65f6\u5f97\u5206${formatScorePart(c.lessonScore)}`, `\u4f5c\u4e1a\u5f97\u5206${formatScorePart(c.homeworkScore)}`, `\u671f\u672b\u5f97\u5206${formatScorePart(c.examScore)}`, ]; if (courseQuotaMet(c, c.learnedMin)) parts.push("\u65f6\u957f\u5df2\u6ee1"); return parts.join(" · "); } async function fetchCourseStudyQuota(course) { const path = `/MyOnlineCourseNew/ScoreDetailListNew/ScoreDetailListNewIndex?type=0&termCourseID=${encodeURIComponent( course.termCourseID )}`; try { const html = await fetchPortalHtml(path); const q = parseStudyQuotaHtml(html); if (q.needMin != null) course.needMin = q.needMin; if (q.learnedMin != null) course.learnedMin = q.learnedMin; if (q.lessonScore != null) course.lessonScore = q.lessonScore; if (q.homeworkScore != null) course.homeworkScore = q.homeworkScore; if (q.examScore != null) course.examScore = q.examScore; if (typeof q.homeworkFull === "boolean") course.homeworkFull = q.homeworkFull; if (typeof q.examFull === "boolean") course.examFull = q.examFull; if (typeof q.lessonFull === "boolean") course.lessonFull = q.lessonFull; if (q.homeworkScore != null) course.hasHomework = true; if (q.examScore != null) course.hasExam = true; if (q.courseName && looksLikeCourseCode(course.title)) course.title = q.courseName; return q; } catch (e) { dbg(`\u6210\u7ee9\u8be6\u60c5\u5931\u8d25\uff1a${e.message || e}`); log(`\u300c${course.title}\u300d\u6210\u7ee9\u8be6\u60c5\u6682\u4e0d\u53ef\u7528`); return { learnedMin: course.learnedMin, needMin: course.needMin }; } } function sumChaptersSofarMin(chapters) { const sec = (chapters || []).reduce((s, ch) => s + (Number(ch.sofar) || 0), 0); return Math.floor(sec / 60); } function courseQuotaMet(course, localMin) { const need = Number(course.needMin) || 0; if (need <= 0) return false; const learned = Math.max(Number(course.learnedMin) || 0, Number(localMin) || 0); return learned >= need; } const COURSE_LIST_PATH = "/MyOnlineCourseNew/OnlineLearningNew/OnlineLearningNewIndex?isCurrent=0&type=0"; function looksLikePortalLoginHtml(html) { const h = String(html || ""); if (!h) return false; if (h.length >= 4000) return false; return /Object moved|ReturnUrl=|\/Account\/Login|\/Home\/Login|\u8bf7\u5148\u767b\u5f55|\u91cd\u65b0\u767b\u5f55/i.test(h); } function looksLikeExIndexHtml(html) { const h = String(html || ""); if (/\u5f00\u59cb\u8003\u8bd5|start_testing|\u5269\u4f59\u8003\u8bd5\u6b21\u6570/i.test(h)) return true; if (/pszy_click2\s*\(/i.test(h) && /hidSign|#hidcourse_id|StudentSiteNewExIndex/i.test(h)) return true; if (/titer-li|task_list/i.test(h) && /pszy_click2\s*\(/i.test(h)) return true; return false; } function isValidExPaperFields(termCourseID, signCJ, examCode) { const tid = String(termCourseID || "").trim(); const sign = String(signCJ || "").trim(); const code = String(examCode || "").trim(); if (!code || !/^[A-Za-z0-9_.-]+$/.test(code)) return false; if (!/^\d{3,}$/.test(tid)) return false; if (!/^[a-f0-9]{32}$/i.test(sign)) return false; return true; } function looksLikePortalSysError(html) { const h = String(html || ""); return /Index\/Error|\u7cfb\u7edf\u9519\u8bef|\u5728\u6267\u884c\s*controller|controller\[OnlineLearningNew\]/i.test(h); } function findPortalContentFrame() { const sels = [ "iframe#mainiframe", "iframe[name='mainiframe']", "iframe#iframeMain", "iframe#mainFrame", "iframe.mainiframe", "iframe[name='main']", "iframe[id*='main' i]", "iframe[src*='OnlineLearning' i]", "iframe[src*='StudentSite' i]", ]; for (const s of sels) { try { const el = document.querySelector(s); if (el) return el; } catch (_) {} } let best = null; let area = 0; try { for (const fr of document.querySelectorAll("iframe")) { const r = fr.getBoundingClientRect(); const a = Math.max(0, r.width) * Math.max(0, r.height); if (a > area) { area = a; best = fr; } } } catch (_) {} return area >= 20000 ? best : null; } function loadUrlInFrame(fr, url, timeoutMs) { return new Promise((resolve) => { if (!fr) { resolve(""); return; } const wait = Math.max(4000, Number(timeoutMs) || 12000); let done = false; const finish = () => { if (done) return; done = true; try { clearTimeout(timer); } catch (_) {} try { fr.removeEventListener("load", onLoad); } catch (_) {} try { const doc = fr.contentDocument || (fr.contentWindow && fr.contentWindow.document); resolve(doc && doc.documentElement ? doc.documentElement.outerHTML : ""); } catch (_) { resolve(""); } }; const onLoad = () => setTimeout(finish, 350); const timer = setTimeout(finish, wait); try { fr.addEventListener("load", onLoad); fr.src = url; } catch (_) { finish(); } }); } function snippetHtml(html, n) { return String(html || "") .replace(/\s+/g, " ") .trim() .slice(0, n || 160); } async function gmRequestFollow(opt) { let url = String(opt.url || ""); let res = await gmRequest({ ...opt, url }); for (let hop = 0; hop < 6; hop++) { const status = Number(res.status) || 0; const text = String(res.responseText || res.response || ""); const headers = res.responseHeaders || res.headers || ""; let loc = headerLocation(headers); if (!loc) { const m = text.match(/Object moved to <a href=["']([^"']+)["']/i); if (m) loc = m[1].replace(/&/g, "&"); } const needFollow = !!loc && (status === 301 || status === 302 || status === 303 || status === 307 || status === 308 || /Object moved/i.test(text)); if (!needFollow) break; url = new URL(loc, url).href; dbg(`\u8ddf\u968f\u8df3\u8f6c → ${url.slice(0, 160)}`); res = await gmRequest({ ...opt, method: "GET", url, data: undefined, }); } return res; } function syncPortalFromLocation() { if (!isPortal) return; try { const portal = String(location.origin || "").replace(/\/$/, ""); if (!portal) return; PORTAL = portal; const school = schoolAbbFromHost(location.hostname); if (school) SCHOOL = school; SCHOOL_DISPLAY = resolveSchoolDisplay(SCHOOL); gmSet(PORTAL_CACHE_KEY, { portal: PORTAL, school: SCHOOL, host: String(location.hostname || ""), }); } catch (_) {} } async function xhrGetText(url) { try { if (!WIN.XMLHttpRequest) return ""; return await new Promise((resolve) => { try { const xhr = new WIN.XMLHttpRequest(); xhr.open("GET", url, true); xhr.withCredentials = true; xhr.setRequestHeader("Accept", "text/html,application/xhtml+xml,*/*"); xhr.onreadystatechange = () => { if (xhr.readyState === 4) resolve(String(xhr.responseText || "")); }; xhr.onerror = () => resolve(""); xhr.send(); } catch (_) { resolve(""); } }); } catch (_) { return ""; } } async function xhrPostFormViaPage(url, formBody, headers) { const portalOrigin = (() => { try { return new URL(PORTAL).origin; } catch (_) { return ""; } })(); if (portalOrigin && String(location.origin) !== portalOrigin) { return { text: "", status: 0, j: null }; } const body = typeof formBody === "string" ? formBody : String(formBody || ""); try { if (!WIN.XMLHttpRequest) return { text: "", status: 0, j: null }; return await new Promise((resolve) => { try { const xhr = new WIN.XMLHttpRequest(); xhr.open("POST", url, true); xhr.withCredentials = true; xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); xhr.setRequestHeader("Accept", "application/json, text/javascript, */*; q=0.01"); xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); const h = headers || {}; for (const k of Object.keys(h)) { try { if (/^(content-type|referer|origin)$/i.test(k)) continue; xhr.setRequestHeader(k, h[k]); } catch (_) {} } xhr.onreadystatechange = () => { if (xhr.readyState !== 4) return; const text = String(xhr.responseText || ""); let j = null; try { j = text ? JSON.parse(text) : null; } catch (_) {} resolve({ text, status: Number(xhr.status) || 0, j }); }; xhr.onerror = () => resolve({ text: "", status: 0, j: null }); xhr.send(body); } catch (_) { resolve({ text: "", status: 0, j: null }); } }); } catch (_) { return { text: "", status: 0, j: null }; } } function iframeXhr(win, method, url, body, headers) { return new Promise((resolve) => { try { const XHR = win.XMLHttpRequest || WIN.XMLHttpRequest; if (!XHR) return resolve({ text: "", status: 0, j: null }); const xhr = new XHR(); xhr.open(method || "GET", url, true); xhr.withCredentials = true; if (method === "POST") { xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); xhr.setRequestHeader("Accept", "application/json, text/javascript, */*; q=0.01"); xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); } else { xhr.setRequestHeader("Accept", "text/html,application/xhtml+xml,*/*"); } const h = headers || {}; for (const k of Object.keys(h)) { try { if (/^(content-type|referer|origin)$/i.test(k)) continue; xhr.setRequestHeader(k, h[k]); } catch (_) {} } xhr.onreadystatechange = () => { if (xhr.readyState !== 4) return; const text = String(xhr.responseText || ""); let j = null; try { j = text ? JSON.parse(text) : null; } catch (_) {} resolve({ text, status: Number(xhr.status) || 0, j, finalUrl: String(xhr.responseURL || url) }); }; xhr.onerror = () => resolve({ text: "", status: 0, j: null }); xhr.send(body || null); } catch (_) { resolve({ text: "", status: 0, j: null }); } }); } function readIframeHtml(iframe) { try { const d = iframe.contentDocument; return d && d.documentElement ? d.documentElement.outerHTML : ""; } catch (_) { return ""; } } function withAllTermListFrame(runner, timeoutMs) { const listUrl = `${PORTAL}${COURSE_LIST_PATH}`; const wait = Math.max(12000, Number(timeoutMs) || 28000); return new Promise((resolve) => { let done = false; const iframe = document.createElement("iframe"); iframe.setAttribute("aria-hidden", "true"); iframe.setAttribute("sandbox", "allow-same-origin"); iframe.style.cssText = "position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none;border:0"; const finish = (val) => { if (done) return; done = true; try { clearTimeout(timer); } catch (_) {} try { iframe.onload = null; iframe.remove(); } catch (_) {} resolve(val); }; const timer = setTimeout(() => { dbg("allTermFrame \u8d85\u65f6"); finish(null); }, wait); const liveXhr = (method, url, body) => { const win = iframe.contentWindow; if (!win) return Promise.resolve({ text: "", status: 0, j: null }); return iframeXhr(win, method, url, body); }; const navigate = (nextUrl, settleMs) => new Promise((res) => { let settled = false; const doneNav = () => { if (settled) return; settled = true; const delay = Math.max(400, Number(settleMs) || 600); setTimeout(() => res(readIframeHtml(iframe)), delay); }; iframe.onload = doneNav; try { iframe.src = nextUrl; } catch (_) { res(""); return; } setTimeout(doneNav, 14000); }); let started = false; iframe.onload = () => { if (started) return; started = true; setTimeout(async () => { try { const html0 = readIframeHtml(iframe); if (!html0 || looksLikePortalSysError(html0) || looksLikePortalLoginHtml(html0)) { dbg(`\u5168\u90e8\u5b66\u671f\u5217\u8868\u672a\u5c31\u7eea snip=${snippetHtml(html0, 100)}`); return finish(null); } const out = await runner({ iframe, listUrl, getWin: () => iframe.contentWindow, getDoc: () => iframe.contentDocument, getHtml: () => readIframeHtml(iframe), navigate, xhr: liveXhr, }); finish(out); } catch (e) { dbg(`allTermFrame\uff1a${e.message || e}`); finish(null); } }, 350); }; try { document.documentElement.appendChild(iframe); iframe.src = listUrl; } catch (_) { finish(null); } }); } async function followObjectMovedHtml(html, baseUrl, getter) { let cur = String(html || ""); let url = String(baseUrl || ""); for (let i = 0; i < 6; i++) { if (!/Object moved/i.test(cur)) break; const m = cur.match(/Object moved to <a href=["']([^"']+)["']/i); if (!m) break; let next = ""; try { next = new URL(String(m[1] || "").replace(/&/g, "&"), url || PORTAL).href; } catch (_) { break; } dbg(`\u9875\u9762 Object moved → ${next.slice(0, 160)}`); if (/\/(Account|Home)\/Login|ReturnUrl=/i.test(next) && /Login/i.test(next)) { cur = (await getter(next)) || cur; url = next; break; } const nxtHtml = await getter(next); if (!nxtHtml) break; cur = nxtHtml; url = next; } return cur; } async function fetchPortalHtmlViaPage(url) { const portalOrigin = (() => { try { return new URL(PORTAL).origin; } catch (_) { return ""; } })(); if (portalOrigin && String(location.origin) !== portalOrigin) return ""; let text = await xhrGetText(url); if (text) text = await followObjectMovedHtml(text, url, xhrGetText); if (text) return text; try { if (typeof WIN.fetch !== "function") return ""; const r = await WIN.fetch(url, { method: "GET", credentials: "include", redirect: "follow", headers: { Accept: "text/html,application/xhtml+xml,*/*" }, }); text = (await r.text()) || ""; if (text) text = await followObjectMovedHtml(text, r.url || url, xhrGetText); return text; } catch (_) { return ""; } } async function fetchPortalHtml(path, opts) { const forceNet = !!(opts && opts.forceNet); const url = path.startsWith("http") ? path : PORTAL + path; if ( !forceNet && isPortal && location.pathname.includes("OnlineLearningNewIndex") && !/isCurrent=0/i.test(path) ) { return document.documentElement.outerHTML; } let text = ""; let status = 0; let finalUrl = url; try { const viaPage = await fetchPortalHtmlViaPage(url); if (viaPage) text = viaPage; } catch (_) {} if (!text || looksLikePortalLoginHtml(text)) { try { const res = await gmRequestFollow({ method: "GET", url, headers: { Accept: "text/html,application/xhtml+xml,*/*", "Accept-Encoding": "identity", Referer: String(location.href || PORTAL + "/"), }, withCredentials: true, }); status = Number(res.status) || 0; finalUrl = String(res.finalUrl || url); let gmText = String(res.responseText || res.response || "") || ""; gmText = await followObjectMovedHtml(gmText, finalUrl, async (u) => { try { const r2 = await gmRequestFollow({ method: "GET", url: u, headers: { Accept: "text/html,application/xhtml+xml,*/*", "Accept-Encoding": "identity", Referer: String(location.href || PORTAL + "/"), }, withCredentials: true, }); return String(r2.responseText || r2.response || "") || ""; } catch (_) { return ""; } }); if (gmText.length > text.length) text = gmText; } catch (e) { dbg(`GM \u62c9\u9875\u5931\u8d25\uff1a${e.message || e}`); } } if (!text) throw new Error(`\u62c9\u8bfe\u7a0b\u9875\u5931\u8d25 HTTP ${status || "net"}`); if (looksLikePortalLoginHtml(text)) { dbg(`\u62c9\u9875\u5f02\u5e38 status=${status} final=${finalUrl.slice(0, 120)} snip=${snippetHtml(text, 180)}`); } return text; } function ensurePortalHostForExam() { syncPortalFromLocation(); try { const ph = new URL(PORTAL).hostname.toLowerCase(); const lh = String(location.hostname || "").toLowerCase(); if (ph && lh && ph !== lh) { log(`\u8bf7\u5728\u5b66\u4e60\u95e8\u6237 ${ph} \u6253\u5f00\u540e\u518d\u70b9\u4f5c\u4e1a/\u8003\u8bd5\uff08\u5f53\u524d\u9875\u9762\u662f ${lh}\uff09`, "err"); try { if (typeof GM_openInTab === "function") { GM_openInTab(PORTAL + COURSE_LIST_PATH, { active: true }); } else { window.open(PORTAL + COURSE_LIST_PATH, "_blank"); } } catch (_) {} return false; } } catch (_) {} return true; } async function refreshCourses() { setStatus("\u6b63\u5728\u52a0\u8f7d\u8bfe\u7a0b…"); log("\u6b63\u5728\u52a0\u8f7d\u8bfe\u7a0b\u5217\u8868"); const html = await fetchPortalHtml(COURSE_LIST_PATH, { forceNet: true }); const parsed = parseCoursesFromHtml(html); const all = parsed.courses || []; state.allCourses = all; state.currentTermIndex = parsed.currentTermIndex; state.termTabs = buildTermTabs(all, state.currentTermIndex); if (!state.termFilter) state.termFilter = "current"; const list = applyTermFilter(); const curLabel = (all.find((c) => c.termIndex === state.currentTermIndex) || {}).termLabel || "\u5f53\u524d\u5b66\u671f"; log(`\u5df2\u8f7d\u5165 ${all.length} \u95e8\u8bfe\uff0c\u5f53\u524d\u663e\u793a\uff1a${state.termFilter === "current" ? curLabel : "\u6240\u9009\u5b66\u671f"}\uff08${list.length} \u95e8\uff09`); renderTermTabs(); renderCourses(); for (const c of list) { try { await fetchCourseStudyQuota(c); log(`\u300c${c.title}\u300d${courseScoreLine(c)}`); renderCourses(); } catch (_) {} await sleep(200); } setStatus(list.length ? `\u5df2\u52a0\u8f7d ${list.length} \u95e8\u8bfe` : "\u672a\u8bc6\u522b\u5230\u8bfe\u7a0b\uff0c\u8bf7\u5148\u767b\u5f55"); renderCourses(); return list; } async function recheckCourseOnList(course) { try { const html = await fetchPortalHtml(COURSE_LIST_PATH, { forceNet: true }); const parsed = parseCoursesFromHtml(html); const list = parsed.courses || []; const hit = list.find((c) => c.termCourseID === course.termCourseID); if (hit) { if (hit.portalScore != null) course.portalScore = hit.portalScore; if (hit.title && !looksLikeCourseCode(hit.title)) course.title = hit.title; } const q = await fetchCourseStudyQuota(course); log(`\u300c${course.title}\u300d${courseScoreLine(course)}`); return { listHit: hit, quota: q }; } catch (e) { dbg(`\u56de\u67e5\u5931\u8d25\uff1a${e.message || e}`); log(`\u300c${course.title}\u300d\u6210\u7ee9\u5237\u65b0\u5931\u8d25`); return null; } } async function compareTime(termCourseID) { try { const body = new URLSearchParams({ termcourseID: String(termCourseID), type: "0" }); const { j } = await gmJson({ method: "POST", url: `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/CompareTime`, headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", }, data: body.toString(), }); return !j || j.state !== false; } catch (_) { return true; } } async function getCourseLearnUrl(course) { const tid = String(course.termCourseID || ""); const code = String(course.courseCode || ""); const compareUrl = `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/CompareTime`; const courseUrl = `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/GetCourseUrl`; const courseExUrl = `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/MyOnlineCourseExIndex?termCourseID=${encodeURIComponent( tid )}`; const compareBody = new URLSearchParams({ termcourseID: tid, type: "0" }).toString(); const courseBody = new URLSearchParams({ termcourseID: tid, courseCode: code }).toString(); let lastErr = ""; try { const framed = await withAllTermListFrame(async ({ xhr, navigate }) => { const ct = await xhr("POST", compareUrl, compareBody); if (ct.j && ct.j.state === false) throw new Error(String(ct.j.msg || "\u4e0d\u5728\u5b66\u4e60\u65f6\u95f4\u7a97\u53e3\u5185")); const html = await navigate(courseExUrl, 700); if (looksLikePortalSysError(html) || looksLikePortalLoginHtml(html)) { dbg(`\u8bfe\u4ef6\u9875\u5f02\u5e38 snip=${snippetHtml(html, 100)}`); return null; } return xhr("POST", courseUrl, courseBody); }, 32000); if (framed && framed.j && framed.j.state && framed.j.msg) { dbg(`GetCourseUrl \u9759\u9ed8\u6210\u529f\u300c${course.title}\u300d`); return String(framed.j.msg).trim(); } lastErr = (framed && framed.j && framed.j.msg) || `HTTP ${(framed && framed.status) || "?"} ${String((framed && framed.text) || "").slice(0, 80)}`; } catch (e) { if (/\u4e0d\u5728\u5b66\u4e60/.test(String(e.message || e))) throw e; lastErr = e.message || String(e); } throw new Error(`GetCourseUrl \u5931\u8d25 ${lastErr}`); } function adoptBackUrl(backUrl) { try { const u = new URL(backUrl); PORTAL = u.origin; const en = u.searchParams.get("ename"); if (en) SCHOOL = en; else SCHOOL = schoolAbbFromHost(u.hostname); gmSet(PORTAL_CACHE_KEY, { portal: PORTAL, school: SCHOOL, host: u.hostname }); refreshSchoolDisplayUI(); } catch (_) {} } function sessionFromLearnUrl(learnUrl, course) { const q = parseQuery(learnUrl); let backUrl = backUrlOf(); if (q.remark) { try { const rem = decodeURIComponent(q.remark); if (/^https?:\/\//i.test(rem)) backUrl = rem.split("#")[0]; } catch (_) {} } if (q.backUrl && /^https?:\/\//i.test(q.backUrl)) backUrl = q.backUrl; adoptBackUrl(backUrl); return { learnUrl, clientCode: String(q.clientCode || ""), userId: String(q.userId || ""), userName: String(q.userName || SCHOOL), coursewareCode: String(q.coursewareCode || course.courseCode || ""), businessLineCode: String(q.businessLineCode || ""), coursewareName: course.title || "", classNum: "", backUrl, termCourseID: course.termCourseID, courseCode: course.courseCode, }; } async function warmLearnSession(session) { try { await gmRequest({ method: "GET", url: session.learnUrl, timeout: 45000 }); } catch (_) {} try { await gmRequest({ method: "GET", url: `${CWS}/page/client`, timeout: 20000 }); } catch (_) {} } function catalogIdOf(node) { if (!node || typeof node !== "object") return ""; const id = node.id ?? node.catalogId ?? node.catalogID ?? node.chapterId ?? node.resourceId ?? node.nodeId; return id == null || id === "" ? "" : String(id); } function catalogVideoTimeOf(node) { const n = Number(node.mediaDuration) || Number(node.videoTime) || Number(node.duration) || Number(node.media && node.media.duration) || 0; return Math.max(0, Math.floor(n)); } function catalogSofarOf(node) { const lr = node.learnRecord && typeof node.learnRecord === "object" ? node.learnRecord : null; const v = (lr && (lr.accumulativeTimesofar ?? lr.accumulativeTime)) ?? node.accumulativeTimesofar ?? node.accumulativeTime ?? node.sofar ?? 0; return Math.max(0, parseInt(v, 10) || 0); } function catalogLastTimeOf(node) { const lr = node.learnRecord && typeof node.learnRecord === "object" ? node.learnRecord : null; const v = (lr && lr.lastTime) ?? node.lastTime ?? 0; return Math.max(0, Number(v) || 0); } function catalogTitleOf(node) { const lr = node.learnRecord && typeof node.learnRecord === "object" ? node.learnRecord : null; return String( node.title || node.catalogTitle || node.name || node.chapterName || (lr && lr.catalogTitle) || "" ).trim(); } function applyChapterDone(ch) { const vt = ch.videoTime || 0; if (vt <= 0) { ch.done = false; return; } const sofar = ch.sofar || 0; ch.done = sofar >= vt - 2; } function chapterPct(ch) { const vt = ch.videoTime || 0; if (vt <= 0) return ch.done ? 100 : 0; return Math.min(100, Math.round(((ch.sofar || 0) / vt) * 100)); } function parseCatalogFlat(data) { if (!Array.isArray(data) || !data.length) return []; const out = []; const push = (node, requireMedia) => { if (!node || typeof node !== "object") return; const id = catalogIdOf(node); if (!id) return; const videoTime = catalogVideoTimeOf(node); const isMedia = node.isMedia === 1 || node.isMedia === "1" || node.isMedia === true; if (requireMedia && !isMedia && videoTime <= 0) return; const ch = { catalogId: id, catalogTitle: catalogTitleOf(node) || `\u7ae0\u8282${out.length + 1}`, videoTime, sofar: catalogSofarOf(node), lastTime: catalogLastTimeOf(node), done: false, questions: Array.isArray(node.questions) ? node.questions : [], _raw: node, }; applyChapterDone(ch); out.push(ch); }; data.forEach((n) => push(n, true)); if (!out.length) data.forEach((n) => push(n, false)); return out; } function pickNeedMinFromObj(obj) { if (!obj || typeof obj !== "object") return null; const keys = [ "needStudyTime", "needStudyMinute", "studyNeed", "studyNeedTime", "requiredStudyTime", "requiredTime", "needTime", "totalStudyTime", "standardStudyTime", "kjNeedTime", "needMinute", "needMinutes", ]; for (const k of keys) { if (obj[k] != null && obj[k] !== "") { const n = parseInt(obj[k], 10); if (Number.isFinite(n) && n > 0) { return n > 10000 ? Math.ceil(n / 60) : n; } } } return null; } async function fetchCatalogs(session, course) { const { j, status, text } = await gmJson({ method: "POST", url: `${CWS}/client/catalogs`, headers: { "Content-Type": "application/json;charset=UTF-8", Accept: "application/json, text/javascript, */*; q=0.01", "X-Requested-With": "XMLHttpRequest", }, data: JSON.stringify({ coursewareCode: session.coursewareCode, clientCode: session.clientCode, userId: session.userId, }), }); if (status < 200 || status >= 300) throw new Error(`catalogs HTTP ${status}`); const data = j && j.data != null ? j.data : j; let chapters = parseCatalogFlat(Array.isArray(data) ? data : []); try { const cw = await gmJson({ method: "GET", url: `${CWS}/client/${encodeURIComponent(session.coursewareCode)}/findCwByCode?userId=${encodeURIComponent( session.userId )}&clientCode=${encodeURIComponent(session.clientCode)}`, }); const d = cw.j && cw.j.data != null ? cw.j.data : cw.j; if (!chapters.length) { const arr = Array.isArray(d) ? d : d && Array.isArray(d.catalogs) ? d.catalogs : []; chapters = parseCatalogFlat(arr); } if (d && d.coursewareName) session.coursewareName = d.coursewareName; if (course) { const need = pickNeedMinFromObj(d) || pickNeedMinFromObj(cw.j); if (need && !(course.needMin > 0)) course.needMin = need; const learned = pickNeedMinFromObj( d && typeof d === "object" ? { needStudyTime: d.studiedTime || d.studyTime || d.learnedTime || d.accumulativeTime, } : null ); if (d && typeof d === "object") { for (const k of ["studiedTime", "studiedMinute", "learnedTime", "learnTime", "studyTimeSofar"]) { if (d[k] != null) { const n = parseInt(d[k], 10); if (Number.isFinite(n) && n >= 0) { const min = n > 10000 ? Math.floor(n / 60) : n; course.learnedMin = Math.max(Number(course.learnedMin) || 0, min); break; } } } } } } catch (e) { dbg(`findCwByCode \u5931\u8d25\uff1a${e.message || e}`); } if (!chapters.length) throw new Error(`\u672a\u62c9\u5230\u7ae0\u8282 ${(text || "").slice(0, 120)}`); return chapters; } async function fetchCatalogInfo(session, catalogId) { const { j } = await gmJson({ method: "POST", url: `${CWS}/client/catalogInfo/${encodeURIComponent(catalogId)}`, headers: { "Content-Type": "application/json;charset=UTF-8", Accept: "application/json, text/javascript, */*; q=0.01", }, data: JSON.stringify({ clientCode: session.clientCode, userId: session.userId, isQuestion: true, coursewareCode: session.coursewareCode, }), }); const data = j && j.data != null ? j.data : j; if (!data || typeof data !== "object") return null; return { videoTime: catalogVideoTimeOf(data), sofar: catalogSofarOf(data), lastTime: catalogLastTimeOf(data), questions: Array.isArray(data.questions) ? data.questions : [], title: catalogTitleOf(data), _raw: data, }; } function buildRecord(session, chapter, accumulativeTime, lastTime, learnRecordId) { const rec = { learnRecordId: learnRecordId != null ? learnRecordId : 0, userId: session.userId, userName: session.userName, clientCode: session.clientCode, businessLineCode: session.businessLineCode || "", coursewareCode: session.coursewareCode, catalogId: String(chapter.catalogId), catalogTitle: chapter.catalogTitle || "", classNum: session.classNum || "", coursewareName: session.coursewareName || "", videoTime: chapter.videoTime || 0, backUrl: session.backUrl || backUrlOf(), validTime: 0, accumulativeTime, lastTime, deviceType: "TYPE_PC", orderNo: "", token: "", maxTimePerDay: 0, }; const sig = signLearnRecord(rec); rec.md5 = sig.md5; rec.md5Timestamp = sig.md5Timestamp; return rec; } function timingOk(j) { if (!j) return false; if (j.code === 0 || j.success === true || j.success === "true") return true; if (j.data && (j.data.lastTime != null || j.data.accumulativeTimesofar != null || j.data.learnRecordId != null)) { return true; } return false; } function absorbLearnData(chapter, data, preferSofar) { if (!data || typeof data !== "object") return { sofar: chapter.sofar || 0, lastTime: chapter.lastTime || 0 }; if (data.learnRecordId != null) chapter._learnRecordId = data.learnRecordId; if (data.lastTime != null) { const srv = Number(data.lastTime) || 0; chapter.lastTime = Math.max(chapter.lastTime || 0, srv); } if (data.accumulativeTimesofar != null) { const sf = parseInt(data.accumulativeTimesofar, 10) || 0; chapter.sofar = preferSofar ? sf : Math.max(chapter.sofar || 0, sf); } else if (data.accumulativeTime != null && preferSofar) { } if (data.videoTime != null) { const vt = parseInt(data.videoTime, 10) || 0; if (vt > 0) chapter.videoTime = vt; } state.sofar = chapter.sofar || 0; state.lastTime = chapter.lastTime || 0; state.videoTime = chapter.videoTime || 0; applyChapterDone(chapter); return { sofar: chapter.sofar || 0, lastTime: chapter.lastTime || 0, learnRecordId: chapter._learnRecordId }; } async function putLearnCws(kind, session, chapter, accumulativeTime, lastTime) { const learnRecordId = chapter._learnRecordId || 0; const rec = buildRecord(session, chapter, accumulativeTime, lastTime, learnRecordId); const path = kind === "start" ? "/client/learnRecords/start" : "/client/learnRecords/timing"; const signed = await gmJson({ method: "PUT", url: CWS + path, headers: { "Content-Type": "application/json;charset=UTF-8", Accept: "application/json, text/javascript, */*; q=0.01", "X-Requested-With": "XMLHttpRequest", Referer: `${CWS}/page/client`, Origin: CWS, }, data: JSON.stringify(rec), }); if (timingOk(signed.j)) { return { j: signed.j, text: signed.text, status: signed.status, style: "signed" }; } if (kind === "start") { const createBody = { learnRecordId: 0, userId: session.userId, userName: session.userName, clientCode: session.clientCode, businessLineCode: session.businessLineCode || "", coursewareCode: session.coursewareCode, coursewareName: session.coursewareName || "", catalogId: String(chapter.catalogId), catalogTitle: chapter.catalogTitle || "", videoTime: null, backUrl: session.backUrl || backUrlOf(), validTime: 0, accumulativeTime: 0, token: "", }; const created = await gmJson({ method: "POST", url: `${CWS}/client/learnRecords`, headers: { "Content-Type": "application/json;charset=UTF-8", Accept: "application/json, text/javascript, */*; q=0.01", Referer: `${CWS}/page/client`, Origin: CWS, }, data: JSON.stringify(createBody), }); if (timingOk(created.j) || (created.j && created.j.data && created.j.data.learnRecordId)) { return { j: created.j, text: created.text, status: created.status, style: "post" }; } return { j: signed.j || created.j, text: signed.text || created.text, status: signed.status || created.status, style: "fail" }; } const id = chapter._learnRecordId; if (id) { const putBody = { learnRecordId: String(id), userId: session.userId, userName: session.userName, clientCode: session.clientCode, businessLineCode: session.businessLineCode || "", coursewareCode: session.coursewareCode, coursewareName: session.coursewareName || "", catalogId: String(chapter.catalogId), catalogTitle: chapter.catalogTitle || "", videoTime: chapter.videoTime || 0, backUrl: session.backUrl || backUrlOf(), validTime: 0, accumulativeTime, token: "", lastTime, classNum: session.classNum || "", maxTimePerDay: 0, }; const updated = await gmJson({ method: "PUT", url: `${CWS}/client/learnRecords`, headers: { "Content-Type": "application/json;charset=UTF-8", Accept: "application/json, text/javascript, */*; q=0.01", Referer: `${CWS}/page/client`, Origin: CWS, }, data: JSON.stringify(putBody), }); if (timingOk(updated.j)) { return { j: updated.j, text: updated.text, status: updated.status, style: "put" }; } return { j: updated.j || signed.j, text: updated.text || signed.text, status: updated.status || signed.status, style: "fail" }; } return { j: signed.j, text: signed.text, status: signed.status, style: "fail" }; } async function postBackUrl(data) { const url = (data && data.backUrl) || (state.session && state.session.backUrl) || backUrlOf(); const payload = Object.assign({}, data || {}); if (!payload.backUrl) payload.backUrl = url; try { const { status, text, j } = await gmJson({ method: "POST", url, headers: { "Content-Type": "application/json; charset=utf-8", Accept: "application/json, text/javascript, */*; q=0.01", }, data: JSON.stringify(payload), }); const okHttp = status >= 200 && status < 300; const okBody = j == null || j.success === true || j.success === "true" || j.state === true || /\u540c\u6b65\u6210\u529f|success/i.test(String((j && (j.message || j.msg)) || text || "")); if (!(okHttp && okBody)) { dbg(`\u95e8\u6237\u56de\u5199\u5931\u8d25 HTTP ${status} ${(text || "").slice(0, 80)}`); } return okHttp && okBody; } catch (e) { dbg(`LearnReturnUrl \u5931\u8d25\uff1a${e.message || e}`); return false; } } async function submitKnowledgeAnswers(session, chapter, playSec, answers) { if (!answers || !answers.length) return 0; let okN = 0; const lastTime = Math.max(1, parseInt(playSec, 10) || 1); for (const q of answers) { try { const rec = buildRecord(session, chapter, 0, lastTime, 0); rec._method = "PUT"; const qres = await gmJson({ method: "POST", url: `${CWS}/client/learnRecord/question`, headers: { "Content-Type": "application/json; charset=UTF-8", Accept: "application/json, text/javascript, */*; q=0.01", Referer: `${CWS}/page/client`, Origin: CWS, }, data: JSON.stringify(rec), }); const base = qres.j && qres.j.data && typeof qres.j.data === "object" ? qres.j.data : null; if (!base) continue; const payload = Object.assign({}, base, { examinePoint: q.examinePoint || "", pass: true, questionId: q.questionId, questionType: q.questionType, respond: q.respond != null && q.respond !== "" ? String(q.respond) : "", }); if (q.optionId) payload.optionId = q.optionId; if (await postBackUrl(payload)) okN += 1; } catch (_) {} await sleep(200); } return okN; } async function runChapter(session, chapter) { const gap = getGapMs(); const step = getStepSec(); const info = await fetchCatalogInfo(session, chapter.catalogId); if (info) { if (info.videoTime > 0) chapter.videoTime = info.videoTime; if (info.sofar != null) chapter.sofar = info.sofar; if (info.lastTime != null) chapter.lastTime = info.lastTime; if (info.title) chapter.catalogTitle = info.title; if (info.questions && info.questions.length) chapter.questions = info.questions; if (info._raw && info._raw.learnRecord && info._raw.learnRecord.learnRecordId != null) { chapter._learnRecordId = info._raw.learnRecord.learnRecordId; } applyChapterDone(chapter); } const vt = chapter.videoTime || 0; state.catalogTitle = chapter.catalogTitle; state.videoTime = vt; state.lastTime = chapter.lastTime || 0; state.sofar = chapter.sofar || 0; updatePanel(); renderChapters(); if (chapter.done) { log(`\u300c${chapter.catalogTitle}\u300d\u5df2\u5b66\u5b8c\uff0c\u8df3\u8fc7`); return true; } if (vt <= 0) { if (chapter.questions && chapter.questions.length) { await submitKnowledgeAnswers(session, chapter, 1, chapter.questions); } log(`\u300c${chapter.catalogTitle}\u300d\u65e0\u89c6\u9891\uff0c\u8df3\u8fc7`); return true; } try { await cloudConsumeChapter(chapter, state._activeCourse || null); } catch (e) { log(friendlyCloudMessage(e), "err"); throw e; } let lastTime = Math.min(Math.max(chapter.lastTime || 0, chapter.sofar || 0), vt); let sofar = chapter.sofar || 0; const answered = new Set(); const qs = Array.isArray(chapter.questions) ? chapter.questions.slice() : []; const flushQs = async (playSec) => { const due = qs.filter( (q) => q && q.questionId != null && !answered.has(String(q.questionId)) && playSec >= (q.mediaTime || 0) ); if (!due.length) return; await submitKnowledgeAnswers(session, chapter, playSec, due); due.forEach((q) => answered.add(String(q.questionId))); }; log( `\u5f00\u59cb\u5b66\u4e60\uff1a${chapter.catalogTitle}\uff08\u7d2f\u8ba1 ${formatDuration(sofar)} / \u4f4d\u7f6e ${formatDuration(lastTime)} / \u7247\u957f ${formatDuration( vt )}\uff09` ); setStatus(`\u5b66\u4e60\u4e2d · ${chapter.catalogTitle}`); try { const s = await putLearnCws("start", session, chapter, 0, Math.max(1, lastTime || 1)); if (s.j && s.j.data) { const abs = absorbLearnData(chapter, s.j.data, true); lastTime = Math.max(lastTime, abs.lastTime || 0); sofar = abs.sofar || sofar; const backOk = await postBackUrl(s.j.data); dbg(`start ${s.style || ""} · sofar=${formatDuration(sofar)} · back=${backOk}`); } else if (!timingOk(s.j)) { dbg(`start \u672a\u6210\u529f\uff1a${(s.text || "").slice(0, 120) || s.status}`); log(`\u300c${chapter.catalogTitle}\u300d\u5f00\u59cb\u5b66\u4e60\u5931\u8d25`); } } catch (e) { dbg(`start \u5f02\u5e38\uff1a${e.message || e}`); log(`\u300c${chapter.catalogTitle}\u300d\u5f00\u59cb\u5b66\u4e60\u5931\u8d25`); } state.stall = 0; state.tick = 0; while (state.running && !state.stopFlag && sofar < vt - 1) { const nextLast = Math.min(vt, Math.max(lastTime, sofar) + step); const accum = Math.min(step, Math.max(1, vt - sofar)); const prevSofar = sofar; try { const t = await putLearnCws("timing", session, chapter, accum, nextLast); state.tick += 1; if (!timingOk(t.j) || !t.j || !t.j.data) { state.stall += 1; dbg(`\u4e0a\u62a5\u5931\u8d25\uff1a${(t.text || "").slice(0, 120) || t.status}`); if (String(t.j && t.j.code) === "-1") { log("\u4eca\u65e5\u5b66\u65f6\u5df2\u8fbe\u4e0a\u9650\uff0c\u5df2\u505c\u6b62"); return false; } if (state.stall >= 8) { log(`\u300c${chapter.catalogTitle}\u300d\u4e0a\u62a5\u591a\u6b21\u5931\u8d25\uff0c\u5df2\u6682\u505c`); return false; } } else { const abs = absorbLearnData(chapter, t.j.data, true); sofar = abs.sofar; lastTime = Math.max(nextLast, abs.lastTime || 0); chapter.lastTime = lastTime; const backOk = await postBackUrl(t.j.data); await flushQs(lastTime); const gained = sofar - prevSofar; if (gained <= 0) { state.stall += 1; dbg(`#${state.tick} sofar\u672a\u6da8 ${formatDuration(sofar)} +${accum}s back=${backOk}`); if (state.stall >= 8) { log("\u5b66\u4e60\u8fdb\u5ea6\u672a\u66f4\u65b0\uff0c\u8bf7\u786e\u8ba4\u5df2\u767b\u5f55\u540e\u91cd\u8bd5"); return false; } } else { state.stall = 0; if (state.tick === 1 || state.tick % 5 === 0 || sofar >= vt - 1) { log(`\u300c${chapter.catalogTitle}\u300d\u8fdb\u5ea6 ${chapterPct(chapter)}%`); } else { dbg(`#${state.tick} ${chapter.catalogTitle} ${formatDuration(sofar)}/${formatDuration(vt)} +${gained}s`); } } } } catch (e) { state.stall += 1; dbg(`\u4e0a\u62a5\u5f02\u5e38\uff1a${e.message || e}`); log(`\u300c${chapter.catalogTitle}\u300d\u5b66\u4e60\u4e0a\u62a5\u5931\u8d25`); if (state.stall >= 8) return false; } updatePanel(); renderChapters(); await sleep(gap); } if (state.running && !state.stopFlag && sofar >= vt - 1) { try { const seal = await putLearnCws("timing", session, chapter, 0, vt); if (seal.j && seal.j.data) { absorbLearnData(chapter, seal.j.data, true); await postBackUrl(seal.j.data); } } catch (_) {} if (qs.length) await submitKnowledgeAnswers(session, chapter, vt, qs); } try { const again = await fetchCatalogInfo(session, chapter.catalogId); if (again) { if (again.sofar != null) chapter.sofar = again.sofar; if (again.lastTime != null) chapter.lastTime = again.lastTime; } } catch (_) {} applyChapterDone(chapter); if (chapter.done) { log(`\u300c${chapter.catalogTitle}\u300d\u5df2\u5b8c\u6210`); return true; } log( `\u672a\u8fbe\u7d2f\u8ba1\u6ee1\u5206\uff1a${chapter.catalogTitle} \u5f53\u524d ${formatDuration(chapter.sofar || 0)}/${formatDuration(vt)}\uff08${chapterPct( chapter )}%\uff09` ); return false; } const EM_CJ = "https://cjexamnew.edu-edu.com"; const EXAM_HOST_DEFAULT = "https://cje6.edu-edu.com"; const KS_TYPE_HOMEWORK = "2"; const KS_TYPE_FINAL = "3"; function examAnswerSign(userExamId, questionId, psqId, answer, stime) { const raw = `answer=${answer}&psqId=${psqId}&stime=${stime}&key=${questionId}${userExamId}`; return md5(raw); } function examBase(sess) { const b = (sess && sess.examBase) || ""; if (/^https?:\/\/cje\d+\.edu-edu\.com$/i.test(b)) return b.replace(/\/$/, ""); if (/^https?:\/\/[a-z0-9.-]+\.edu-edu\.com$/i.test(b) && /\/exam\//i.test(String(sess.paperUrl || sess.doexamHtml || ""))) { return b.replace(/\/$/, ""); } return EXAM_HOST_DEFAULT; } function rememberExamBase(sess, urlOrHtml) { if (!sess) return; const s = String(urlOrHtml || ""); const m = s.match(/https?:\/\/(cje\d+\.edu-edu\.com)/i); if (m) sess.examBase = `https://${m[1]}`; } function extractRestartUrl(htmlOrUrl, base) { const s0 = String(htmlOrUrl || "").replace(/&/g, "&").trim(); if (!s0) return ""; if (/\/single\/restart\//i.test(s0) && /^https?:\/\//i.test(s0.split(/\s/)[0])) { try { const u = new URL(s0.split(/\s/)[0]); if (u.searchParams.get("m") && u.searchParams.get("d")) return u.href; } catch (_) {} } const m = s0.match(/https?:\/\/cjexamnew\.edu-edu\.com\/exam-admin\/student\/exam\/single\/restart\/[^"'\\\s<>]+/i) || s0.match(/https?:\/\/em\.edu-edu\.com\.cn\/exam-admin\/student\/exam\/single\/restart\/[^"'\\\s<>]+/i) || s0.match(/\/exam-admin\/student\/exam\/single\/restart\/[^"'\\\s<>]+/i); if (!m) return ""; try { const u = new URL(m[0], base || EM_CJ); if (!u.searchParams.get("m") || !u.searchParams.get("d")) return ""; return u.href; } catch (_) { return ""; } } function pickRestartFromHops(hops) { for (const u of hops || []) { const got = extractRestartUrl(u, EM_CJ); if (got) return got; } return ""; } async function gmGetStay(url, maxRedirects) { let cur = url; const hops = []; let last = { url: cur, text: "", status: 0 }; for (let i = 0; i < (maxRedirects || 8); i++) { if (hops.includes(cur)) break; hops.push(cur); let res = null; try { res = await gmRequest({ method: "GET", url: cur, redirect: "manual", timeout: 45000, headers: { Accept: "text/html,application/xhtml+xml", "Upgrade-Insecure-Requests": "1" }, }); } catch (_) { res = null; } if (!res || !res.status || res.status === 0) { res = await gmRequest({ method: "GET", url: cur, timeout: 60000, headers: { Accept: "text/html,application/xhtml+xml", "Upgrade-Insecure-Requests": "1" }, }); } const finalUrl = res.finalUrl || cur; if (finalUrl && finalUrl !== cur && !hops.includes(finalUrl)) hops.push(finalUrl); last = { url: finalUrl, text: res.responseText || "", status: res.status, res }; const loc = headerLocation(res.responseHeaders); if (loc && res.status >= 300 && res.status < 400) { try { cur = new URL(loc, cur).href; } catch (_) { cur = loc; } continue; } break; } return { url: last.url, text: last.text, status: last.status, hops, res: last.res }; } function doexamUrlFromStart(startUrl) { try { const u = new URL(startUrl, EXAM_HOST_DEFAULT); const sm = u.pathname.match(/\/exam2\/start\/(\d+)\/(\d+)\/(\d+)\/([^/]+)\/(\d+)/); if (!sm) return ""; return `${u.origin}/exam/student/exam2/doexam/${sm[4]}/${sm[3]}/${sm[5]}`; } catch (_) { return ""; } } async function followRestartToDoexam(restartUrl) { const logHops = []; const getOnce = async (url, note) => { let res = null; let loc = ""; try { res = await gmRequest({ method: "GET", url, redirect: "manual", timeout: 45000, headers: { Accept: "text/html,application/xhtml+xml,*/*;q=0.8", "Upgrade-Insecure-Requests": "1", }, }); loc = headerLocation(res.responseHeaders); } catch (_) { res = null; } if (!res || !res.status || res.status === 0) { res = await gmRequest({ method: "GET", url, timeout: 60000, headers: { Accept: "text/html,application/xhtml+xml", "Upgrade-Insecure-Requests": "1" }, }); loc = headerLocation(res.responseHeaders); } const finalUrl = res.finalUrl || url; const text = res.responseText || ""; logHops.push(`${note}:${res.status}@${(finalUrl || url).slice(0, 90)}`); return { res, loc, finalUrl, text, status: res.status }; }; let cur = restartUrl; let r = await getOnce(cur, "restart"); let startUrl = ""; if (r.loc && /exam2\/start\//i.test(r.loc)) startUrl = new URL(r.loc, cur).href; else if (/exam2\/start\//i.test(r.finalUrl || "")) startUrl = r.finalUrl; else if (/ExamObj\s*\(/.test(r.text)) return { url: r.finalUrl, text: r.text, hops: logHops }; else { const m = (r.text || "").match(/https?:\/\/cje\d+\.edu-edu\.com\/exam\/student\/exam2\/start\/[^\s"'<>]+/i); if (m) startUrl = m[0].replace(/&/g, "&"); } if (!startUrl && r.loc) { try { startUrl = new URL(r.loc, cur).href; } catch (_) {} } if (!startUrl) { const auto = await gmRequest({ method: "GET", url: restartUrl, timeout: 60000, headers: { Accept: "text/html,application/xhtml+xml", "Upgrade-Insecure-Requests": "1" }, }); logHops.push(`restart-auto:${auto.status}@${(auto.finalUrl || "").slice(0, 90)}`); if (/ExamObj\s*\(/.test(auto.responseText || "")) { return { url: auto.finalUrl || restartUrl, text: auto.responseText || "", hops: logHops }; } if (/exam2\/start\//i.test(auto.finalUrl || "")) startUrl = auto.finalUrl; const m2 = String(auto.responseText || "").match( /https?:\/\/cje\d+\.edu-edu\.com\/exam\/student\/exam2\/start\/[^\s"'<>]+/i ); if (!startUrl && m2) startUrl = m2[0].replace(/&/g, "&"); } if (!startUrl) throw new Error("restart \u672a\u8fd4\u56de exam2/start\uff08\u68c0\u67e5 m/d \u7b7e\u540d\uff09"); r = await getOnce(startUrl, "start"); let doexamUrl = ""; if (r.loc && /exam2\/doexam\//i.test(r.loc)) doexamUrl = new URL(r.loc, r.finalUrl || startUrl).href; else if (/exam2\/doexam\//i.test(r.finalUrl || "")) doexamUrl = r.finalUrl; else if (/ExamObj\s*\(/.test(r.text)) return { url: r.finalUrl || startUrl, text: r.text, hops: logHops, startUrl }; else doexamUrl = doexamUrlFromStart(startUrl); if (!doexamUrl) throw new Error("exam2/start \u672a\u8f6c\u5230 doexam"); r = await getOnce(doexamUrl, "doexam"); if (!/ExamObj\s*\(/.test(r.text || "")) r = await getOnce(doexamUrl, "doexam2"); if (!/ExamObj\s*\(/.test(r.text || "")) throw new Error("\u7eed\u8003\u672a\u8fdb\u5165\u8003\u573a\uff08\u4f1a\u8bdd key \u672a\u5efa\u7acb\uff09"); return { url: r.finalUrl || doexamUrl, text: r.text, hops: logHops, startUrl }; } function absorbExamSession(url, text, out) { if (!url) return; rememberExamBase(out, url); try { const u = new URL(url, examBase(out)); const m = u.pathname.match(/\/exam2\/start\/(\d+)\/(\d+)\/(\d+)\/([^/]+)\/(\d+)/); if (m) { out.eid = m[1]; out.pid = m[2]; out.psid = m[3]; out.studentId = m[4]; out.bst = m[5]; out.userExamId = `${m[1]}_${m[4]}_${m[5]}`; } const m2 = u.pathname.match(/\/exam2\/doexam\/([^/]+)\/(\d+)\/(\d+)/); if (m2) { out.studentId = out.studentId || m2[1]; out.psid = out.psid || m2[2]; out.bst = out.bst || m2[3]; } const mRestart = u.pathname.match(/\/single\/restart\/([^/]+)\/([^/?]+)/); if (mRestart) { out.userExamId = out.userExamId || mRestart[1]; out.clientId = mRestart[2]; } const mStartEm = u.pathname.match(/\/single\/start\/([^/]+)\/([^/]+)\/([^/?]+)/); if (mStartEm) { out.clientId = mStartEm[2]; out.studentId = out.studentId || mStartEm[3]; } const tk = u.searchParams.get("take_exam_params_key"); if (tk) { try { const p = JSON.parse(decodeURIComponent(tk)); if (p.eid) out.eid = String(p.eid); if (p.pid) out.pid = String(p.pid); if (p.psid) out.psid = String(p.psid); if (p.bst) out.bst = String(p.bst); if (p.studentId) out.studentId = String(p.studentId); if (p.clientId) out.clientId = String(p.clientId); if (out.eid && out.studentId && out.bst) out.userExamId = `${out.eid}_${out.studentId}_${out.bst}`; } catch (_) {} } } catch (_) {} if (text) { const html = String(text); rememberExamBase(out, html); const eo = html.match(/ExamObj\s*\(\s*\d+\s*,\s*['"]([^'"]+)['"]/); if (eo) out.userExamId = out.userExamId || eo[1]; const pc = html.match(/paper_card4?\/(\d+)\/(\d+)\//i) || html.match(/paper_card2\/(\d+)\/(\d+)\//i); if (pc) { out.pid = out.pid || pc[1]; out.psid = out.psid || pc[2]; } const ma = html.match(/\/myanswer\/(?:list|newSave)\/([0-9A-Za-z_]+)/); if (ma && ma[1].includes("_")) out.userExamId = out.userExamId || ma[1]; const rsFull = html.match(/https?:\/\/cjexamnew\.edu-edu\.com\/exam-admin\/student\/exam\/single\/restart\/[^"'\\\s]+/i) || html.match(/\/exam-admin\/student\/exam\/single\/restart\/[^"'\\\s]+/i); if (rsFull) { try { out.restartUrl = new URL(String(rsFull[0]).replace(/&/g, "&"), EM_CJ).href; } catch (_) {} } const sm = html.match(/\/exam2\/start\/(\d+)\/(\d+)\/(\d+)\/([^/"']+)\/(\d+)/); if (sm) { out.eid = sm[1]; out.pid = sm[2]; out.psid = sm[3]; out.studentId = sm[4]; out.bst = sm[5]; out.userExamId = `${sm[1]}_${sm[4]}_${sm[5]}`; } } if (!out.userExamId && out.eid && out.studentId && out.bst) { out.userExamId = `${out.eid}_${out.studentId}_${out.bst}`; } } function applyPaperUrlFromHtml(sess, html) { const m = String(html || "").match(/\/exam\/student\/exam\/resource\/(paper_card4\/\d+\/\d+\/[^"'?\s]+)/i) || String(html || "").match(/\/exam\/student\/exam\/resource\/(paper_card2\/\d+\/\d+\/[^"'?\s]+)/i); if (!m) return false; const segs = m[1].match(/paper_card\d*\/(\d+)\/(\d+)\//i); if (segs) { sess.pid = segs[1]; sess.psid = segs[2]; } rememberExamBase(sess, html); sess.paperUrl = `${examBase(sess)}/exam/student/exam/resource/${m[1]}`; return true; } function finalizeExamPaperUrl(sess) { applyPaperUrlFromHtml(sess, sess.doexamHtml); if (sess.pid && sess.psid) { const base = examBase(sess); if (!sess.paperUrl || !/paper_card/i.test(sess.paperUrl) || !sess.paperUrl.startsWith(base)) { sess.paperUrl = `${base}/exam/student/exam/resource/paper_card4/${sess.pid}/${sess.psid}/214__index.html`; } } } function parsePaperQuestions(html) { const qs = []; const seen = new Set(); const add = (qid, psq) => { const k = String(qid); if (!k || seen.has(k)) return; seen.add(k); qs.push({ questionId: k, psqId: String(psq) }); }; const src = String(html || ""); let m; const re = /id=["']q_(\d+)["'][\s\S]{0,320}?code=["']psq_(\d+)["']/gi; while ((m = re.exec(src))) add(m[1], m[2]); if (!qs.length) { const re2 = /code=["']psq_(\d+)["'][\s\S]{0,320}?id=["']q_(\d+)["']/gi; while ((m = re2.exec(src))) add(m[2], m[1]); } return qs; } function compareTimeKindForKs(ksType) { const k = Number(ksType) || 2; return String(Math.max(1, k - 1)); } function decodeBasicHtmlEntities(s) { return String(s || "") .replace(/&/g, "&") .replace(/"/g, '"') .replace(/�*39;/g, "'") .replace(/�*27;/gi, "'") .replace(/'/g, "'") .replace(/&#(\d+);/g, (_, n) => { const c = Number(n); return Number.isFinite(c) ? String.fromCharCode(c) : _; }); } function parseExIndexPapers(html, ksType, expectTermCourseID) { const list = []; const seen = new Set(); const ks = String(ksType || KS_TYPE_HOMEWORK); const expectTid = String(expectTermCourseID || "").trim(); const src = decodeBasicHtmlEntities(String(html || "")); const pushPaper = (examCode, allowCount, termCourseID, buttonName, title, startOrRecord, signCJ) => { const code = String(examCode || "").trim(); let tid = String(termCourseID || "").trim(); const sor = String(startOrRecord || "1").trim() || "1"; const sign = String(signCJ || "").trim(); if (sor === "2") return; if (!isValidExPaperFields(tid, sign, code)) return; if (expectTid && tid !== expectTid) return; const titleText = String(title || code).trim(); const btn = String(buttonName || (ks === KS_TYPE_FINAL ? "\u671f\u672b\u8003\u8bd5" : "\u5e73\u65f6\u4f5c\u4e1a")).trim(); if (/\u4e0d\u8ba1\u5165\u6210\u7ee9|\u4e0d\u7b97\u6210\u7ee9|\u4ec5\u590d\u4e60|\u81ea\u6d4b\u7ec3\u4e60/.test(titleText)) return; if (ks === KS_TYPE_HOMEWORK && /\u7efc\u5408\u6d4b\u8bd5/.test(titleText) && /\u590d\u4e60|\u81ea\u6d4b|\u4e0d\u8ba1\u5165|\u4e0d\u7b97/.test(titleText)) return; const key = `${code}|${tid}|${sor}`; if (seen.has(key)) return; seen.add(key); list.push({ examCode: code, allowCount: String(allowCount || ""), termCourseID: tid, buttonName: btn, title: titleText, startOrRecord: sor, signCJ: sign, ksType: ks, }); }; const reClick2 = /pszy_click2\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]*)['"]\s*,\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]*)['"]\s*,\s*['"]([^'"]*)['"]\s*,\s*['"]([^'"]*)['"]\s*,\s*['"]([^'"]+)['"]\s*\)/gi; let m; while ((m = reClick2.exec(src))) { pushPaper(m[1], m[2], m[3], m[4], m[5], m[6], m[7]); } if (!list.length && /start_testing|\u5f00\u59cb\u8003\u8bd5/i.test(src)) { const reClick = /pszy_click\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]*)['"]\s*,\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]*)['"]\s*,\s*['"]([^'"]*)['"]\s*,\s*['"]1['"]\s*,\s*['"]([^'"]+)['"]\s*\)/gi; while ((m = reClick.exec(src))) { pushPaper(m[1], m[2], m[3], m[4], m[5], "1", m[6]); } } return list; } async function fetchExIndexPapers(course, ksType) { const ks = String(ksType || KS_TYPE_HOMEWORK); syncPortalFromLocation(); const tid = String((course && course.termCourseID) || "").trim(); if (!tid) { log(`\u300c${(course && course.title) || "\u8bfe\u7a0b"}\u300d\u7f3a\u5c11 termCourseID\uff0c\u65e0\u6cd5\u6253\u5f00\u4f5c\u4e1a/\u8003\u8bd5`, "err"); return []; } const finishOk = (papers) => { if (ks === KS_TYPE_FINAL) course.examPapers = papers; else course.homeworkPapers = papers; if (ks === KS_TYPE_FINAL) course.hasExam = true; else course.hasHomework = true; return papers; }; const htmlRef = { html: "" }; let papers = []; if (isPortal && /StudentSiteNewExIndex/i.test(location.pathname || "")) { try { const q = new URLSearchParams(location.search || ""); const qid = String(q.get("termcourseID") || q.get("termCourseID") || ""); const typ = String(q.get("type") || ""); if ((!qid || qid === tid) && (!typ || typ === ks)) { const pageHtml = document.documentElement.outerHTML; if (looksLikeExIndexHtml(pageHtml) && !looksLikePortalSysError(pageHtml)) { const papersNow = parseExIndexPapers(pageHtml, ks, tid); if (papersNow.length) return finishOk(papersNow); } } } catch (_) {} } const exUrl = `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/StudentSiteNewExIndex?termcourseID=${encodeURIComponent( tid )}&type=${encodeURIComponent(ks)}`; const compareUrl = `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/CompareTime`; const compareBody = new URLSearchParams({ termcourseID: tid, type: compareTimeKindForKs(ks), }).toString(); dbg(`\u9759\u9ed8\u62c9\u8bd5\u5377\uff08isCurrent=0→ExIndex\uff09\u300c${course.title}\u300d${exUrl}`); try { const framedHtml = await withAllTermListFrame(async ({ xhr, navigate }) => { try { await xhr("POST", compareUrl, compareBody); } catch (_) {} return navigate(exUrl, 800); }, 32000); if (framedHtml) { htmlRef.html = framedHtml; if (!looksLikePortalSysError(framedHtml) && looksLikeExIndexHtml(framedHtml)) { papers = parseExIndexPapers(framedHtml, ks, tid); if (papers.length) { dbg(`\u9759\u9ed8\u53d6\u5f97 ${papers.length} \u4efd${ks === KS_TYPE_FINAL ? "\u8bd5\u5377" : "\u4f5c\u4e1a"}\u300c${course.title}\u300d`); return finishOk(papers); } } else { dbg(`\u9759\u9ed8 ExIndex \u5f02\u5e38 snip=${snippetHtml(framedHtml, 120)}`); } } } catch (e) { dbg(`\u9759\u9ed8\u62c9\u5377\u5931\u8d25\uff1a${e.message || e}`); } if (!papers.length) { const html = htmlRef.html || ""; const hasBtn = /\u5f00\u59cb\u8003\u8bd5|start_testing|\u5269\u4f59\u8003\u8bd5\u6b21\u6570/i.test(html); const hasClick = /pszy_click2?\s*\(/i.test(html); const login = looksLikePortalLoginHtml(html); const sysErr = looksLikePortalSysError(html); const snip = snippetHtml(html, 160); dbg( `\u8bd5\u5377\u5217\u8868\u7a7a\u300c${course.title}\u300dtype=${ks} tid=${tid} html=${html.length} btn=${hasBtn} click=${hasClick} login=${login} sysErr=${sysErr} snip=${snip}` ); if (sysErr) { log( `\u300c${course.title}\u300d\u9759\u9ed8\u62c9\u53d6\u4f5c\u4e1a/\u8003\u8bd5\u5931\u8d25\uff08\u9700\u5148\u8fdb\u5165\u5168\u90e8\u5b66\u671f isCurrent=0\uff09\u3002\u8bf7\u5237\u65b0\u95e8\u6237\u540e\u91cd\u8bd5\uff0c\u5e76\u7981\u7528\u300c\u81ea\u8003\u4e91\u300d\u3002`, "err" ); } else if (login || /Object moved|\/Login/i.test(html) || html.length < 800) { log( `\u300c${course.title}\u300d\u4f5c\u4e1a/\u8003\u8bd5\u9875\u672a\u6253\u5f00\uff08\u767b\u5f55\u5931\u6548\u6216\u88ab\u8df3\u8f6c\uff09\u3002\u8bf7\u5237\u65b0\u540e\u91cd\u8bd5\u3002\u8be6\u60c5\uff1a${snip || "\u7a7a\u54cd\u5e94"}`, "err" ); } else if (hasBtn && !hasClick) { log(`\u300c${course.title}\u300d\u8003\u8bd5\u9875\u6709\u6309\u94ae\u4f46\u672a\u80fd\u8bc6\u522b\u5f00\u8003\u94fe\u63a5\uff0c\u8bf7\u66f4\u65b0\u811a\u672c\u540e\u91cd\u8bd5`, "err"); } } if (ks === KS_TYPE_FINAL) course.examPapers = papers; else course.homeworkPapers = papers; if (ks === KS_TYPE_FINAL && papers.length) course.hasExam = true; else if (ks === KS_TYPE_HOMEWORK && papers.length) course.hasHomework = true; return papers; } async function fetchHomeworkPapers(course) { return fetchExIndexPapers(course, KS_TYPE_HOMEWORK); } async function fetchFinalExamPapers(course) { return fetchExIndexPapers(course, KS_TYPE_FINAL); } async function compareTimeType(termCourseID, type) { try { const body = new URLSearchParams({ termcourseID: String(termCourseID), type: String(type) }); const { j } = await gmJson({ method: "POST", url: `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/CompareTime`, headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", }, data: body.toString(), }); if (j && j.state === false) throw new Error(j.msg || "\u4e0d\u5728\u8003\u8bd5/\u4f5c\u4e1a\u65f6\u95f4\u7a97\u53e3"); return true; } catch (e) { if (/\u4e0d\u5728/.test(String(e.message || e))) throw e; return true; } } async function ensureExamEntry(exam) { const ks = String(exam.ksType || KS_TYPE_HOMEWORK); try { await gmJson({ method: "POST", url: `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/GetExamStuCertificateBySuId`, headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", }, data: new URLSearchParams({ InstructionsType: ks }).toString(), }); } catch (_) {} try { await compareTimeType(exam.termCourseID, compareTimeKindForKs(ks)); if (ks === KS_TYPE_FINAL) await compareTimeType(exam.termCourseID, ks); } catch (e) { if (/\u4e0d\u5728/.test(String(e.message || e))) throw e; } try { await gmJson({ method: "POST", url: `${PORTAL}/StudentSiteNew/StudentSiteIndexNew/GetFaceCjAndDbModel`, headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", }, data: new URLSearchParams({ termCourse_id: String(exam.termCourseID) }).toString(), }); } catch (_) {} } function exIndexRefererFor(exam) { const tid = encodeURIComponent(String((exam && exam.termCourseID) || "")); const ks = encodeURIComponent(String((exam && exam.ksType) || KS_TYPE_HOMEWORK)); return `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/StudentSiteNewExIndex?termcourseID=${tid}&type=${ks}`; } function fillExamSignFromDom(exam) { if (!exam || /^[a-f0-9]{32}$/i.test(String(exam.signCJ || "").trim())) return; const take = (v) => { const s = String(v || "").trim(); return /^[a-f0-9]{32}$/i.test(s) ? s : ""; }; try { const sels = ["#hidSign", "input[name='hidSign']", "#hidSignCJ"]; for (const s of sels) { const el = document.querySelector(s); const v = take(el && el.value); if (v) { exam.signCJ = v; return; } } const fr = findPortalContentFrame(); if (fr && fr.contentDocument) { for (const s of sels) { const el = fr.contentDocument.querySelector(s); const v = take(el && el.value); if (v) { exam.signCJ = v; return; } } } } catch (_) {} } async function postGetExamUrlNew(exam, startOrRecord) { fillExamSignFromDom(exam); const sor = String(startOrRecord || "1"); const tid = String(exam.termCourseID || ""); const ks = String(exam.ksType || KS_TYPE_HOMEWORK); const body = new URLSearchParams({ ksType: ks, termcourseID: tid, courseCode: String(exam.examCode), startOrRecord: sor, signCJ: String(exam.signCJ || ""), }).toString(); const url = `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/GetExamUrl_New`; const exUrl = exIndexRefererFor(exam); const compareUrl = `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/CompareTime`; const compareBody = new URLSearchParams({ termcourseID: tid, type: compareTimeKindForKs(ks), }).toString(); const paramHint = `code=${exam.examCode} ks=${ks} tid=${tid} sor=${sor} signLen=${String(exam.signCJ || "").length}`; try { await ensureExamEntry(exam); } catch (e) { if (/\u4e0d\u5728/.test(String(e.message || e))) throw e; } let lastErr = ""; try { const framed = await withAllTermListFrame(async ({ xhr, navigate }) => { try { await xhr("POST", compareUrl, compareBody); } catch (_) {} const html = await navigate(exUrl, 700); if (looksLikePortalSysError(html)) { dbg(`GetExamUrl \u524d ExIndex \u5f02\u5e38 snip=${snippetHtml(html, 80)}`); return null; } try { await xhr( "POST", `${PORTAL}/MyOnlineCourseNew/OnlineLearningNew/GetExamStuCertificateBySuId`, new URLSearchParams({ InstructionsType: ks }).toString() ); } catch (_) {} try { await xhr( "POST", `${PORTAL}/StudentSiteNew/StudentSiteIndexNew/GetFaceCjAndDbModel`, new URLSearchParams({ termCourse_id: tid }).toString() ); } catch (_) {} return xhr("POST", url, body); }, 36000); if (framed && framed.j && framed.j.state && framed.j.msg) return framed.j; lastErr = (framed && framed.j && framed.j.msg) || `HTTP ${(framed && framed.status) || "?"} ${String((framed && framed.text) || "").slice(0, 80)}`; } catch (e) { lastErr = e.message || String(e); } if (!/^[a-f0-9]{32}$/i.test(String(exam.signCJ || "").trim())) { throw new Error(`GetExamUrl_New \u7f3a\u5c11\u6709\u6548 signCJ\uff08${paramHint}\uff09`); } throw new Error(`GetExamUrl_New \u5931\u8d25 ${paramHint} ${lastErr}`); } function pickCjeExamHop(html, baseUrl) { const src = String(html || "").replace(/&/g, "&"); const patterns = [ /https?:\/\/cje\d+\.edu-edu\.com\/exam\/student\/exam2\/(?:start|doexam)\/[^\s"'<>\\]+/i, /https?:\/\/cje\d+\.edu-edu\.com\/exam\/student\/exam2\/(?:start|doexam)\/[^"'\\\s]+/i, /["'](https?:\/\/cje\d+\.edu-edu\.com\/exam\/student\/exam2\/(?:start|doexam)\/[^"']+)["']/i, /(?:location\.href|location\.replace|window\.location)\s*=\s*["']([^"']*exam2\/(?:start|doexam)\/[^"']+)["']/i, /content=["']\d+;\s*url=([^"']*exam2\/(?:start|doexam)\/[^"']+)["']/i, /(\/exam\/student\/exam2\/(?:start|doexam)\/[^\s"'<>]+)/i, ]; for (const re of patterns) { const m = src.match(re); if (!m) continue; const raw = (m[1] || m[0] || "").replace(/^["']|["']$/g, ""); try { return new URL(raw, baseUrl || EXAM_HOST_DEFAULT).href; } catch (_) {} } return ""; } async function gmGetOnceExam(url, note) { let res = null; let loc = ""; try { res = await gmRequest({ method: "GET", url, redirect: "manual", timeout: 45000, headers: { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Upgrade-Insecure-Requests": "1", }, }); loc = headerLocation(res.responseHeaders); } catch (_) { res = null; } if (!res || !res.status || res.status === 0) { res = await gmRequest({ method: "GET", url, timeout: 60000, headers: { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Upgrade-Insecure-Requests": "1", }, }); loc = headerLocation(res.responseHeaders); } return { note, status: res.status, loc, finalUrl: res.finalUrl || url, text: res.responseText || "", res, }; } async function followAdminStartToDoexam(adminStartUrl) { const hops = []; let cur = adminStartUrl; let startUrl = ""; let doexamUrl = ""; let last = null; for (let i = 0; i < 8 && !doexamUrl; i++) { last = await gmGetOnceExam(cur, `hop${i}`); hops.push(`${last.note}:${last.status}@${(last.finalUrl || cur).slice(0, 100)}`); if (last.loc) { try { const abs = new URL(last.loc, cur).href; if (/exam2\/doexam\//i.test(abs)) { doexamUrl = abs; break; } if (/exam2\/start\//i.test(abs)) { startUrl = abs; cur = abs; continue; } if (/cjexamnew|cje\d+\.edu-edu\.com/i.test(abs)) { cur = abs; continue; } } catch (_) {} } if (/exam2\/doexam\//i.test(last.finalUrl || "")) { doexamUrl = last.finalUrl; break; } if (/exam2\/start\//i.test(last.finalUrl || "")) { startUrl = last.finalUrl; } if (/ExamObj\s*\(/.test(last.text || "")) { return { url: last.finalUrl || cur, text: last.text, hops, startUrl }; } const picked = pickCjeExamHop(last.text, last.finalUrl || cur); if (picked) { if (/exam2\/doexam\//i.test(picked)) { doexamUrl = picked; break; } if (/exam2\/start\//i.test(picked)) { startUrl = picked; cur = picked; continue; } cur = picked; continue; } if (i === 0 || i === 2) { const auto = await gmRequest({ method: "GET", url: cur, timeout: 60000, headers: { Accept: "text/html,application/xhtml+xml", "Upgrade-Insecure-Requests": "1" }, }); hops.push(`auto:${auto.status}@${(auto.finalUrl || "").slice(0, 100)}`); if (/ExamObj\s*\(/.test(auto.responseText || "")) { return { url: auto.finalUrl || cur, text: auto.responseText || "", hops, startUrl }; } if (/exam2\/doexam\//i.test(auto.finalUrl || "")) { doexamUrl = auto.finalUrl; last = { finalUrl: auto.finalUrl, text: auto.responseText || "", status: auto.status }; break; } if (/exam2\/start\//i.test(auto.finalUrl || "")) { startUrl = auto.finalUrl; cur = auto.finalUrl; continue; } const p2 = pickCjeExamHop(auto.responseText || "", auto.finalUrl || cur); if (p2) { cur = p2; if (/exam2\/start\//i.test(p2)) startUrl = p2; if (/exam2\/doexam\//i.test(p2)) { doexamUrl = p2; break; } continue; } } break; } if (!doexamUrl && startUrl) { doexamUrl = doexamUrlFromStart(startUrl); last = await gmGetOnceExam(startUrl, "start"); hops.push(`${last.note}:${last.status}@${(last.finalUrl || startUrl).slice(0, 100)}`); if (last.loc && /exam2\/doexam\//i.test(last.loc)) { try { doexamUrl = new URL(last.loc, startUrl).href; } catch (_) {} } else if (/exam2\/doexam\//i.test(last.finalUrl || "")) { doexamUrl = last.finalUrl; } else if (/ExamObj\s*\(/.test(last.text || "")) { return { url: last.finalUrl || startUrl, text: last.text, hops, startUrl }; } } if (!doexamUrl && startUrl) doexamUrl = doexamUrlFromStart(startUrl); if (!doexamUrl) { throw new Error(`\u5f00\u8003\u672a\u8df3\u5230 doexam · ${hops.join(" | ")}`); } last = await gmGetOnceExam(doexamUrl, "doexam"); hops.push(`${last.note}:${last.status}@${(last.finalUrl || doexamUrl).slice(0, 100)}`); if (!/ExamObj\s*\(/.test(last.text || "")) { last = await gmGetOnceExam(doexamUrl, "doexam2"); hops.push(`${last.note}:${last.status}@${(last.finalUrl || doexamUrl).slice(0, 100)}`); } return { url: last.finalUrl || doexamUrl, text: last.text || "", hops, startUrl }; } function isResultPageUrl(url) { return /\/view\/(?:do)?result\//i.test(String(url || "")); } function parseExamResultScore(html) { const src = String(html || ""); const patterns = [ /\u5f97\s*\u5206\s*[\uff1a:]\s*([\d.]+)/i, /\u60a8\u7684\u5f97\u5206\s*[\uff1a:\uff1a]?\s*([\d.]+)/i, /\u5206\s*\u6570\s*[\uff1a:]\s*([\d.]+)/i, /\u6210\s*\u7ee9\s*[\uff1a:]\s*([\d.]+)/i, /\u603b\s*\u5206\s*[\uff1a:]\s*([\d.]+)/i, /\u672c\u6b21\u5f97\u5206\s*[\uff1a:\uff1a]?\s*([\d.]+)/i, /["']score["']\s*[:=]\s*["']?([\d.]+)/i, /class=["'][^"']*score[^"']*["'][^>]*>\s*([\d.]+)/i, />([\d.]+)\s*\u5206\s*</, /\u6b63\u786e\u7387\s*[\uff1a:]\s*([\d.]+)\s*%/i, ]; for (const re of patterns) { const m = src.match(re); if (!m) continue; const n = parseFloat(m[1]); if (Number.isFinite(n)) return n; } if (/\u6ee1\u5206|\u5168\u90e8\u6b63\u786e|100\s*\u5206/.test(src) && /\u5f97\u5206|\u6210\u7ee9|\u5206\u6570/.test(src)) return 100; return null; } function isExamScoreFull(score) { if (score == null) return false; const n = Number(score); return Number.isFinite(n) && n >= 99.5; } async function ensureDoresultHtml(url, text) { let cur = String(url || ""); let html = String(text || ""); const refetch = async (u) => { const stay = await gmGetStay(u, 4); return { url: stay.url || u, text: stay.text || "", hops: stay.hops || [] }; }; if (/\/view\/result\//i.test(cur) && !/\/view\/doresult\//i.test(cur)) { return refetch(cur.replace("/view/result/", "/view/doresult/")); } if (/\/view\/doresult\//i.test(cur)) { if (html.length > 300 && /restart|\u5f97\u5206|\u6210\u7ee9|\u5206\u6570/i.test(html)) { return { url: cur, text: html, hops: [] }; } return refetch(cur); } const m = html.match(/https?:\/\/cjexamnew\.edu-edu\.com\/exam-admin\/student\/exam\/single\/view\/doresult\/[^"'\\\s]+/i) || html.match(/\/exam-admin\/student\/exam\/single\/view\/doresult\/[^"'\\\s]+/i); if (m) { try { const dr = new URL(m[0].replace(/&/g, "&"), EM_CJ).href; return refetch(dr); } catch (_) {} } return { url: cur, text: html, hops: [] }; } async function enterExamFromResultPage(exam, resultUrl, resultHtml) { const dr = await ensureDoresultHtml(resultUrl, resultHtml); let restartUrl = extractRestartUrl(dr.text, dr.url) || pickRestartFromHops(dr.hops); if (!restartUrl) { const again = await gmGetStay(dr.url, 4); restartUrl = extractRestartUrl(again.text, again.url) || pickRestartFromHops(again.hops); dr.text = again.text || dr.text; dr.url = again.url || dr.url; } if (!restartUrl) throw new Error("\u6210\u7ee9\u9875\u65e0\u7ee7\u7eed\u8003\u8bd5\u94fe\u63a5\uff08\u9700\u5e26 m/d\uff09"); dbg(`\u7eed\u8003\u94fe\u63a5\uff1a${restartUrl.slice(0, 100)}`); const entered = await followRestartToDoexam(restartUrl); return { entered, restartUrl, resultHtml: dr.text, resultUrl: dr.url }; } function sessFromEntered(exam, entered, restartUrl) { const sess = { examCode: exam.examCode, termCourseID: exam.termCourseID, title: exam.title, signCJ: exam.signCJ, ksType: exam.ksType || KS_TYPE_HOMEWORK, clientId: "ckc001", examBase: EXAM_HOST_DEFAULT, doexamHtml: entered.text || "", restartUrl: restartUrl || "", userExamId: "", eid: "", pid: "", psid: "", bst: "", studentId: "", paperUrl: "", }; for (const u of entered.hops || []) { const um = String(u).match(/https?:\/\/[^\s|]+/); if (um) absorbExamSession(um[0], "", sess); } if (entered.startUrl) absorbExamSession(entered.startUrl, "", sess); if (restartUrl) absorbExamSession(restartUrl, "", sess); absorbExamSession(entered.url, entered.text, sess); applyPaperUrlFromHtml(sess, sess.doexamHtml); finalizeExamPaperUrl(sess); return sess; } async function probeHomeworkRecord(exam) { try { const j = await postGetExamUrlNew(exam, "2"); const url = String(j.msg || "").trim(); if (!/^https?:\/\//i.test(url)) return { hasRecord: false, score: null, full: false }; const stay = await gmGetStay(url, 10); const dr = await ensureDoresultHtml(stay.url, stay.text); const score = parseExamResultScore(dr.text); const restartUrl = extractRestartUrl(dr.text, dr.url) || pickRestartFromHops(dr.hops || stay.hops); const full = isExamScoreFull(score); return { hasRecord: true, score, full, restartUrl, resultUrl: dr.url, resultHtml: dr.text, }; } catch (e) { dbg(`\u67e5\u8003\u8bd5\u8bb0\u5f55\uff1a${e.message || e}`); return { hasRecord: false, score: null, full: false }; } } async function openExamSessionAfterUrl(exam, startUrl) { dbg(`\u5f00\u8003\u8df3\u8f6c\uff1a${String(startUrl || "").slice(0, 100)}`); let entered = null; let restartUrl = ""; let landedResult = null; try { entered = await followAdminStartToDoexam(startUrl); } catch (e) { dbg(`\u9996\u6b21\u8fdb\u573a\u672a\u5230 doexam\uff1a${e.message || e}`); const stay = await gmGetStay(startUrl, 14); landedResult = { url: stay.url, text: stay.text, hops: stay.hops || [] }; } const finalUrl = (entered && entered.url) || (landedResult && landedResult.url) || ""; const finalHtml = (entered && entered.text) || (landedResult && landedResult.text) || ""; if (!entered || !/ExamObj\s*\(/.test(finalHtml) || isResultPageUrl(finalUrl)) { if (isResultPageUrl(finalUrl) || /doresult|view\/result/i.test(finalHtml)) { const fromResult = await enterExamFromResultPage(exam, finalUrl, finalHtml); entered = fromResult.entered; restartUrl = fromResult.restartUrl; } else { const picked = pickCjeExamHop(finalHtml, finalUrl); if (picked) { try { entered = await followAdminStartToDoexam(picked); } catch (_) {} } } } if (!entered) throw new Error("\u672a\u80fd\u8fdb\u5165\u8003\u573a\uff08\u65e0\u8df3\u8f6c\u7ed3\u679c\uff09"); const sess = sessFromEntered(exam, entered, restartUrl); if (!sess.userExamId) { try { const rec = await probeHomeworkRecord(exam); if (rec.restartUrl) { dbg(`\u6539\u7528\u8003\u8bd5\u8bb0\u5f55\u7eed\u8003\u8fdb\u573a`); const fromResult = await enterExamFromResultPage(exam, rec.resultUrl, rec.resultHtml); const sess2 = sessFromEntered(exam, fromResult.entered, fromResult.restartUrl); if (sess2.userExamId) { dbg(`\u5f00\u8003\u5c31\u7eea uid=…${String(sess2.userExamId).slice(-18)} · ${examBase(sess2)}`); return sess2; } } } catch (e) { dbg(`\u7eed\u8003\u8fdb\u573a\u5931\u8d25\uff1a${e.message || e}`); } dbg(`\u5f00\u8003\u89e3\u6790\u5931\u8d25 hops=${(entered.hops || []).join(" → ")}`); dbg(`final=${String(entered.url || "").slice(0, 140)}`); throw new Error("\u672a\u80fd\u8fdb\u5165\u8003\u573a"); } if (!/ExamObj\s*\(/.test(sess.doexamHtml || "") && sess.studentId && sess.psid && sess.bst) { const dUrl = `${examBase(sess)}/exam/student/exam2/doexam/${encodeURIComponent(sess.studentId)}/${encodeURIComponent( sess.psid )}/${encodeURIComponent(sess.bst)}`; try { const again = await gmGetOnceExam(dUrl, "doexam-retry"); if (/ExamObj\s*\(/.test(again.text || "")) { sess.doexamHtml = again.text; absorbExamSession(again.finalUrl, again.text, sess); applyPaperUrlFromHtml(sess, sess.doexamHtml); finalizeExamPaperUrl(sess); } } catch (_) {} } dbg(`\u5f00\u8003\u5c31\u7eea uid=…${String(sess.userExamId).slice(-18)} · ${examBase(sess)}`); return sess; } async function openExamSession(exam, startOrRecord) { const j = await postGetExamUrlNew(exam, startOrRecord); const url = String(j.msg || "").trim(); if (!/^https?:\/\//i.test(url)) throw new Error(`\u5f00\u8003\u94fe\u63a5\u5f02\u5e38\uff1a${url.slice(0, 80)}`); return openExamSessionAfterUrl(exam, url); } async function fetchSignedRestartUrl(sess, exam) { let restartUrl = extractRestartUrl(sess.restartUrl, EM_CJ) || ""; const tryAbsorb = (label, html, pageUrl, hops) => { const got = extractRestartUrl(html, pageUrl || EM_CJ) || pickRestartFromHops(hops); if (got) { restartUrl = got; dbg(`${label} \u53d6\u5230\u7ee7\u7eed\u8003\u8bd5\u94fe\u63a5`); return true; } return false; }; try { const j2 = await postGetExamUrlNew(exam, "2"); if (j2 && j2.state && j2.msg) { const stay = await gmGetStay(j2.msg, 8); tryAbsorb("\u6210\u7ee9\u9875", stay.text, stay.url, stay.hops); let doresultUrl = ""; if (/\/view\/result\//.test(stay.url || "")) doresultUrl = stay.url.replace("/view/result/", "/view/doresult/"); else if (/\/view\/doresult\//.test(stay.url || "")) doresultUrl = stay.url; if (!doresultUrl) { for (const u of stay.hops || []) { if (/\/view\/result\//.test(u)) { doresultUrl = u.replace("/view/result/", "/view/doresult/"); break; } if (/\/view\/doresult\//.test(u)) { doresultUrl = u; break; } } } if (doresultUrl) { const dr = await gmGetStay(doresultUrl, 4); const fresh = extractRestartUrl(dr.text, dr.url) || pickRestartFromHops(dr.hops); if (fresh) restartUrl = fresh; } } } catch (e) { dbg(`\u53d6\u7eed\u8003\u94fe\u63a5\uff1a${e.message || e}`); } if (!restartUrl) { const j1 = await postGetExamUrlNew(exam, "1"); const stay = await gmGetStay(j1.msg, 10); tryAbsorb("\u5f00\u8003\u9875", stay.text, stay.url, stay.hops); if (!restartUrl && /\/view\/result\//.test(stay.url || "")) { const dr = await gmGetStay(stay.url.replace("/view/result/", "/view/doresult/"), 4); tryAbsorb("doresult", dr.text, dr.url, dr.hops); } if (!restartUrl) restartUrl = pickRestartFromHops(stay.hops); } if (!restartUrl) throw new Error("\u6210\u7ee9\u9875\u65e0\u5e26 m/d \u7b7e\u540d\u7684\u7ee7\u7eed\u8003\u8bd5\u94fe\u63a5"); return restartUrl; } async function restartExamSession(sess, exam) { const restartUrl = await fetchSignedRestartUrl(sess, exam); const entered = await followRestartToDoexam(restartUrl); const out = Object.assign({}, sess, { doexamHtml: entered.text || "", restartUrl, }); if (entered.startUrl) absorbExamSession(entered.startUrl, "", out); absorbExamSession(entered.url, entered.text, out); applyPaperUrlFromHtml(out, out.doexamHtml); finalizeExamPaperUrl(out); if (!/ExamObj\s*\(/.test(out.doexamHtml || "")) throw new Error("\u7eed\u8003\u672a\u8fdb\u5165\u8003\u573a"); return out; } function decodeBasicEntities(s) { return String(s || "") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /g, " "); } function stripHtmlToText(html) { let s = decodeBasicEntities(html); s = s.replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<\/div>/gi, "\n"); s = s.replace(/<img[^>]*alt=["']([^"']*)["'][^>]*>/gi, " $1 "); s = s.replace(/<[^>]+>/g, " "); s = s.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n"); return s.replace(/[ \t]{2,}/g, " ").trim(); } function isChoiceLikeAnswer(ans, type) { const t = Number(type); if (t === 1 || t === 2 || t === 3) return true; const s = String(ans || "").trim(); return /^[a-dA-D]([,\uff0c\u3001\s]*[a-dA-D])*$/.test(s); } function normalizeExamAnswer(raw, type) { let s = decodeBasicEntities(String(raw == null ? "" : raw)).trim(); if (!s) return { text: "", imgOnly: false, raw: "" }; if (isChoiceLikeAnswer(s, type) && !/<img/i.test(s)) { return { text: s.toLowerCase().replace(/[\uff0c\u3001\s]+/g, ""), imgOnly: false, raw: s }; } const hasImg = /<img\b/i.test(s); const text = stripHtmlToText(s); if (text && text.length >= 2 && !/^https?:\/\//i.test(text)) { return { text, imgOnly: false, raw: s }; } if (hasImg) { const urls = []; const re = /<img[^>]+src=["']([^"']+)["']/gi; let m; while ((m = re.exec(s))) { try { urls.push(new URL(m[1], EM_CJ).href); } catch (_) { urls.push(m[1]); } } return { text: s, imgOnly: true, raw: s, imgUrls: urls }; } return { text: s, imgOnly: false, raw: s }; } function extractRightAnswerFromDoview(html, questionId) { const src = String(html || ""); const qid = String(questionId || ""); if (!src || !qid) return ""; const idx = src.search(new RegExp(`id=["']q_${qid}["']`, "i")); const chunk = idx >= 0 ? src.slice(idx, idx + 12000) : ""; const blocks = []; if (chunk) { const m1 = chunk.match(/\u53c2\u8003\u7b54\u6848\s*[\uff1a:]\s*([\s\S]{0,5000}?)(?=\u8003\u751f\u56de\u7b54|\u53c2\u8003\u7b54\u6848|id=["']q_|$)/i); if (m1) blocks.push(m1[1]); const m2 = chunk.match( /class=["'][^"']*(?:right_?answer|exam_rightAnswer|standard_answer|has_standard_answer)[^"']*["'][^>]*>([\s\S]{0,5000}?)<\/div>/i ); if (m2) blocks.push(m2[1]); } const m3 = src.match( new RegExp( `data-question-id=["']${qid}["'][\\s\\S]{0,6000}?\u53c2\u8003\u7b54\u6848\\s*[\uff1a:]([\\s\\S]{0,4000}?)(?=\u8003\u751f\u56de\u7b54|\u53c2\u8003\u7b54\u6848|$)`, "i" ) ); if (m3) blocks.push(m3[1]); for (const b of blocks) { const n = normalizeExamAnswer(b, 0); if (n.text) return n.text; } return ""; } async function enrichImageAnswersFromDoview(sess, keys, doviewUrl) { const need = (keys || []).filter((a) => { const n = normalizeExamAnswer(a && a.answer, a && a.type); return n.imgOnly || (!n.text && a && a.answer); }); if (!need.length) return keys; const base = examBase(sess); const url = doviewUrl || `${base}/exam/student/exam2/doview/${encodeURIComponent(sess.userExamId)}/1`; let html = ""; try { const res = await gmRequest({ method: "GET", url, timeout: 60000, headers: { Accept: "text/html,application/xhtml+xml", Referer: `${base}/` }, }); html = res.responseText || ""; } catch (e) { dbg(`\u62c9\u53d6\u7b54\u5377\u9875\u8865\u6807\u7b54\u5931\u8d25\uff1a${e.message || e}`); return keys; } let filled = 0; for (const a of keys) { const n0 = normalizeExamAnswer(a.answer, a.type); if (!n0.imgOnly && n0.text) continue; const got = extractRightAnswerFromDoview(html, a.questionId); if (got && got !== n0.text) { a.answer = got; filled += 1; } } if (filled) dbg(`\u7b54\u5377\u9875\u8865\u5168\u4e3b\u89c2/\u56fe\u7247\u6807\u7b54 ${filled} \u9053`); return keys; } async function saveExamAnswer(sess, q, answer) { const stime = String(Date.now()); const norm = typeof answer === "object" && answer && answer.text != null ? answer : normalizeExamAnswer(answer, q && q.type); const ans = String(norm.text || ""); if (!ans) throw new Error(`\u7a7a\u7b54\u6848 q=${q.questionId}`); const mdig = examAnswerSign(sess.userExamId, q.questionId, q.psqId, ans, stime); const body = `psqId=${encodeURIComponent(q.psqId)}&answer=${encodeURIComponent(ans)}&attach=&stime=${stime}&m=${mdig}`; const base = examBase(sess); const { j, status, text } = await gmJson({ method: "POST", url: `${base}/exam/student/exam/myanswer/newSave/${encodeURIComponent(sess.userExamId)}/${encodeURIComponent( q.questionId )}`, headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", Accept: "application/json, text/javascript, */*; q=0.01", "X-Requested-With": "XMLHttpRequest", Origin: base, Referer: `${base}/exam/student/exam2/doexam/${encodeURIComponent(sess.studentId || "")}/${encodeURIComponent( sess.psid || "" )}/${encodeURIComponent(sess.bst || "")}`, }, data: body, }); const ok = status >= 200 && status < 300 && (!j || j.success !== false); if (!ok) throw new Error(`\u4fdd\u5b58\u7b54\u6848\u5931\u8d25 q=${q.questionId} ${(text || "").slice(0, 80)}`); return j; } async function submitExamPaper(sess) { const base = examBase(sess); const { j, status, text } = await gmJson({ method: "POST", url: `${base}/exam/student/exam/submit/${encodeURIComponent(sess.userExamId)}`, headers: { Accept: "application/json, text/javascript, */*; q=0.01", "X-Requested-With": "XMLHttpRequest", Origin: base, Referer: `${base}/exam/student/exam2/doexam/${encodeURIComponent(sess.studentId || "")}/${encodeURIComponent( sess.psid || "" )}/${encodeURIComponent(sess.bst || "")}`, }, data: "", }); const ok = status >= 200 && status < 300 && (!j || j.success === true || j.success === "true"); if (!ok) throw new Error(`\u4ea4\u5377\u5931\u8d25 ${(text || "").slice(0, 100)}`); return j; } async function openExamResultViews(sess) { const uid = sess.userExamId; const base = examBase(sess); const finishedUrl = `${base}/exam/student/exam/finished/${encodeURIComponent(uid)}?&clientTheme=client_card4`; const fin = await gmRequest({ method: "GET", url: finishedUrl, timeout: 45000, headers: { Accept: "text/html,application/xhtml+xml", Referer: `${base}/` }, }); const finHtml = String(fin.responseText || "").replace(/&/g, "&"); let viewUrl = ""; const vm = finHtml.match(/\/exam\/student\/exam2\/view\/[^"'\\\s]+/); if (vm) { try { viewUrl = new URL(vm[0], base).href; } catch (_) { viewUrl = base + vm[0]; } } if (!viewUrl) { const params = { cc: "0", isUserQuestion: "false", d: String(Date.now()), limitedTime: "0", userViewType: "1", co: "0", asa: "1", ase: "true", atf: "0", furl: "", hasJoinErrorBook: "true", clientTheme: "client_card4", isCensorwords: "false", }; viewUrl = `${base}/exam/student/exam2/view/${encodeURIComponent(uid)}/1?&view_paper_params_key=${encodeURIComponent( JSON.stringify(params) )}&clientTheme=client_card4`; } await gmRequest({ method: "GET", url: viewUrl, timeout: 45000, headers: { Accept: "text/html,application/xhtml+xml", Referer: finishedUrl }, }); const doviewUrl = `${base}/exam/student/exam2/doview/${encodeURIComponent(uid)}/1`; const dv = await gmRequest({ method: "GET", url: doviewUrl, timeout: 45000, headers: { Accept: "text/html,application/xhtml+xml", Referer: viewUrl }, }); const dvHtml = dv.responseText || ""; for (const html of [finHtml, dvHtml]) { const signed = extractRestartUrl(html, EM_CJ); if (signed) { sess.restartUrl = signed; break; } } return { finishedUrl, viewUrl, doviewUrl }; } async function fetchExamAnswerKey(sess, doviewUrl) { const base = examBase(sess); const referer = doviewUrl || `${base}/exam/student/exam2/doview/${encodeURIComponent(sess.userExamId)}/1`; for (let attempt = 1; attempt <= 5; attempt++) { const { j, text, status } = await gmJson({ method: "GET", url: `${base}/exam/student/exam/answer/${encodeURIComponent(sess.userExamId)}?_=${Date.now()}`, headers: { Accept: "application/json, text/javascript, */*; q=0.01", "X-Requested-With": "XMLHttpRequest", Referer: referer, Origin: base, }, }); const answers = (j && Array.isArray(j.answers) && j.answers) || []; if (answers.length) return answers; dbg(`\u6807\u7b54\u4e3a\u7a7a try ${attempt}/5 · HTTP ${status} ${(text || "").slice(0, 80)}`); await sleep(500 * attempt); if (attempt === 2 || attempt === 4) { try { await openExamResultViews(sess); } catch (_) {} } } return []; } async function loadPaperQuestions(sess) { let questions = []; finalizeExamPaperUrl(sess); const base = examBase(sess); const referer = `${base}/exam/student/exam2/doexam/${encodeURIComponent(sess.studentId || "")}/${encodeURIComponent( sess.psid || "" )}/${encodeURIComponent(sess.bst || "")}`; for (let i = 0; i < 3 && !questions.length; i++) { if (!sess.paperUrl) break; const paper = await gmRequest({ method: "GET", url: sess.paperUrl, timeout: 60000, headers: { Accept: "text/html,application/xhtml+xml,*/*", Referer: referer }, }); questions = parsePaperQuestions(paper.responseText || ""); if (!questions.length) await sleep(350); } if (!questions.length && sess.doexamHtml) { applyPaperUrlFromHtml(sess, sess.doexamHtml); finalizeExamPaperUrl(sess); questions = parsePaperQuestions(sess.doexamHtml); } return questions; } async function runOneHomeworkPaper(exam) { setStatus(`\u4f5c\u7b54\uff1a${exam.title}`); const rec = await probeHomeworkRecord(exam); if (rec.hasRecord) { if (rec.full) { log(`\u300c${exam.title}\u300d\u5df2\u6ee1\u5206\uff0c\u8df3\u8fc7`); return true; } if (rec.score != null) dbg(`\u300c${exam.title}\u300d\u5f53\u524d\u5f97\u5206 ${rec.score}`); } let sess = null; if (rec.hasRecord && rec.restartUrl) { try { const fromResult = await enterExamFromResultPage(exam, rec.resultUrl, rec.resultHtml); sess = sessFromEntered(exam, fromResult.entered, fromResult.restartUrl); if (!sess.userExamId) throw new Error("\u672a\u80fd\u8fdb\u5165"); } catch (e) { dbg(`\u8bb0\u5f55\u7eed\u8003\u5931\u8d25\uff0c\u6539\u5f00\u8003\uff1a${e.message || e}`); sess = await openExamSession(exam, "1"); } } else { sess = await openExamSession(exam, "1"); } let questions = await loadPaperQuestions(sess); if (!questions.length) { sess = await restartExamSession(sess, exam); questions = await loadPaperQuestions(sess); } if (!questions.length) throw new Error("\u8bd5\u5377\u6682\u4e0d\u53ef\u7528"); dbg(`\u300c${exam.title}\u300d\u5171 ${questions.length} \u9898`); let keys = []; let doviewUrl = ""; try { const views0 = await openExamResultViews(sess); doviewUrl = views0.doviewUrl || ""; keys = await fetchExamAnswerKey(sess, doviewUrl); if (keys.length) dbg(`${exam.title} \u6cbf\u7528\u5df2\u4ea4\u5377\u6807\u7b54 ${keys.length} \u9053`); } catch (e) { dbg(`\u9884\u62c9\u6807\u7b54\u8df3\u8fc7\uff1a${e.message || e}`); } if (!keys.length) { setStatus(`\u6838\u5bf9\u7b54\u6848\uff1a${exam.title}`); sess = await restartExamSession(sess, exam); await saveExamAnswer(sess, questions[0], "a"); await submitExamPaper(sess); await sleep(500); const views = await openExamResultViews(sess); doviewUrl = views.doviewUrl || doviewUrl; keys = await fetchExamAnswerKey(sess, doviewUrl); } if (!keys.length) throw new Error("\u672a\u83b7\u53d6\u5230\u7b54\u6848"); keys = await enrichImageAnswersFromDoview(sess, keys, doviewUrl); const keyMap = new Map(); let imgN = 0; let skipN = 0; keys.forEach((a) => { if (!a || a.questionId == null) return; const n = normalizeExamAnswer(a.answer, a.type); if (!n.text) { skipN += 1; return; } if (n.imgOnly) imgN += 1; keyMap.set(String(a.questionId), n); }); if (imgN) dbg(`${exam.title} \u56fe\u7247\u6807\u7b54 ${imgN} \u9053`); if (skipN) dbg(`${exam.title} \u7a7a\u6807\u7b54\u8df3\u8fc7 ${skipN} \u9053`); setStatus(`\u63d0\u4ea4\uff1a${exam.title}`); sess = await restartExamSession(sess, exam); let okN = 0; let failN = 0; for (const q of questions) { if (!state.running || state.stopFlag) break; const ans = keyMap.get(String(q.questionId)); if (!ans) continue; try { await saveExamAnswer(sess, q, ans); okN += 1; } catch (e) { failN += 1; const msg = String(e.message || e); if (/10001|\u6ce8\u9500|\u6ca1\u6709\u8003\u8bd5/.test(msg)) { try { sess = await restartExamSession(sess, exam); await saveExamAnswer(sess, q, ans); okN += 1; failN -= 1; } catch (e2) { dbg(`\u6539\u7b54\u6062\u590d\u5931\u8d25\uff1a${e2.message || e2}`); break; } } else if (failN <= 3) dbg(`\u6539\u7b54\u5931\u8d25 ${q.questionId}\uff1a${msg.slice(0, 80)}`); } await sleep(60); } if (okN < 1) throw new Error("\u672a\u80fd\u63d0\u4ea4\u7b54\u6848"); await submitExamPaper(sess); log(`\u300c${exam.title}\u300d\u5df2\u5b8c\u6210`); return okN; } async function runPapersForCourse(course, ksType) { const ks = String(ksType || KS_TYPE_HOMEWORK); const isFinal = ks === KS_TYPE_FINAL; const label = isFinal ? "\u671f\u672b\u8003\u8bd5" : "\u5e73\u65f6\u4f5c\u4e1a"; if (!isFinal && !course.hasHomework && !course.zyPortalCode && course.homeworkScore == null) { log(`\u300c${course.title}\u300d\u6682\u65e0\u4f5c\u4e1a\uff0c\u8df3\u8fc7`); return true; } if (isFinal && !course.hasExam && course.examScore == null) { log(`\u300c${course.title}\u300d\u6682\u65e0\u671f\u672b\u8003\u8bd5\uff0c\u8df3\u8fc7`); return true; } setStatus(`${label}\uff1a${course.title}`); log(`\u300c${course.title}\u300d\u5f00\u59cb${label}`); const papers = await fetchExIndexPapers(course, ks); if (!papers.length) { log(`\u300c${course.title}\u300d\u672a\u62c9\u5230${label}\u8bd5\u5377\u5217\u8868\uff0c\u8bf7\u5237\u65b0\u95e8\u6237\u540e\u91cd\u8bd5`, "err"); return false; } log(`\u300c${course.title}\u300d${label} ${papers.length} \u4efd`); let did = 0; for (const paper of papers) { if (!state.running || state.stopFlag) break; try { await runOneHomeworkPaper(paper); did += 1; renderCourses(); } catch (e) { dbg(`${label}\u5931\u8d25\u300c${paper.title}\u300d\uff1a${e.message || e}`); log(`\u300c${paper.title}\u300d\u5931\u8d25\uff1a${e.message || e}`, "err"); return false; } await sleep(800); } if (did > 0) { log(`\u300c${course.title}\u300d${label}\u5df2\u4ea4\u5377\u3002\u6210\u7ee9\u8be6\u60c5\u53ef\u80fd\u7a0d\u665a\u624d\u66f4\u65b0\uff0c\u8bf7\u8fc7\u51e0\u5206\u949f\u518d\u5237\u65b0\u67e5\u770b\u6ee1\u5206\u72b6\u6001`); } try { await recheckCourseOnList(course); renderCourses(); updatePanel(); const fullNow = isFinal ? course.examFull : course.homeworkFull; if (did > 0 && !fullNow) { log(`\u300c${course.title}\u300d${label}\u6210\u7ee9\u5c1a\u672a\u663e\u793a\u6ee1\u5206\uff08\u5e73\u53f0\u540c\u6b65\u5ef6\u8fdf\u5c5e\u6b63\u5e38\uff09\uff0c\u7a0d\u540e\u91cd\u65b0\u52a0\u8f7d\u8bfe\u7a0b\u5217\u8868\u5373\u53ef`); } } catch (_) {} return did > 0; } async function runHomeworkForCourse(course) { return runPapersForCourse(course, KS_TYPE_HOMEWORK); } async function runFinalExamForCourse(course) { return runPapersForCourse(course, KS_TYPE_FINAL); } async function openAndStudyCourse(course, opts) { const skipFollowup = !!(opts && opts.skipFollowup); setStatus(`\u5b66\u4e60\uff1a${course.title}`); log(`\u5f00\u59cb\u5b66\u4e60\u300c${course.title}\u300d`); state._activeCourse = course; await fetchCourseStudyQuota(course); if (course.needMin > 0 && courseQuotaMet(course, course.learnedMin)) { log(`\u300c${course.title}\u300d\u65f6\u957f\u5df2\u6ee1\uff0c\u8df3\u8fc7\u8bfe\u4ef6`); await recheckCourseOnList(course); return true; } const learnUrl = await getCourseLearnUrl(course); const session = sessionFromLearnUrl(learnUrl, course); if (!session.clientCode || !session.userId || !session.coursewareCode) { throw new Error("\u5b66\u4e60\u94fe\u63a5\u7f3a\u5c11 clientCode/userId/coursewareCode"); } state.session = session; state.courseTitle = course.title; try { await ensureCloudLease(true); } catch (_) {} await warmLearnSession(session); const chapters = await fetchCatalogs(session, course); state.chapters = chapters; renderChapters(); log(`\u300c${course.title}\u300d\u5171 ${chapters.length} \u4e2a\u5c0f\u8282`); for (const ch of chapters) { if (!state.running || state.stopFlag) break; if (course.needMin > 0 && courseQuotaMet(course, sumChaptersSofarMin(state.chapters))) { log(`\u300c${course.title}\u300d\u65f6\u957f\u5df2\u6ee1`); break; } const ok = await runChapter(session, ch); if (!ok) return false; const localMin = sumChaptersSofarMin(state.chapters); course.learnedMin = Math.max(Number(course.learnedMin) || 0, localMin); if (course.needMin > 0 && localMin >= course.needMin) { await recheckCourseOnList(course); log(`\u300c${course.title}\u300d\u65f6\u957f\u5df2\u6ee1`); break; } await sleep(getGapMs()); } await recheckCourseOnList(course); return true; } function findCourseByAction(action, selected) { const id = String((action && action.termCourseID) || ""); return ( selected.find((c) => String(c.termCourseID) === id) || state.allCourses.find((c) => String(c.termCourseID) === id) || null ); } async function startPipeline(mode) { if (state.running) return; const selected = state.courses.filter((c) => c.checked); if (!selected.length) { setStatus("\u8bf7\u5148\u52fe\u9009\u8bfe\u7a0b"); return; } const planMode = mode === "homework" ? "homework" : mode === "exam" ? "exam" : "all"; if ((planMode === "homework" || planMode === "exam") && !ensurePortalHostForExam()) { return; } state.running = true; state.stopFlag = false; updatePanel(); log( planMode === "homework" ? `\u5f00\u59cb\u4f5c\u4e1a\uff0c\u5171 ${selected.length} \u95e8` : planMode === "exam" ? `\u5f00\u59cb\u8003\u8bd5\uff0c\u5171 ${selected.length} \u95e8` : `\u5f00\u59cb\u5b66\u4e60\uff0c\u5171 ${selected.length} \u95e8` ); try { await ensureCloudLease(true); for (const c of selected) { if (!state.running || state.stopFlag) break; try { await fetchCourseStudyQuota(c); } catch (_) {} } renderCourses(); let actions = []; try { actions = await cloudStudyPlan(selected, planMode); log(`\u4e91\u7aef\u5df2\u7f16\u6392 ${actions.filter((a) => /^(video|homework|exam)$/.test(a.type)).length} \u9879\u4efb\u52a1`); } catch (e) { log(`\u4e91\u7aef\u7f16\u6392\u5931\u8d25\uff1a${e.message || e}`, "err"); actions = []; } let needProLogged = false; for (const action of actions) { if (!state.running || state.stopFlag) break; const course = findCourseByAction(action, selected); if (!course && /^(video|homework|exam)$/.test(action.type)) continue; if (action.type === "video") { await openAndStudyCourse(course, { skipFollowup: true }); } else if (action.type === "homework") { await runHomeworkForCourse(course); } else if (action.type === "exam") { await runFinalExamForCourse(course); } else if (action.type === "need_pro") { if (!needProLogged) { log(action.message || "\u4f5c\u4e1a/\u8003\u8bd5\u9700\u5f00\u901a\u4f1a\u5458"); needProLogged = true; } } else if (/_skip$/.test(action.type) && action.reason) { log(`\u300c${action.title || (course && course.title) || ""}\u300d${action.reason}`); } } setStatus(state.stopFlag ? "\u5df2\u505c\u6b62" : "\u5168\u90e8\u5b8c\u6210"); log(state.stopFlag ? "\u5df2\u505c\u6b62" : "\u5168\u90e8\u5b8c\u6210"); } catch (e) { const msg = isQuotaError(e) ? friendlyCloudMessage(e) : e.message || e; setStatus("\u51fa\u9519\uff1a" + msg); dbg(`\u6d41\u6c34\u7ebf\u5f02\u5e38\uff1a${e.message || e}`); log(`\u8fd0\u884c\u51fa\u9519\uff1a${msg}`, "err"); } finally { state.running = false; state._activeCourse = null; updatePanel(); updateCloudPanelUI(); } } function stopPipeline() { state.stopFlag = true; state.running = false; setStatus("\u6b63\u5728\u505c\u6b62…"); log("\u6b63\u5728\u505c\u6b62"); updatePanel(); } const PANEL_NOTICE = "\u534e\u5bcc\u6559\u80b2\u6210\u6559\u52a9\u624b\uff1a\u514d\u8d39\u53ef\u5b66 3 \u4e2a\u89c6\u9891\u5c0f\u8282\uff1b\u4f5c\u4e1a\u4e0e\u671f\u672b\u9700\u5f00\u901a\u4f1a\u5458\u3002\u4f1a\u5458\u5f00\u901a\u540e 30 \u5929\u5185\u4e0d\u9650\u3002\u8bf7\u4f7f\u7528 Chrome\u3002"; function courseBadgesHtml(c) { const badges = []; if (courseQuotaMet(c, c.learnedMin) || c.lessonFull) badges.push(`<span class="cj-badge done">\u8bfe\u65f6\u5df2\u6ee1</span>`); else if (c.lessonScore != null) badges.push(`<span class="cj-badge todo">\u8bfe\u65f6:${esc(formatScorePart(c.lessonScore))}</span>`); else badges.push(`<span class="cj-badge">\u8bfe\u65f6:—</span>`); if (!c.hasHomework) badges.push(`<span class="cj-badge todo">\u65e0\u4f5c\u4e1a</span>`); else if (c.homeworkFull) badges.push(`<span class="cj-badge done">\u4f5c\u4e1a\u6ee1\u5206</span>`); else if (c.homeworkScore != null && Number(c.homeworkScore) > 0) badges.push(`<span class="cj-badge warn">\u4f5c\u4e1a\u672a\u6ee1\u5206</span>`); else badges.push(`<span class="cj-badge todo">\u4f5c\u4e1a\u5f85\u505a</span>`); if (!c.hasExam) badges.push(`<span class="cj-badge todo">\u65e0\u8003\u8bd5</span>`); else if (c.examFull) badges.push(`<span class="cj-badge done">\u8003\u8bd5\u6ee1\u5206</span>`); else if (c.examScore != null && Number(c.examScore) > 0) badges.push(`<span class="cj-badge warn">\u8003\u8bd5\u672a\u6ee1\u5206</span>`); else badges.push(`<span class="cj-badge todo">\u8003\u8bd5\u5f85\u505a</span>`); return badges.join(""); } function ensureStyle() { let st = document.getElementById(STYLE_ID); if (!st) { st = document.createElement("style"); st.id = STYLE_ID; document.documentElement.appendChild(st); } st.textContent = ` #${PANEL_ID}{position:fixed;right:20px;top:80px;z-index:2147483646;width:420px;background:#f4f7fb;border:1px solid #dbe4f0;border-radius:18px;box-shadow:0 18px 44px rgba(15,23,42,.18);font:12px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#0f172a;overflow:hidden} #${PANEL_ID}.collapsed .cj-body,#${PANEL_ID}.collapsed .cj-footer-extra,#${PANEL_ID}.collapsed .cj-footer{display:none} #${PANEL_ID} .cj-hd{padding:10px 12px;background:linear-gradient(180deg,#f8ecd5,#f4e8cf);border-bottom:1px solid #e5dbc6;display:flex;justify-content:space-between;align-items:center;cursor:move;user-select:none} #${PANEL_ID} .cj-brand{display:flex;align-items:center;gap:10px;min-width:0} #${PANEL_ID} .cj-logo{width:34px;height:34px;border-radius:10px;object-fit:cover;box-shadow:0 2px 10px rgba(15,23,42,.12);border:1px solid rgba(148,163,184,.45);background:#fff} #${PANEL_ID} .cj-title{font-size:15px;font-weight:900;color:#b45309;line-height:1.2;display:flex;align-items:center;gap:8px;flex-wrap:wrap} #${PANEL_ID} .cj-ver{font-size:11px;font-weight:900;color:#64748b;padding:2px 8px;border-radius:999px;background:#f1f5f9;border:1px solid #e2e8f0} #${PANEL_ID} .cj-sub{font-size:12px;color:#475569;margin-top:2px} #${PANEL_ID} .cj-toggle{border:none;background:#fff;color:#64748b;width:30px;height:30px;border-radius:999px;cursor:pointer;box-shadow:0 1px 2px rgba(15,23,42,.1)} #${PANEL_ID} .cj-body{padding:9px} #${PANEL_ID} .cj-card{background:#fff;border:1px solid #d9e2ee;border-radius:14px;padding:9px 10px;margin-bottom:9px} #${PANEL_ID} .cj-progress-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;font-size:12px;color:#334155} #${PANEL_ID} .cj-progress-num{font-size:26px;font-weight:900;color:#0f172a} #${PANEL_ID} .cj-progress-num small{font-size:14px;color:#64748b} #${PANEL_ID} .cj-progress-pct{font-size:22px;font-weight:900;color:#0369a1} #${PANEL_ID} .cj-progress-bar{height:10px;border-radius:999px;background:#e2e8f0;overflow:hidden} #${PANEL_ID} .cj-progress-bar>span{display:block;height:100%;width:0;background:linear-gradient(90deg,#22d3ee,#2563eb);transition:width .2s ease} #${PANEL_ID} #csustcj-status{padding:5px 10px;border-radius:999px;border:1px solid rgba(148,163,184,.45);font-weight:900;font-size:12px;background:#fff} #${PANEL_ID} #csustcj-status.cj-status-running{background:rgba(16,185,129,.12);border-color:rgba(16,185,129,.45);color:#059669} #${PANEL_ID} #csustcj-status.cj-status-starting{background:rgba(14,165,233,.12);border-color:rgba(14,165,233,.45);color:#0369a1} #${PANEL_ID} #csustcj-status.cj-status-stopped{background:rgba(100,116,139,.10);border-color:rgba(148,163,184,.45);color:#475569} #${PANEL_ID} #csustcj-status.cj-status-error{background:rgba(239,68,68,.10);border-color:rgba(239,68,68,.45);color:#dc2626} #${PANEL_ID} .cj-tabbar{display:flex;gap:8px;margin-bottom:10px} #${PANEL_ID} .cj-tab-btn{flex:1;border:1px solid #cbd5e1;background:#f8fafc;color:#475569;padding:6px 8px;border-radius:10px;cursor:pointer;font-weight:700;font-size:12px} #${PANEL_ID} .cj-tab-btn.active{background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;border-color:transparent;box-shadow:0 4px 12px rgba(30,64,175,.25)} #${PANEL_ID} .cj-pane{display:none} #${PANEL_ID} .cj-pane.active{display:block} #${PANEL_ID} .cj-list-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px} #${PANEL_ID} .cj-list-title{font-size:12px;color:#64748b;font-weight:700} #${PANEL_ID} .cj-list-tag{font-size:11px;color:#92400e;background:#ffedd5;border:1px solid #fdba74;border-radius:999px;padding:2px 8px} #${PANEL_ID} .cj-terms{margin:0 0 8px} #${PANEL_ID} .cj-terms select{width:100%;box-sizing:border-box;border:1px solid #cbd5e1;background:#f8fafc;color:#0f172a;border-radius:10px;padding:7px 10px;font-size:12px;font-weight:800;cursor:pointer;outline:none} #${PANEL_ID} #csustcj-courses,#csustcj-chapters,#csustcj-log{max-height:170px;overflow:auto;background:#f8fafc;border:1px solid #dbe4f0;border-radius:12px;padding:6px} #${PANEL_ID} #csustcj-log{max-height:150px} #${PANEL_ID} .cj-course-card{display:flex;align-items:center;justify-content:space-between;gap:10px;background:linear-gradient(180deg,#fff,#f8fbff);border:1px solid #dbe4f0;border-radius:12px;padding:10px;margin-bottom:8px;cursor:pointer} #${PANEL_ID} .cj-course-card:hover{border-color:#93c5fd;box-shadow:0 10px 22px rgba(30,64,175,.10)} #${PANEL_ID} .cj-course-card.selected{border-color:#2563eb;box-shadow:0 14px 34px rgba(37,99,235,.16)} #${PANEL_ID} .cj-course-left{display:flex;gap:10px;align-items:center;min-width:0;flex:1} #${PANEL_ID} .cj-course-left input{accent-color:#2563eb;flex:none} #${PANEL_ID} .cj-course-title{font-size:13px;font-weight:900;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #${PANEL_ID} .cj-course-sub{font-size:11px;color:#64748b;margin-top:2px;line-height:1.4} #${PANEL_ID} .cj-course-badges{display:flex;gap:6px;align-items:center;flex:0 0 auto;flex-wrap:wrap;justify-content:flex-end;max-width:46%} #${PANEL_ID} .cj-badge{font-size:10px;font-weight:900;border-radius:999px;padding:2px 6px;border:1px solid rgba(148,163,184,.35);color:#334155;background:#fff;white-space:nowrap} #${PANEL_ID} .cj-badge.done{color:#059669;background:rgba(16,185,129,.10);border-color:rgba(16,185,129,.45)} #${PANEL_ID} .cj-badge.todo{color:#0369a1;background:rgba(14,165,233,.10);border-color:rgba(14,165,233,.45)} #${PANEL_ID} .cj-badge.warn{color:#b45309;background:rgba(245,158,11,.12);border-color:rgba(245,158,11,.45)} #${PANEL_ID} .cj-item{display:flex;gap:8px;align-items:flex-start;background:linear-gradient(180deg,#fff,#f8fbff);border:1px solid #dbe4f0;border-radius:11px;padding:8px 9px;margin-bottom:6px} #${PANEL_ID} .cj-item.done{border-color:rgba(16,185,129,.45)} #${PANEL_ID} .cj-item.done b{color:#059669} #${PANEL_ID} .cj-meta-row{display:flex;justify-content:space-between;gap:8px;font-size:12px;margin-bottom:5px} #${PANEL_ID} .cj-meta-label{color:#64748b} #${PANEL_ID} .cj-meta-value{font-weight:700;color:#0f172a;text-align:right;max-width:70%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #${PANEL_ID} .cj-btn-row{display:flex;gap:8px;flex-wrap:wrap} #${PANEL_ID} .cj-btn{flex:1;min-width:0;border:none;color:#fff;padding:10px 10px;border-radius:12px;cursor:pointer;font-weight:800;font-size:13px} #${PANEL_ID} .cj-btn:disabled{opacity:.55;cursor:not-allowed} #${PANEL_ID} .cj-btn-go{background:#16a34a} #${PANEL_ID} .cj-btn-hw{background:#0ea5e9} #${PANEL_ID} .cj-btn-exam{background:#0f766e} #${PANEL_ID} .cj-btn-stop{background:#ef4444} #${PANEL_ID} .cj-btn-buy{background:#2563eb} #${PANEL_ID} .cj-btn-ghost{border:1px solid #cbd5e1;background:#fff;color:#0f172a} #${PANEL_ID} .cj-token-input{width:100%;box-sizing:border-box;border:1px solid #cbd5e1;border-radius:10px;padding:8px 10px;font-size:12px;margin:6px 0 8px;background:#fff} #${PANEL_ID} .cj-settings-group{background:#f8fafc;border:1px solid #dbe4f0;border-radius:14px;padding:10px;margin-bottom:10px} #${PANEL_ID} .cj-settings-title{font-size:12px;color:#64748b;font-weight:900;margin-bottom:8px} #${PANEL_ID} .cj-empty{padding:14px 8px;text-align:center;color:#94a3b8;font-size:12px;font-weight:900} #${PANEL_ID} .cj-log-line{padding:4px 6px;border-bottom:1px dashed #d4deea;color:#334155;font-size:12px;line-height:1.45;word-break:break-word} #${PANEL_ID} .cj-footer-extra{padding:7px 10px;background:#f8fafc;border-top:1px solid #e2e8f0} #${PANEL_ID} .cj-ann{position:relative;font-size:12px;color:#334155;line-height:1.5;background:linear-gradient(180deg,#fffdf5,#fff7e6);border:1px solid #fcd34d;border-radius:12px;padding:10px 10px 10px 12px} #${PANEL_ID} .cj-ann::before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:linear-gradient(180deg,#f59e0b,#ef4444);border-top-left-radius:12px;border-bottom-left-radius:12px} #${PANEL_ID} .cj-footer{padding:8px 12px;background:#eef2f7;border-top:1px solid #dbe4f0;color:#334155;font-size:12px} `; } function onTermFilterChange(key) { const tabs = state.termTabs && state.termTabs.length ? state.termTabs : []; const next = key || "current"; if (String(state.termFilter) === String(next)) return; state.termFilter = next; const list = applyTermFilter(); renderTermTabs(); renderCourses(); const label = (tabs.find((t) => String(t.key) === String(next)) || {}).label || "\u6240\u9009\u5b66\u671f"; setStatus(`${label} · ${list.length} \u95e8\u8bfe`); log(`\u5df2\u5207\u6362\u5230${label}\uff08${list.length} \u95e8\uff09`); (async () => { for (const c of list) { if (c.lessonScore != null || c.homeworkScore != null || c.examScore != null) continue; try { await fetchCourseStudyQuota(c); renderCourses(); } catch (_) {} await sleep(180); } })(); } function renderTermTabs() { const box = document.getElementById("csustcj-terms"); if (!box) return; const tabs = state.termTabs && state.termTabs.length ? state.termTabs : [{ key: "current", label: "\u5f53\u524d\u5b66\u671f" }]; const cur = String(state.termFilter || "current"); box.innerHTML = `<select id="csustcj-term-select" aria-label="\u9009\u62e9\u5b66\u671f">${tabs .map((t) => { const sel = String(t.key) === cur ? " selected" : ""; return `<option value="${esc(t.key)}"${sel}>${esc(t.label)}</option>`; }) .join("")}</select>`; const sel = document.getElementById("csustcj-term-select"); if (sel) sel.onchange = () => onTermFilterChange(sel.value); } function renderCourses() { const box = document.getElementById("csustcj-courses"); if (!box) return; if (!state.courses.length) { box.innerHTML = `<div class="cj-empty">\u8be5\u5b66\u671f\u6682\u65e0\u8bfe\u7a0b\uff0c\u53ef\u5207\u6362\u5176\u5b83\u5b66\u671f</div>`; return; } box.innerHTML = state.courses .map((c, i) => { const selected = c.checked ? "selected" : ""; return `<div class="cj-course-card ${selected}" data-i="${i}"> <div class="cj-course-left"> <input type="checkbox" data-i="${i}" ${c.checked ? "checked" : ""} /> <div style="min-width:0;flex:1"> <div class="cj-course-title" title="${esc(c.title)}">${esc(c.title)}</div> <div class="cj-course-sub">${esc(courseScoreLine(c))}</div> </div> </div> <div class="cj-course-badges">${courseBadgesHtml(c)}</div> </div>`; }) .join(""); box.querySelectorAll("input[type=checkbox]").forEach((el) => { el.addEventListener("click", (e) => e.stopPropagation()); el.addEventListener("change", () => { const i = Number(el.getAttribute("data-i")); if (state.courses[i]) state.courses[i].checked = !!el.checked; renderCourses(); updatePanel(); }); }); box.querySelectorAll(".cj-course-card").forEach((card) => { card.addEventListener("click", (e) => { if (e.target.closest("input")) return; const i = Number(card.getAttribute("data-i")); if (!state.courses[i]) return; state.courses[i].checked = !state.courses[i].checked; renderCourses(); updatePanel(); }); }); } function renderChapters() { const box = document.getElementById("csustcj-chapters"); if (!box) return; if (!state.chapters.length) { box.innerHTML = `<div class="cj-empty">\u5c1a\u672a\u5f00\u59cb\u5b66\u4e60</div>`; return; } box.innerHTML = state.chapters .map((ch) => { const pct = chapterPct(ch); return `<div class="cj-item ${ch.done ? "done" : ""}"> <span>${ch.done ? "✓" : "•"}</span> <div style="min-width:0;flex:1"><b>${esc(ch.catalogTitle)}</b><div class="cj-course-sub">\u8fdb\u5ea6 ${pct}%</div></div> </div>`; }) .join(""); } function renderLog() { const box = document.getElementById("csustcj-log"); if (!box) return; if (!state.logs.length) { box.innerHTML = `<div class="cj-empty">\u6682\u65e0\u8fd0\u884c\u65e5\u5fd7</div>`; return; } box.innerHTML = state.logs .slice(0, 40) .map((l) => `<div class="cj-log-line">${esc(l)}</div>`) .join(""); } function updateProgressUI() { const total = state.chapters.length || 0; const done = (state.chapters || []).filter((c) => c.done).length; let pct = 0; if (total > 0) pct = Math.round((done / total) * 100); else if (state.videoTime > 0) pct = Math.min(100, Math.round((state.sofar / state.videoTime) * 100)); const doneEl = document.getElementById("csustcj-queue-count"); const totalEl = document.getElementById("csustcj-queue-total"); const pctEl = document.getElementById("csustcj-queue-percent"); const bar = document.getElementById("csustcj-queue-progress"); if (doneEl) doneEl.textContent = String(done); if (totalEl) totalEl.textContent = String(total); if (pctEl) pctEl.textContent = `${pct}%`; if (bar) bar.style.width = `${pct}%`; } function updatePanel() { setStatus(state.status); updateProgressUI(); const go = document.getElementById("csustcj-go"); const hw = document.getElementById("csustcj-hw"); const ex = document.getElementById("csustcj-exam"); const stop = document.getElementById("csustcj-stop"); if (go) go.disabled = !!state.running; if (hw) hw.disabled = !!state.running; if (ex) ex.disabled = !!state.running; if (stop) stop.disabled = !state.running; const curCourse = document.getElementById("csustcj-current-course"); const curChapter = document.getElementById("csustcj-current-chapter"); if (curCourse) curCourse.textContent = state.courseTitle || "\u65e0"; if (curChapter) curChapter.textContent = state.catalogTitle || "\u65e0"; const n = (state.courses || []).filter((c) => c.checked).length; const foot = document.getElementById("csustcj-queue-text"); if (foot && !state.running) foot.textContent = n ? `\u5df2\u9009 ${n} \u95e8\u8bfe` : "\u672a\u9009\u62e9"; } function switchTab(tab) { document.querySelectorAll(`#${PANEL_ID} .cj-tab-btn`).forEach((btn) => { btn.classList.toggle("active", btn.getAttribute("data-tab") === tab); }); document.querySelectorAll(`#${PANEL_ID} .cj-pane`).forEach((pane) => { pane.classList.toggle("active", pane.getAttribute("data-pane") === tab); }); } function mountPanel() { ensureStyle(); const old = document.getElementById(PANEL_ID); if (old) old.remove(); const panel = document.createElement("div"); panel.id = PANEL_ID; if (gmGet(LS.collapsed, false)) panel.classList.add("collapsed"); panel.innerHTML = ` <div class="cj-hd" id="csustcj-drag"> <div class="cj-brand"> <img class="cj-logo" src="${LOGO}" alt="" referrerpolicy="no-referrer" /> <div> <div class="cj-title">\u6210\u6559·\u534e\u5bcc\u6559\u80b2\u770b\u8bfe\u52a9\u624b <span class="cj-ver">v${esc(VERSION)}</span></div> <div class="cj-sub">${esc(SCHOOL_DISPLAY)} · \u514d\u8d39 3 \u5c0f\u8282\uff1b\u4f5c\u4e1a/\u8003\u8bd5\u9700\u4f1a\u5458</div> </div> </div> <button type="button" class="cj-toggle" id="csustcj-toggle" title="\u6298\u53e0">–</button> </div> <div class="cj-body"> <div class="cj-card"> <div class="cj-progress-top"><span>\u72b6\u6001</span><span id="csustcj-status">\u5c31\u7eea</span></div> <div class="cj-progress-top"> <div class="cj-progress-num"><span id="csustcj-queue-count">0</span><small> /<span id="csustcj-queue-total">0</span> \u7ae0\u5df2\u5b8c\u6210</small></div> <div class="cj-progress-pct" id="csustcj-queue-percent">0%</div> </div> <div class="cj-progress-bar"><span id="csustcj-queue-progress"></span></div> </div> <div class="cj-card"> <div class="cj-tabbar"> <button type="button" class="cj-tab-btn active" data-tab="course">\u8bfe\u7a0b\u5217\u8868</button> <button type="button" class="cj-tab-btn" data-tab="chapter">\u7ae0\u8282\u9884\u89c8</button> <button type="button" class="cj-tab-btn" data-tab="log">\u8fd0\u884c\u65e5\u5fd7</button> <button type="button" class="cj-tab-btn" data-tab="settings">\u8bbe\u7f6e</button> </div> <div class="cj-pane active" data-pane="course"> <div class="cj-list-head"><span class="cj-list-title">\u8bfe\u7a0b\u9009\u62e9</span><span class="cj-list-tag">\u6309\u5b66\u671f\u7b5b\u9009</span></div> <div class="cj-terms" id="csustcj-terms"></div> <div id="csustcj-courses"></div> </div> <div class="cj-pane" data-pane="chapter"> <div class="cj-list-head"><span class="cj-list-title">\u7ae0\u8282\u9884\u89c8</span><span class="cj-list-tag">\u5b9e\u65f6\u8fdb\u5ea6</span></div> <div id="csustcj-chapters"></div> </div> <div class="cj-pane" data-pane="log"> <div class="cj-list-head"><span class="cj-list-title">\u8fd0\u884c\u65e5\u5fd7</span><span class="cj-list-tag">\u5b9e\u65f6\u65e5\u5fd7</span></div> <div id="csustcj-log"></div> </div> <div class="cj-pane" data-pane="settings"> <div class="cj-list-head"><span class="cj-list-title">\u8bbe\u7f6e</span><span class="cj-list-tag">\u4f1a\u5458\u6388\u6743</span></div> <div class="cj-settings-group" style="margin-bottom:0"> <div class="cj-settings-title">\u4f1a\u5458\u6388\u6743</div> <div class="cj-meta-row"><span class="cj-meta-label">\u7528\u6237\u7c7b\u578b</span><span class="cj-meta-value" id="csustcj-cloud-tier">\u514d\u8d39\u8bd5\u7528</span></div> <div class="cj-meta-row"><span class="cj-meta-label">\u5b66\u4e60\u989d\u5ea6</span><span class="cj-meta-value" id="csustcj-cloud-quota">—</span></div> <input class="cj-token-input" id="csustcj-cloud-token" type="text" placeholder="\u7c98\u8d34\u4f1a\u5458\u5361\u5bc6\u540e\u70b9\u300c\u4fdd\u5b58\u5361\u5bc6\u300d" autocomplete="off" /> <div class="cj-btn-row"> <button class="cj-btn cj-btn-buy" id="csustcj-save-token" type="button">\u4fdd\u5b58\u5361\u5bc6</button> <button class="cj-btn cj-btn-ghost" id="csustcj-buy" type="button">\u5f00\u901a\u4f1a\u5458</button> </div> </div> </div> </div> <div class="cj-card"> <div class="cj-meta-row"><span class="cj-meta-label">\u5f53\u524d\u8bfe\u7a0b</span><span class="cj-meta-value" id="csustcj-current-course">\u65e0</span></div> <div class="cj-meta-row"><span class="cj-meta-label">\u5f53\u524d\u7ae0\u8282</span><span class="cj-meta-value" id="csustcj-current-chapter">\u65e0</span></div> <div class="cj-meta-row"><span class="cj-meta-label">\u5b66\u6821</span><span class="cj-meta-value" id="csustcj-school-name">${esc(SCHOOL_DISPLAY)}</span></div> </div> <div class="cj-card"> <div class="cj-btn-row"> <button class="cj-btn cj-btn-go" id="csustcj-go" type="button">\u5f00\u59cb</button> <button class="cj-btn cj-btn-hw" id="csustcj-hw" type="button">\u4f5c\u4e1a</button> <button class="cj-btn cj-btn-exam" id="csustcj-exam" type="button">\u8003\u8bd5</button> <button class="cj-btn cj-btn-stop" id="csustcj-stop" type="button" disabled>\u505c\u6b62</button> </div> </div> </div> <div class="cj-footer-extra"> <div class="cj-ann"><span id="csustcj-ann-text">${esc(PANEL_NOTICE)}</span></div> </div> <div class="cj-footer">\u961f\u5217\u72b6\u6001\uff1a<span id="csustcj-queue-text">\u672a\u9009\u62e9</span></div>`; document.documentElement.appendChild(panel); document.getElementById("csustcj-toggle").onclick = () => { panel.classList.toggle("collapsed"); gmSet(LS.collapsed, panel.classList.contains("collapsed")); }; document.querySelectorAll(`#${PANEL_ID} .cj-tab-btn`).forEach((btn) => { btn.onclick = () => switchTab(btn.getAttribute("data-tab") || "course"); }); document.getElementById("csustcj-go").onclick = () => startPipeline("all"); document.getElementById("csustcj-hw").onclick = () => startPipeline("homework"); document.getElementById("csustcj-exam").onclick = () => startPipeline("exam"); document.getElementById("csustcj-stop").onclick = () => stopPipeline(); const buyBtn = document.getElementById("csustcj-buy"); if (buyBtn) buyBtn.onclick = () => openProBuy(); const saveTok = document.getElementById("csustcj-save-token"); if (saveTok) { saveTok.onclick = async () => { const input = document.getElementById("csustcj-cloud-token"); const typed = input ? String(input.value || "").trim() : ""; setCloudToken(typed); const tk = getCloudToken(); const luid = getLearningUserId(); log(tk ? "\u5361\u5bc6\u5df2\u4fdd\u5b58\uff0c\u6b63\u5728\u6821\u9a8c…" : "\u5df2\u6e05\u9664\u5361\u5bc6"); dbg(`\u7ed1\u5361\u6821\u9a8c uid=${luid} server=${state.serverBuild || "\u672a\u77e5"}`); try { state.tokenBoundBlocked = false; await ensureCloudLease(true); if (isProUser()) { log(`\u4f1a\u5458\u5df2\u751f\u6548${state.cloudProExpireAt ? "\uff0c\u5230\u671f " + formatProExpireAt(state.cloudProExpireAt) : ""}`); } else if (!tk) { log("\u5f53\u524d\u4e3a\u514d\u8d39\u8bd5\u7528"); } else if (state.tokenBoundBlocked) { log( state.serverHasRebind ? "\u6b64\u5361\u5df2\u7ed1\u5176\u4ed6\u8d26\u53f7\u3002\u8bf7\u5728\u540e\u53f0\u70b9\u300c\u6e05\u7ed1\u5b9a\u300d\u540e\u91cd\u8bd5\u3002" : "\u4e91\u7aef\u672a\u90e8\u7f72\u6362\u7ed1\u4fee\u590d\u3002\u8bf7\u4e0a\u4f20\u6700\u65b0 database.py/main.py \u5e76\u91cd\u542f\u540e\u518d\u4fdd\u5b58\u5361\u5bc6\u3002", "err" ); } else { log( state.serverHasRebind ? "\u5361\u5bc6\u672a\u5347\u7ea7\u4e3a\u4f1a\u5458\uff08\u8bf7\u6838\u5bf9\u5361\u5bc6\u662f\u5426\u6b63\u786e\uff0c\u6216\u540e\u53f0\u6e05\u7ed1\u5b9a\u540e\u91cd\u8bd5\uff09" : "\u5361\u5bc6\u672a\u751f\u6548\uff1a\u4e91\u7aef\u4ecd\u662f\u65e7\u7248\u3002\u8bf7\u90e8\u7f72 database.py\uff08\u542b shared anon \u6362\u7ed1\uff09\u5e76\u91cd\u542f uvicorn", "err" ); } } catch (e) { const raw = String((e && e.message) || e || ""); const tip = friendlyCloudMessage(e); const badToken = /invalid token|revoked|expired_after_activation|expired|\u5361\u5bc6\u4e0d\u5bf9|\u4e0d\u53ef\u7528|\u5df2\u8fc7\u671f|\u5df2\u7981\u7528/i.test( raw + " " + tip ); if (badToken) { setCloudToken(""); await fallBackToFreeLease(); log(tip, "err"); updateCloudPanelUI(); if (input) input.value = typed; return; } if (!state.serverHasRebind && /\u7ed1\u5b9a|token_bound|\u522b\u7684\u8d26\u53f7/i.test(tip + raw)) { clearProMembershipState(); log("\u4e91\u7aef\u672a\u90e8\u7f72\u6362\u7ed1\u4fee\u590d\u3002\u8bf7\u4e0a\u4f20\u6700\u65b0 database.py/main.py/admin_ui.py\uff0c\u91cd\u542f\u540e\u518d\u4fdd\u5b58\u5361\u5bc6\u3002", "err"); } else { if (/token_bound|\u522b\u7684\u8d26\u53f7/i.test(raw + tip) || (e && e.code === "token_bound")) { state.cloudTier = "free"; state.cloudProExpireAt = 0; state.tokenBoundBlocked = true; cacheCloudLease(); } log(tip, "err"); } } updateCloudPanelUI(); }; } const drag = document.getElementById("csustcj-drag"); let sx = 0, sy = 0, ox = 0, oy = 0, moving = false; drag.addEventListener("mousedown", (e) => { if (e.target.closest("button")) return; moving = true; sx = e.clientX; sy = e.clientY; const r = panel.getBoundingClientRect(); ox = r.left; oy = r.top; e.preventDefault(); }); window.addEventListener("mousemove", (e) => { if (!moving) return; panel.style.left = ox + e.clientX - sx + "px"; panel.style.top = oy + e.clientY - sy + "px"; panel.style.right = "auto"; }); window.addEventListener("mouseup", () => { moving = false; }); renderTermTabs(); renderCourses(); renderChapters(); renderLog(); updatePanel(); refreshSchoolDisplayUI(); } async function boot() { gmSet(LS.gap, 2000); gmSet(LS.step, 60); mountPanel(); initCloudAuth().catch(() => {}); if (isPortal) { try { await refreshCourses(); } catch (e) { dbg(`\u81ea\u52a8\u62c9\u8bfe\u5931\u8d25\uff1a${e.message || e}`); log("\u8bfe\u7a0b\u52a0\u8f7d\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u6216\u91cd\u65b0\u6253\u5f00\u9875\u9762"); } } else { setStatus("\u8bf7\u56de\u5230\u5b66\u4e60\u95e8\u6237\u540e\u518d\u5f00\u59cb"); log("\u8bf7\u5728\u5b66\u4e60\u95e8\u6237\u9875\u9762\u4f7f\u7528\u672c\u52a9\u624b"); } refreshSchoolDisplayUI(); updateCloudPanelUI(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } })();