// ==UserScript==
// @name 【效率版】法宣在线全国智慧普法依法治理云平台学习助手
// @namespace https://faxuanyun.com/
// @version 1.0
// @author 柠檬真酸
// @icon https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png
// @description 法宣在线全国智慧普法依法治理云平台(*.faxuanyun.com)学习助手:勾选课程自动学时 + Pro自动考试,高效率完成
// @antifeature payment 免费体验章节有限,升级Pro不限
// @antifeature membership 需云端授权
// @match *://*.faxuanyun.com/*
// @match *://faxuanyun.com/*
// @match *://*.faxuan.net/*
// @match *://faxuan.net/*
// @match *://*.ifaxuan.com/*
// @match *://ifaxuan.com/*
// @match *://*.ifaxuan.net/*
// @connect *.faxuanyun.com
// @connect faxuanyun.com
// @connect *.faxuan.net
// @connect faxuan.net
// @connect *.ifaxuan.com
// @connect ifaxuan.com
// @connect *.ifaxuan.net
// @connect *.ahzsksw.cn
// @connect ahzsksw.cn
// @connect fa.ahsxks.com
// @connect card.wlxy.live
// @connect huaweicloudobs.ahjxjy.cn
// @grant GM_xmlhttpRequest
// @grant GM_info
// @grant GM_openInTab
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-idle
// @license All Rights Reserved
// ==/UserScript==
(function () {
"use strict";
const SCRIPT_VERSION = (() => {
try {
return String(GM_info?.script?.version || "1.0");
} catch (_) {
return "1.0";
}
})();
if (window.__fxFaxuanHelper) return;
window.__fxFaxuanHelper = true;
const HOST = location.hostname;
const ORIGIN = location.origin;
const PANEL_POS_KEY = "fx_panel_pos_v1";
const PANEL_COLLAPSED_KEY = "fx_panel_collapsed_v1";
const LOG_KEY = "fx_run_log_v1";
const QUEUE_KEY = "fx_course_queue_v1";
const CLOUD_TOKEN_KEY = "fx_cloud_token_v1";
const CLOUD_LEASE_CACHE_KEY = "fx_cloud_lease_cache_v1";
const CLOUD_PRO_EXPIRE_CACHE_KEY = "fx_cloud_pro_expire_cache_v1";
const CLOUD_LAST_STATE_KEY = "fx_cloud_last_state_v1";
const PANEL_LOGO_URL =
"https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png";
const QQ_GROUP_NUMBER = "903117129";
const QQ_GROUP_LINK =
"https://qun.qq.com/universal-share/share?ac=1&authKey=rxdL6YIJ0%2FxOEemjLqTGULvl5aAfJIVQcIvkvnwvmL%2FAmpFZnSafajYHgSXMUXvx&busi_data=eyJncm91cENvZGUiOiI5MDMxMTcxMjkiLCJ0b2tlbiI6IlB2dkFGSm5XRXBrSEhtQVFTUGQzdVNZakhNWDNMbW1kODA2enpoMi9obDh4SWp0YzBDODNFaGtwRU44Z0hyU0siLCJ1aW4iOiIxMjU0MzE1MTQifQ%3D%3D&data=9oyJixSPcigCQW-saV5eXlcMwV9C6J36XySx-rDHwVwNlofvRmd2ze5sLwFtHTbYbG4nAWIUrI0qftC6aTX9xg&svctype=4&tempid=h5_group_info";
const DEFAULT_CLOUD_API_BASE = "https://oa37.ahzsksw.cn";
const PRO_BUY_URL = "https://card.wlxy.live/details/D0D9BC01";
const PANEL_NOTICE_FALLBACK = "\u6cd5\u5ba3\u5728\u7ebf\u5b66\u4e60\u52a9\u624b";
const FREE_CHAPTER_LIMIT_DEFAULT = 3;
const PRO_DAYS = 30;
const TERMINAL = "PC";
let EXAM_FIXED_SERIES = 316;
let LEARN_MAX_JUMP_SEC = 120;
const PERIOD_PROGRESS_TTL_MS = 45000;
let _periodProgCache = { courseId: "", at: 0, data: null };
function invalidatePeriodProgressCache(courseId) {
if (!courseId || _periodProgCache.courseId === String(courseId)) {
_periodProgCache = { courseId: "", at: 0, data: null };
}
}
const state = {
user: null,
courses: [],
waresByCourse: {},
selectedCourseIds: loadQueue(),
running: false,
stopFlag: false,
lastAction: "\u5c31\u7eea",
currentCourseName: "-",
currentTitle: "-",
progressPct: 0,
queueVideoTotal: 0,
queueVideoDone: 0,
logs: [],
secretSk: "",
secretSkEnc: "",
cloudApiBase: DEFAULT_CLOUD_API_BASE,
cloudToken: String(localStorage.getItem(CLOUD_TOKEN_KEY) || "").trim(),
cloudTier: String(localStorage.getItem(CLOUD_TOKEN_KEY) || "").trim()
? "unknown"
: "free",
cloudLease: "",
cloudLeaseExp: 0,
cloudLeaseLuid: "",
cloudProExpireAt: 0,
cloudRevoked: false,
freeVideoLimit: FREE_CHAPTER_LIMIT_DEFAULT,
freeUsedVideos: 0,
panelNoticePath: "/api/fxzx/panel-notice",
remotePanelNotice: "",
proBuyUrl: PRO_BUY_URL,
lastLoggedPctBucket: -1,
};
try {
state.logs = JSON.parse(localStorage.getItem(LOG_KEY) || "[]") || [];
} catch (_) {
state.logs = [];
}
const lastState = readCloudLastState();
if (lastState) {
let curLuid = "";
try {
const u = parseCookieJson("loginUser");
curLuid = String(u?.userAccount || "").trim();
} catch (_) {}
if (lastState.luid && curLuid && lastState.luid === curLuid) {
if (lastState.tier) state.cloudTier = lastState.tier;
if (lastState.freeVideoLimit > 0) state.freeVideoLimit = lastState.freeVideoLimit;
if (lastState.freeUsedVideos >= 0) state.freeUsedVideos = lastState.freeUsedVideos;
state.cloudLeaseLuid = curLuid;
state._authLuid = curLuid;
} else if (lastState.luid && curLuid && lastState.luid !== curLuid) {
try {
localStorage.removeItem(CLOUD_LAST_STATE_KEY);
localStorage.removeItem(CLOUD_LEASE_CACHE_KEY);
} catch (_) {}
}
}
function loadQueue() {
try {
const raw = JSON.parse(localStorage.getItem(QUEUE_KEY) || "[]");
return Array.isArray(raw) ? raw.map(String) : [];
} catch (_) {
return [];
}
}
function saveQueue(ids) {
const list = Array.isArray(ids) ? ids.map(String) : [];
state.selectedCourseIds = list;
try {
localStorage.setItem(QUEUE_KEY, JSON.stringify(list));
} catch (_) {}
}
function escapeHtml(s) {
return String(s || "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function log(msg) {
const line = `[${new Date().toLocaleTimeString()}] ${msg}`;
state.logs.unshift(line);
if (state.logs.length > 50) state.logs.length = 50;
try {
localStorage.setItem(LOG_KEY, JSON.stringify(state.logs.slice(0, 30)));
} catch (_) {}
state.lastAction = msg;
const el = document.getElementById("fx-run-log");
if (el) {
el.innerHTML = state.logs
.slice(0, 30)
.map((s) => `
${escapeHtml(s)}
`)
.join("");
}
}
function clearRunLog() {
state.logs = [];
try {
localStorage.removeItem(LOG_KEY);
} catch (_) {}
const el = document.getElementById("fx-run-log");
if (el) el.innerHTML = "";
}
function logProgress(title, pct) {
const bucket = Math.floor(pct / 10) * 10;
if (bucket === state.lastLoggedPctBucket && pct < 100) return;
if (pct < 100 && bucket === 0 && state.lastLoggedPctBucket < 0) {
state.lastLoggedPctBucket = 0;
return;
}
if (pct < 100 && bucket <= state.lastLoggedPctBucket) return;
state.lastLoggedPctBucket = bucket;
log(`\u5b66\u4e60\u4e2d\uff1a${title} ${Math.floor(pct)}%`);
}
function shouldLogCloudLine(line) {
const s = String(line || "").trim();
if (!s) return false;
if (/\u6548\u7387|\u500d\u901f|\u6253\u6ee1|\u7533\u62a5|\u9876\u683c|burst|tick|\u7ed3\u8bfe/i.test(s)) return false;
if (/^\u5b66\u4e60\u4e2d\uff1a\d+%$/.test(s)) return false;
if (s === "\u5df2\u5b8c\u6210" || s === "\u8003\u8bd5\u5b8c\u6210") return false;
if (/^\u8003\u8bd5\u5b8c\u6210/.test(s)) return false;
return true;
}
function readRawCookie(name) {
const parts = document.cookie.split("; ");
for (const p of parts) {
const i = p.indexOf("=");
if (i < 0) continue;
if (decodeURIComponent(p.slice(0, i)) === name) {
return decodeURIComponent(p.slice(i + 1));
}
}
return "";
}
function parseCookieJson(name) {
const raw = readRawCookie(name);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (_) {
try {
return JSON.parse(decodeURIComponent(raw));
} catch (_) {
return null;
}
}
}
function refreshUser() {
const u = parseCookieJson("loginUser");
const next =
u && u.userAccount && u.sid ? String(u.userAccount).trim() : "";
const prev = String(state.cloudLeaseLuid || state._authLuid || "").trim();
if (next && prev && next !== prev) {
invalidateCloudAuthOnAccountSwitch(prev, next);
}
if (u && u.userAccount && u.sid) {
state.user = u;
state._authLuid = next;
return u;
}
if (prev && !next) {
invalidateCloudAuthOnAccountSwitch(prev, "");
}
state.user = null;
state._authLuid = "";
return null;
}
function _glu() {
return String(state.user?.userAccount || refreshUser()?.userAccount || "").trim();
}
function invalidateCloudAuthOnAccountSwitch(prevLuid, nextLuid) {
state.cloudLease = "";
state.cloudLeaseExp = 0;
state.cloudLeaseLuid = "";
state.cloudTier = state.cloudToken ? "unknown" : "free";
state.cloudProExpireAt = 0;
state.cloudRevoked = false;
state.freeUsedVideos = 0;
state.freeVideoLimit = FREE_CHAPTER_LIMIT_DEFAULT;
state.stopFlag = true;
writeCloudLeaseCache("", 0);
try {
localStorage.removeItem(CLOUD_LAST_STATE_KEY);
} catch (_) {}
_ucu();
if (prevLuid || nextLuid) {
log(
nextLuid
? `\u8d26\u53f7\u5df2\u5207\u6362\uff0c\u6388\u6743\u5df2\u91cd\u7f6e`
: `\u8d26\u53f7\u5df2\u9000\u51fa\uff0c\u6388\u6743\u5df2\u91cd\u7f6e`
);
}
}
function _aca() {
const luid = _glu();
if (!luid) throw new Error("\u8bf7\u5148\u767b\u5f55");
if (state.cloudLeaseLuid && state.cloudLeaseLuid !== luid) {
invalidateCloudAuthOnAccountSwitch(state.cloudLeaseLuid, luid);
throw new Error("\u8d26\u53f7\u5df2\u5207\u6362\uff0c\u8bf7\u91cd\u65b0\u5f00\u59cb");
}
return luid;
}
async function _cas(op, texts) {
const list = (texts || []).map((x) => (x == null ? "" : String(x)));
if (!list.length) return [];
await _el(false);
const data = await _crq("/api/fxzx/crypto/seal", "POST", {
op: op === "decrypt" ? "decrypt" : "encrypt",
texts: list,
});
if (!data || data.ok === false) {
throw new Error(data?.message || data?.detail || "\u4e91\u7aef\u52a0\u89e3\u5bc6\u5931\u8d25");
}
if (op === "decrypt") {
const arr = data.plaintexts;
if (Array.isArray(arr) && arr.length === list.length) return arr.map(String);
if (data.plaintext != null && list.length === 1) return [String(data.plaintext)];
} else {
const arr = data.ciphertexts;
if (Array.isArray(arr) && arr.length === list.length) return arr.map(String);
if (data.ciphertext != null && list.length === 1) return [String(data.ciphertext)];
}
throw new Error("\u4e91\u7aef\u52a0\u89e3\u5bc6\u56de\u5305\u5f02\u5e38");
}
async function _cpk(items) {
const list = (items || []).filter((x) => x && typeof x === "object");
if (!list.length) return { fields: {}, ciphertexts: [], items: [] };
await _el(false);
const data = await _crq("/api/fxzx/crypto/pack", "POST", { items: list });
if (!data || data.ok === false) {
throw new Error(data?.message || data?.detail || "\u4e91\u7aef\u62fc\u88c5\u52a0\u5bc6\u5931\u8d25");
}
return {
fields: data.fields && typeof data.fields === "object" ? data.fields : {},
ciphertexts: Array.isArray(data.ciphertexts) ? data.ciphertexts : [],
items: Array.isArray(data.items) ? data.items : [],
};
}
async function _pf(kind, params, name) {
const packed = await _cpk([
Object.assign({ kind, name: name || kind }, params || {}),
]);
const key = name || kind;
const v = packed.fields[key] || packed.ciphertexts[0];
if (!v) throw new Error("\u4e91\u7aef pack \u65e0\u5bc6\u6587");
return String(v);
}
async function _ae(plain) {
if (plain == null || plain === "") return plain;
const [out] = await _cas("encrypt", [String(plain)]);
return out;
}
async function _aem(plains) {
const list = (plains || []).map((x) => (x == null ? "" : String(x)));
if (!list.length) return [];
const needIdx = [];
const need = [];
list.forEach((t, i) => {
if (t !== "") {
needIdx.push(i);
need.push(t);
}
});
if (!need.length) return list;
const sealed = await _cas("encrypt", need);
const out = list.slice();
needIdx.forEach((i, j) => {
out[i] = sealed[j];
});
return out;
}
async function _ad(b64) {
if (b64 == null || b64 === "") return "";
const [out] = await _cas("decrypt", [String(b64)]);
return out || "";
}
async function _wsk() {
if (!state.secretSk) return "";
if (state.secretSkEnc) return state.secretSkEnc;
state.secretSkEnc = await _ae(state.secretSk);
return state.secretSkEnc;
}
function _csk() {
state.secretSk = "";
state.secretSkEnc = "";
}
async function ensureCryptoJs() {
return true;
}
function gmRequest(method, url, body, headers) {
return new Promise((resolve, reject) => {
const h = Object.assign(
{ Accept: "application/json, text/plain, */*" },
headers || {}
);
const done = (status, text) => {
if (status >= 200 && status < 300) resolve({ status, text });
else reject(new Error(`HTTP ${status}: ${(text || "").slice(0, 160)}`));
};
if (typeof GM_xmlhttpRequest === "function") {
GM_xmlhttpRequest({
method,
url,
headers: h,
data: body,
withCredentials: true,
anonymous: false,
onload(res) {
done(res.status, res.responseText);
},
onerror() {
reject(new Error("\u7f51\u7edc\u9519\u8bef"));
},
});
return;
}
fetch(url, { method, headers: h, body, credentials: "include" })
.then(async (r) => {
const text = await r.text();
done(r.status, text);
})
.catch((e) => reject(e));
});
}
function gmRequestWithStatus(url, method, headers, data) {
return new Promise((resolve, reject) => {
const req = {
method: method || "GET",
url,
headers: headers || {},
timeout: 30000,
withCredentials: false,
responseType: "text",
onload: (res) => resolve({ status: res.status, text: res.responseText || "" }),
onerror: () => reject(new Error("\u7f51\u7edc\u9519\u8bef")),
ontimeout: () => reject(new Error("\u8bf7\u6c42\u8d85\u65f6")),
};
if (data != null) req.data = data;
if (typeof GM_xmlhttpRequest === "function") {
GM_xmlhttpRequest(req);
return;
}
const xhr = new XMLHttpRequest();
xhr.open(req.method, url, true);
xhr.timeout = req.timeout;
Object.keys(req.headers).forEach((k) => xhr.setRequestHeader(k, req.headers[k]));
xhr.onload = () => resolve({ status: xhr.status, text: xhr.responseText || "" });
xhr.onerror = () => reject(new Error("\u7f51\u7edc\u9519\u8bef"));
xhr.ontimeout = () => reject(new Error("\u8bf7\u6c42\u8d85\u65f6"));
xhr.send(data);
});
}
const FALLBACK_API_ORIGINS = [
"http://nmg.faxuanyun.com",
"https://nmg.faxuanyun.com",
"http://ah.faxuanyun.com",
"https://ah.faxuanyun.com",
"http://bj.faxuanyun.com",
"https://bj.faxuanyun.com",
"http://sx.faxuanyun.com",
"https://sx.faxuanyun.com",
"http://ha.faxuanyun.com",
"https://ha.faxuanyun.com",
"http://sc.faxuanyun.com",
"https://sc.faxuanyun.com",
"http://gd.faxuanyun.com",
"https://gd.faxuanyun.com",
];
let apiOrigin = ORIGIN;
function isLearningTenantHost(host) {
const h = String(host || "").toLowerCase();
return (
/(^|\.)faxuanyun\.com$/.test(h) ||
/(^|\.)faxuan\.net$/.test(h)
) && !/(^|\.)(www\.)?ifaxuan\.(com|net)$/.test(h);
}
function regionalApiCandidates() {
const out = [];
const push = (o) => {
const s = String(o || "").replace(/\/$/, "");
if (s && !out.includes(s)) out.push(s);
};
if (isLearningTenantHost(HOST)) {
push(ORIGIN);
if (ORIGIN.startsWith("https://")) push("http://" + HOST);
else if (ORIGIN.startsWith("http://")) push("https://" + HOST);
} else {
push(ORIGIN);
}
FALLBACK_API_ORIGINS.forEach(push);
return out;
}
function apiUrl(path) {
if (/^https?:\/\//i.test(path)) return path;
return String(apiOrigin).replace(/\/$/, "") + path;
}
async function probeApiOrigin() {
const candidates = regionalApiCandidates();
for (const origin of candidates) {
try {
const url = origin.replace(/\/$/, "") + "/bps/common/comm_resources.js";
const res = await gmRequest("GET", url, undefined, {});
if (res.text && res.text.indexOf("_rlp") >= 0) {
apiOrigin = origin;
if (origin !== ORIGIN) {
log("\u5df2\u5207\u6362\u5b66\u4e60\u7ad9\u70b9\uff1a" + origin.replace(/^https?:\/\//, ""));
}
return origin;
}
} catch (_) {}
}
apiOrigin = isLearningTenantHost(HOST) ? ORIGIN : candidates[0] || ORIGIN;
return apiOrigin;
}
async function apiForm(path, fields) {
const body = new URLSearchParams();
Object.keys(fields || {}).forEach((k) => {
if (fields[k] == null) return;
body.append(k, String(fields[k]));
});
const res = await gmRequest("POST", apiUrl(path), body.toString(), {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
});
const text = res.text || "";
const meta = {
_http: res.status,
_raw: text,
_bodyLen: text.length,
_path: String(path || ""),
};
if (!text) {
return Object.assign({ code: 200, msg: "", _empty: true }, meta);
}
try {
const j = JSON.parse(text);
if (j && typeof j === "object" && !Array.isArray(j)) {
return Object.assign({}, j, meta);
}
return Object.assign({ code: 200, data: j }, meta);
} catch (_) {
return Object.assign({ code: 200, raw: text }, meta);
}
}
async function apiGet(path) {
const res = await gmRequest("GET", apiUrl(path), undefined, {});
return res.text || "";
}
async function getSystemTime() {
try {
const j = await apiForm("/dsyun/service/getSystemTime", {});
let n = null;
if (j == null) n = null;
else if (typeof j === "number") n = j;
else if (j.data != null) n = Number(j.data);
else if (j.raw != null && String(j.raw).trim() !== "") n = Number(j.raw);
if (n != null && Number.isFinite(n) && n > 0) {
return n < 1e12 ? Math.floor(n * 1000) : Math.floor(n);
}
} catch (_) {}
return Date.now();
}
function getCloudApiBase() {
return String(state.cloudApiBase || DEFAULT_CLOUD_API_BASE).replace(/\/$/, "");
}
function resolveLeaseExpireSec(data) {
const raw = data?.exp ?? data?.expire_at ?? data?.expires_at ?? data?.lease_exp ?? 0;
const n = Number(raw || 0);
if (Number.isFinite(n) && n > 1e12) return Math.floor(n / 1000);
return Number.isFinite(n) ? Math.floor(n) : 0;
}
function resolveProExpireSec(data) {
const raw = data?.pro_expires_at ?? data?.proExpiresAt ?? data?.pro_expire_at ?? 0;
const n = Number(raw || 0);
if (Number.isFinite(n) && n > 1e12) return Math.floor(n / 1000);
return Number.isFinite(n) ? Math.floor(n) : 0;
}
function formatLeaseExpireText(expSec) {
const ex = Number(expSec || 0);
if (!Number.isFinite(ex) || ex <= 0) return "—";
const d = new Date(ex * 1000);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("zh-CN", { hour12: false });
}
function readCloudLeaseCache() {
try {
const raw = localStorage.getItem(CLOUD_LEASE_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return null;
const lease = String(parsed.lease || "").trim();
const exp = Number(parsed.exp || 0);
const luid = String(parsed.luid || parsed.learning_user_id || "").trim();
if (!lease || !Number.isFinite(exp) || exp <= 0) return null;
return {
lease,
exp,
luid,
tier: String(parsed.tier || "").trim(),
freeVideoLimit: Number(
parsed.freeVideoLimit ??
parsed.free_video_limit ??
parsed.free_chapter_limit ??
FREE_CHAPTER_LIMIT_DEFAULT
),
freeUsedVideos: Number(
parsed.freeUsedVideos ?? parsed.free_used_videos ?? 0
),
proExpireAt: Number(parsed.proExpireAt ?? parsed.pro_expire_at ?? 0),
};
} catch (_) {
return null;
}
}
function writeCloudLeaseCache(lease, exp, extra) {
if (!lease || !exp) {
localStorage.removeItem(CLOUD_LEASE_CACHE_KEY);
return;
}
localStorage.setItem(
CLOUD_LEASE_CACHE_KEY,
JSON.stringify(Object.assign({ lease, exp }, extra || {}))
);
}
function readCloudLastState() {
try {
const raw = localStorage.getItem(CLOUD_LAST_STATE_KEY);
if (!raw) return null;
const p = JSON.parse(raw);
if (!p || typeof p !== "object") return null;
return {
luid: String(p.luid || "").trim(),
tier: String(p.tier || "").trim(),
freeVideoLimit: Number(p.freeVideoLimit ?? p.free_video_limit ?? 1),
freeUsedVideos: Number(p.freeUsedVideos ?? p.free_used_videos ?? 0),
};
} catch (_) {
return null;
}
}
function writeCloudLastState(partial) {
try {
const prev = readCloudLastState() || {};
localStorage.setItem(
CLOUD_LAST_STATE_KEY,
JSON.stringify(
Object.assign({}, prev, partial || {}, { ts: Date.now() })
)
);
} catch (_) {}
}
function writeCloudProExpireCache(token, exp) {
try {
const tk = String(token || "").trim();
if (!tk || !exp) {
localStorage.removeItem(CLOUD_PRO_EXPIRE_CACHE_KEY);
return;
}
localStorage.setItem(
CLOUD_PRO_EXPIRE_CACHE_KEY,
JSON.stringify({ token: tk, exp: Math.floor(exp) })
);
} catch (_) {}
}
async function _crq(path, method, payload) {
const base = getCloudApiBase();
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
const headers = {
Accept: "application/json",
"Content-Type": "application/json",
};
const luid = _glu();
if (luid) headers["x-learning-user-id"] = luid;
if (state.cloudToken) headers.Authorization = `Bearer ${state.cloudToken}`;
let reqPayload = payload;
if (reqPayload && typeof reqPayload === "object") {
reqPayload = Object.assign({}, reqPayload);
if (path !== "/api/fxzx/lease" && state.cloudLease && reqPayload.lease == null) {
reqPayload.lease = state.cloudLease;
}
}
const body = reqPayload == null ? null : JSON.stringify(reqPayload);
const res = await gmRequestWithStatus(url, method || "POST", headers, body);
const text = String(res.text || "").trim();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch (_) {
data = { raw: text };
}
}
if (res.status < 200 || res.status >= 300) {
const msg = String(
data.detail || data.message || data.code || `http_${res.status}`
);
if (
(res.status === 401 || res.status === 403) &&
/lease_user_mismatch|learning_user_mismatch|token_bound/i.test(msg)
) {
invalidateCloudAuthOnAccountSwitch(
state.cloudLeaseLuid,
_glu()
);
}
throw new Error(msg);
}
return data;
}
async function _el(forceRefresh) {
const luid = _glu();
if (!luid) throw new Error("\u8bf7\u5148\u767b\u5f55");
const now = Math.floor(Date.now() / 1000);
if (
!forceRefresh &&
state.cloudLease &&
state.cloudLeaseLuid === luid &&
state.cloudLeaseExp - now > 60
) {
return true;
}
if (!forceRefresh) {
const cached = readCloudLeaseCache();
if (
cached &&
cached.luid === luid &&
cached.exp - now > 60
) {
state.cloudLease = cached.lease;
state.cloudLeaseExp = cached.exp;
state.cloudLeaseLuid = luid;
state.cloudProExpireAt = Number(cached.proExpireAt || 0);
if (cached.tier) state.cloudTier = cached.tier;
if (cached.freeVideoLimit > 0) state.freeVideoLimit = cached.freeVideoLimit;
if (cached.freeUsedVideos >= 0) {
state.freeUsedVideos = Math.max(
state.freeUsedVideos,
cached.freeUsedVideos
);
}
_ucu();
return true;
}
if (cached && cached.luid && cached.luid !== luid) {
writeCloudLeaseCache("", 0);
}
}
let data;
try {
data = await _crq("/api/fxzx/lease", "POST", {
learning_user_id: luid,
});
state.cloudRevoked = false;
} catch (e) {
const em = String(e?.message || e);
if (/(revoked|invalid token|expired|learning_user_mismatch|token_bound)/i.test(em)) {
state.cloudRevoked = true;
state.cloudTier = "revoked";
state.cloudLease = "";
state.cloudLeaseExp = 0;
state.cloudLeaseLuid = "";
writeCloudLeaseCache("", 0);
_ucu();
}
throw e;
}
const lease = String(data.lease || "");
const exp = resolveLeaseExpireSec(data);
let tier =
String(data.tier || "").trim() || (state.cloudToken ? "pro" : "free");
const proExpireAt = resolveProExpireSec(data);
state.cloudLease = lease;
state.cloudLeaseExp = exp;
state.cloudLeaseLuid = luid;
state.cloudProExpireAt = proExpireAt > 0 ? proExpireAt : 0;
state.cloudTier = tier;
state.freeVideoLimit = Number(
data.free_chapter_limit ??
data.free_video_limit ??
data.freeVideoLimit ??
state.freeVideoLimit ??
FREE_CHAPTER_LIMIT_DEFAULT
) || FREE_CHAPTER_LIMIT_DEFAULT;
const serverUsed = Number(
data.free_used_videos ?? data.free_used_chapters ?? data.freeUsedVideos ?? 0
);
state.freeUsedVideos = serverUsed;
writeCloudLeaseCache(lease, exp, {
luid,
tier: state.cloudTier,
freeVideoLimit: state.freeVideoLimit,
freeUsedVideos: state.freeUsedVideos,
proExpireAt: state.cloudProExpireAt,
});
writeCloudLastState({
luid,
tier: state.cloudTier,
freeVideoLimit: state.freeVideoLimit,
freeUsedVideos: state.freeUsedVideos,
});
if (tier === "pro" && state.cloudProExpireAt > 0) {
writeCloudProExpireCache(state.cloudToken, state.cloudProExpireAt);
}
_ucu();
return !!lease;
}
async function cloudVerifyToken() {
if (!state.cloudToken) return true;
const base = getCloudApiBase();
const url = `${base}/api/license/verify`;
const headers = {
Accept: "application/json",
Authorization: `Bearer ${state.cloudToken}`,
};
const luid = _glu();
if (luid) headers["x-learning-user-id"] = luid;
const res = await gmRequestWithStatus(url, "GET", headers, null);
const text = String(res.text || "").trim();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch (_) {}
}
if (res.status < 200 || res.status >= 300 || data.ok === false) {
throw new Error(String(data.message || data.detail || "\u6388\u6743\u65e0\u6548\u8bf7\u68c0\u67e5 Token"));
}
return true;
}
async function _ecr() {
refreshUser();
if (!state.user) {
log("\u8bf7\u5148\u767b\u5f55");
return false;
}
try {
if (state.cloudToken) await cloudVerifyToken();
await _el(false);
} catch (err) {
const msg = String(err?.message || err);
if (/token|\u6388\u6743|invalid|expired|revoked/i.test(msg)) {
log("\u6388\u6743\u65e0\u6548\u8bf7\u68c0\u67e5 Token");
} else {
log("\u4e91\u7aef\u6388\u6743\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5");
}
console.debug("[\u6cd5\u5ba3\u52a9\u624b] cloud error", err);
return false;
}
const tier = String(state.cloudTier || "").toLowerCase();
if (tier === "pro") return true;
if (tier === "free" || tier === "unknown") {
if (Number(state.freeUsedVideos) >= Number(state.freeVideoLimit)) {
log("\u514d\u8d39\u4f53\u9a8c\u5df2\u7528\u5b8c\uff0c\u8bf7\u5347\u7ea7 Pro");
switchPanelTab("settings");
openProModal();
return false;
}
return true;
}
log("\u6388\u6743\u65e0\u6548\u8bf7\u68c0\u67e5 Token");
return false;
}
function canTakeExam() {
return String(state.cloudTier || "").toLowerCase() === "pro";
}
function canStudyNextFreeVideo() {
if (state.cloudRevoked) return false;
const tier = String(state.cloudTier || "").toLowerCase();
if (tier === "pro") return true;
return Number(state.freeUsedVideos) < Number(state.freeVideoLimit);
}
function markFreeVideoUsed() {
const tier = String(state.cloudTier || "").toLowerCase();
if (tier === "pro") return;
state.freeUsedVideos = Number(state.freeUsedVideos || 0) + 1;
writeCloudLastState({
tier: state.cloudTier,
freeVideoLimit: state.freeVideoLimit,
freeUsedVideos: state.freeUsedVideos,
});
_ucu();
}
async function _fpn() {
try {
const origin = getCloudApiBase();
const path = String(state.panelNoticePath || "/api/fxzx/panel-notice");
const url = `${origin}${path.startsWith("/") ? path : `/${path}`}`;
const res = await gmRequestWithStatus(url, "GET", {}, null);
if (res.status >= 200 && res.status < 300) {
state.remotePanelNotice =
String(res.text || "").trim() || PANEL_NOTICE_FALLBACK;
const el = document.querySelector("#fx-ann-text");
if (el) el.textContent = state.remotePanelNotice;
}
} catch (_) {}
}
async function _fcc() {
try {
const data = await gmRequestWithStatus(
`${getCloudApiBase()}/api/fxzx/client-config`,
"GET",
{ Accept: "application/json" },
null
);
if (data.status >= 200 && data.status < 300 && data.text) {
const j = JSON.parse(data.text);
if (j?.panelNoticePath) state.panelNoticePath = String(j.panelNoticePath);
if (j?.freeVideoLimit != null)
state.freeVideoLimit =
Number(j.freeVideoLimit) || FREE_CHAPTER_LIMIT_DEFAULT;
if (j?.free_chapter_limit != null)
state.freeVideoLimit =
Number(j.free_chapter_limit) || FREE_CHAPTER_LIMIT_DEFAULT;
if (j?.proDays != null) state.proDays = Number(j.proDays) || PRO_DAYS;
if (j?.proBuyUrl)
state.proBuyUrl = String(j.proBuyUrl).trim() || PRO_BUY_URL;
if (j?.examFixedSeries != null) {
const n = Number(j.examFixedSeries);
if (Number.isFinite(n)) EXAM_FIXED_SERIES = Math.max(0, Math.min(499, Math.floor(n)));
}
if (j?.learnMaxJumpSec != null) {
const n = Number(j.learnMaxJumpSec);
if (Number.isFinite(n) && n > 0) LEARN_MAX_JUMP_SEC = Math.floor(n);
}
}
} catch (_) {}
await _fpn();
}
function getProBuyUrl() {
return String(state.proBuyUrl || PRO_BUY_URL).trim() || PRO_BUY_URL;
}
function openProBuyPage() {
const url = getProBuyUrl();
try {
GM_openInTab(url, { active: true, insert: true, setParent: true });
} catch (_) {
window.open(url, "_blank", "noopener,noreferrer");
}
}
function openProModal() {
const vm = document.getElementById("fx-pro-modal");
if (vm) vm.style.display = "flex";
}
function closeProModal() {
const vm = document.getElementById("fx-pro-modal");
if (vm) vm.style.display = "none";
}
function createProModal() {
const old = document.getElementById("fx-pro-modal");
if (old) old.remove();
const modal = document.createElement("div");
modal.id = "fx-pro-modal";
modal.innerHTML = `
\u5f00\u901a Pro
Pro \u6fc0\u6d3b\u540e ${PRO_DAYS} \u5929\u6709\u6548\uff0c\u8bfe\u7a0b\u4e0e\u8003\u8bd5\u4e0d\u9650
Pro \u6743\u76ca
- \u4e0d\u9650\u7ae0\u8282\u5b66\u4e60\uff08\u514d\u8d39\u4f53\u9a8c\u4ec5 ${FREE_CHAPTER_LIMIT_DEFAULT} \u4e2a\u7ae0\u8282\uff09
- \u652f\u6301\u81ea\u52a8\u8003\u8bd5\uff08\u514d\u8d39\u7528\u6237\u4e0d\u53ef\u8003\u8bd5\uff09
- \u6fc0\u6d3b\u540e ${PRO_DAYS} \u5929\u6709\u6548\uff0c\u7ed1\u5b9a\u5f53\u524d\u767b\u5f55\u8d26\u53f7
\u5f00\u901a\u65b9\u5f0f
1) \u52a0\u5165 QQ \u7fa4 ${QQ_GROUP_NUMBER}\uff0c\u8054\u7cfb\u7ba1\u7406\u5458\u83b7\u53d6 Token
2) \u524d\u5f80\u8d2d\u4e70\u9875\u83b7\u53d6 Token
\u8d2d\u5f97\u540e\uff1a\u5728\u8bbe\u7f6e\u9875\u7c98\u8d34 Token\uff0c\u70b9\u51fb\u300c\u4fdd\u5b58\u5e76\u6821\u9a8c\u300d\u3002
`;
document.documentElement.appendChild(modal);
modal.addEventListener("click", (e) => {
if (e.target === modal) closeProModal();
});
modal.querySelector("#fx-pro-close")?.addEventListener("click", closeProModal);
modal.querySelector("#fx-pro-buy-link")?.addEventListener("click", openProBuyPage);
}
function formatCloudTierText(tier) {
if (state.cloudRevoked) return "\u5df2\u7981\u7528";
const t = String(tier || "").trim().toLowerCase();
if (t === "pro") return "Pro \u4f1a\u5458";
if (t === "free") return "\u514d\u8d39\u4f53\u9a8c";
if (t === "revoked") return "\u5df2\u7981\u7528";
if (t === "unknown") return "\u672a\u6821\u9a8c";
return tier ? String(tier) : "\u672a\u6821\u9a8c";
}
function _ucu() {
const tierEl = document.querySelector("#fx-cloud-tier");
const freeEl = document.querySelector("#fx-cloud-free");
const freeLabelEl = document.querySelector("#fx-cloud-free-label");
const tokenInput = document.querySelector("#fx-cloud-token");
if (tierEl) {
tierEl.textContent = formatCloudTierText(state.cloudTier);
tierEl.style.color =
state.cloudRevoked ||
String(state.cloudTier || "").toLowerCase() === "revoked"
? "#dc2626"
: "#0f172a";
}
if (state.cloudRevoked) {
if (freeLabelEl) freeLabelEl.textContent = "\u6388\u6743\u72b6\u6001";
if (freeEl) freeEl.textContent = "Token \u5df2\u7981\u7528";
} else if (String(state.cloudTier || "").toLowerCase() === "pro") {
if (freeLabelEl) freeLabelEl.textContent = "Pro \u5230\u671f";
if (freeEl)
freeEl.textContent = formatLeaseExpireText(
state.cloudProExpireAt || state.cloudLeaseExp
);
} else {
if (freeLabelEl) freeLabelEl.textContent = "\u514d\u8d39\u4f53\u9a8c\u7ae0\u8282";
if (freeEl)
freeEl.textContent = `${state.freeUsedVideos}/${state.freeVideoLimit}`;
}
if (tokenInput && tokenInput !== document.activeElement) {
tokenInput.value = state.cloudToken || "";
}
}
function domainGrade(domainCode) {
const s = String(domainCode || "");
let g = 1;
for (let v = 1; v <= 5; v++) {
const slice = s.substr(0, v * 3);
if (slice && !/^0+$/.test(s.substr(v * 3))) g = v;
}
let n = 0;
for (let i = 0; i < s.length; i += 3) {
const part = s.substr(i, 3);
if (part && part !== "000") n = i / 3 + 1;
}
return Math.max(1, n || g);
}
function padDomain(prefix, totalLen) {
let s = String(prefix || "");
while (s.length < totalLen) s += "0";
return s.slice(0, totalLen);
}
function buildCourseFields(user, courseClassify) {
const e = Number(courseClassify) || 12;
const w = user.rankId || "";
const x = user.politicsCode || "";
const k = String(user.domainCode || "");
const b = domainGrade(k);
let a = "";
for (let v = 1; v <= b; v++) {
const u = padDomain(k.substr(0, v * 3), 15);
a += `STUDY:COURSE:STR.${e}_${u}_${w},`;
if (x) a += `STUDY:COURSE:STR.${e}_${u}_${x},`;
}
return a.replace(/,+$/, "");
}
async function getIndustryCodes(domainCode) {
try {
const res = await gmRequest(
"POST",
apiUrl("/bss/service/getIndustryCodes"),
new URLSearchParams({ domainCode: domainCode || "" }).toString(),
{ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }
);
const text = (res.text || "").trim();
if (!text) return "00000000";
try {
const j = JSON.parse(text);
if (typeof j === "string") return j;
if (j?.data != null) return String(j.data);
} catch (_) {
return text.replace(/^"|"$/g, "");
}
return text;
} catch (_) {}
return "00000000";
}
function parseCourseListPayload(text) {
const out = [];
const raw = String(text || "").trim();
if (!raw || raw === "null" || raw === "[]" || raw === "{}") return out;
const pushCourse = (item) => {
if (!item) return;
if (typeof item === "string") {
const s = item.trim();
if (!s || s === "{}" || s === "null") return;
try {
pushCourse(JSON.parse(s));
} catch (_) {}
return;
}
if (Array.isArray(item)) {
item.forEach(pushCourse);
return;
}
if (item.courseId != null || item.courseName) {
out.push({
courseId: String(item.courseId),
courseName: item.courseName || String(item.courseId),
courseWareCount: Number(item.courseWareCount || 0),
courseWarePeriod: item.courseWarePeriod || "",
courseClassify: Number(item.courseClassify || 0) || 0,
courseType: item.courseType,
raw: item,
});
return;
}
Object.keys(item).forEach((k) => pushCourse(item[k]));
};
try {
if (raw.indexOf('{"') === 0) {
const obj = JSON.parse(raw);
Object.keys(obj).forEach((k) => pushCourse(obj[k]));
} else if (raw.indexOf("[") === 0) {
const arr = JSON.parse(raw);
arr.forEach((row) => {
if (!row) return;
if (row.courseId != null) {
pushCourse(row);
return;
}
Object.keys(row).forEach((k) => pushCourse(row[k]));
});
} else {
pushCourse(JSON.parse(raw));
}
} catch (e) {
console.debug("[\u6cd5\u5ba3\u52a9\u624b] \u8bfe\u7a0b\u89e3\u6790\u5931\u8d25", e);
}
const seen = new Set();
return out.filter((c) => {
if (!c.courseId || seen.has(c.courseId)) return false;
seen.add(c.courseId);
return true;
});
}
function courseKindLabel(course) {
const c = Number(course?.courseClassify || course?.raw?.courseClassify || 0);
if (c === 12) return "\u5fc5\u4fee\u8bfe";
if (c === 11) return "\u9009\u4fee\u8bfe";
if (c === 14 || c === 16 || c === 19) return "\u884c\u653f\u6267\u6cd5";
if (c === 18) return "\u5b66\u6cd5\u521b\u4e1a";
if (c === 24) return "\u516b\u4e94\u666e\u6cd5";
if (c === 25) return "\u601d\u653f\u5b66\u521b";
return "";
}
function formatCoursePeriod(period) {
const n = Number(period);
if (!Number.isFinite(n) || n <= 0) return "";
const s = Number.isInteger(n) ? String(n) : String(n);
return `${s} \u8bfe\u65f6`;
}
function collectCoursesFromPage() {
const out = [];
const seen = new Set();
const push = (c, classifyHint) => {
if (!c || c.courseId == null) return;
const id = String(c.courseId);
if (seen.has(id)) return;
seen.add(id);
out.push({
courseId: id,
courseName: c.courseName || id,
courseWareCount: Number(c.courseWareCount || 0),
courseWarePeriod: c.courseWarePeriod || "",
courseClassify:
Number(c.courseClassify || classifyHint || 0) || Number(classifyHint || 0) || 0,
courseType: c.courseType,
raw: c,
});
};
try {
if (typeof bxArr !== "undefined" && Array.isArray(bxArr))
bxArr.forEach((c) => push(c, 12));
if (typeof xxArr !== "undefined" && Array.isArray(xxArr))
xxArr.forEach((c) => push(c, 11));
if (typeof xzzfArr !== "undefined" && Array.isArray(xzzfArr))
xzzfArr.forEach((c) => push(c, 14));
if (typeof xjxcyArr !== "undefined" && Array.isArray(xjxcyArr))
xjxcyArr.forEach((c) => push(c, 18));
if (typeof ssxcyArr !== "undefined" && Array.isArray(ssxcyArr))
ssxcyArr.forEach((c) => push(c, 25));
if (typeof eightArr !== "undefined" && Array.isArray(eightArr))
eightArr.forEach((c) => push(c, 24));
} catch (_) {}
return out;
}
async function fetchCoursesByClassify(user, industry, courseClassify) {
const fields = buildCourseFields(user, courseClassify);
const q = new URLSearchParams({
fileds: fields,
industryCodes: industry || "00000000",
languageType: "4",
time: String(new Date()),
});
const res = await gmRequest(
"POST",
apiUrl("/sss/service/getstudycoursebyindusrtycodenew?" + q.toString()),
"",
{ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }
);
const list = parseCourseListPayload(res.text);
const hint = Number(courseClassify) || 0;
if (!hint) return list;
return list.map((c) => ({
...c,
courseClassify: Number(c.courseClassify || hint) || hint,
}));
}
async function loadCourses() {
const user = refreshUser();
if (!user) throw new Error("\u8bf7\u5148\u767b\u5f55");
await probeApiOrigin();
let industry = await getIndustryCodes(user.domainCode);
industry = String(industry || "00000000").trim();
if (!industry.includes("00000000")) {
industry = industry.replace(/;?$/, "") + ";00000000";
}
let courses = [];
for (const classify of [12, 11]) {
try {
const list = await fetchCoursesByClassify(user, industry, classify);
courses = courses.concat(list);
} catch (e) {
console.debug("[\u6cd5\u5ba3\u52a9\u624b] classify fail", classify, e);
}
}
if (!courses.length) {
try {
courses = await fetchCoursesByClassify(user, "00000000", 12);
} catch (_) {}
}
if (!courses.length) {
const pageCourses = collectCoursesFromPage();
if (pageCourses.length) courses = pageCourses;
}
const seen = new Set();
state.courses = courses.filter((c) => {
if (seen.has(c.courseId)) return false;
seen.add(c.courseId);
return true;
});
const valid = new Set(state.courses.map((c) => c.courseId));
const q = loadQueue().filter((id) => valid.has(id));
saveQueue(q);
log(`\u5df2\u52a0\u8f7d\u8bfe\u7a0b ${state.courses.length} \u95e8`);
renderCourseList();
await refreshChapterPreview();
updateQueueSummary();
}
function parseWareList(text) {
const lines = String(text || "")
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const out = [];
for (const line of lines) {
if (/^[*$]/.test(line)) continue;
const parts = line.split("_");
if (parts.length < 5) continue;
const id = parts[0];
if (!/^\d+$/.test(id)) continue;
const dateOrLecturer = parts[parts.length - 1];
const hasDate = /^\d{4}-\d{2}-\d{2}/.test(dateOrLecturer);
const tail = hasDate ? 4 : 3;
if (parts.length < tail + 1) continue;
const type = Number(parts[parts.length - tail]) || 0;
const period = parts[parts.length - (tail - 1)] || "";
const title = parts.slice(1, parts.length - tail).join("_") || id;
out.push({
id: String(id),
title,
type,
period,
progress: 0,
finished: false,
});
}
return out;
}
async function loadWareProgress(courseId, force) {
const user = state.user;
if (!user) return {};
const cid = String(courseId || "");
const now = Date.now();
if (
!force &&
_periodProgCache.data &&
_periodProgCache.courseId === cid &&
now - _periodProgCache.at < PERIOD_PROGRESS_TTL_MS
) {
return _periodProgCache.data;
}
const skey = await _pf("period_skey", {
time: Date.now(),
userAccount: user.userAccount,
sid: user.sid,
courseId,
terminal: TERMINAL,
});
const j = await apiForm(
"/sss/service/coursewareService!getLearnPeriod.do",
{
userAccount: user.userAccount,
terminalCode: TERMINAL,
skey,
}
);
const data = j?.data || {};
_periodProgCache = { courseId: cid, at: Date.now(), data };
return data;
}
async function getLearnedSeconds(courseId, coursewareId) {
const user = state.user;
const skey = await _pf("progress_skey", {
userAccount: user.userAccount,
sid: user.sid,
courseId,
coursewareId,
time: Date.now(),
terminal: TERMINAL,
});
const j = await apiForm(
"/sss/service/coursewareService!doGetLearnProgress.do",
{
userAccount: user.userAccount,
terminalCode: TERMINAL,
skey,
}
);
if (j?.code != null && j.code !== 200) return null;
const sec = Number(j?.data);
return Number.isFinite(sec) ? Math.max(0, sec) : null;
}
async function _gsp(courseId, coursewareId, duration, opts) {
const needSec = !!(opts && opts.needSec);
const forcePeriod = !!(opts && opts.force);
let pctFromPeriod = null;
try {
const prog = await loadWareProgress(courseId, forcePeriod);
if (prog && prog[`${coursewareId}_progress`] != null) {
pctFromPeriod = Math.max(
0,
Math.min(100, Number(prog[`${coursewareId}_progress`]) || 0)
);
}
} catch (_) {}
let pctFromSec = null;
let learnedSec = null;
if (needSec) {
try {
learnedSec = await getLearnedSeconds(courseId, coursewareId);
const dur = Number(duration) || 0;
if (learnedSec != null && dur > 0) {
pctFromSec = Math.max(0, Math.min(100, (learnedSec / dur) * 100));
}
} catch (_) {}
}
if (pctFromPeriod == null && pctFromSec == null && learnedSec == null) {
return null;
}
const pct = Math.max(pctFromPeriod || 0, pctFromSec || 0);
return { pct, learnedSec, pctFromPeriod, pctFromSec };
}
function progressPctOf(sp) {
if (sp == null) return null;
if (typeof sp === "number") return sp;
if (typeof sp === "object" && typeof sp.pct === "number") return sp.pct;
return null;
}
async function loadUserCourseMeta(courseId) {
const user = state.user;
const finished = new Set();
const studied = new Set();
try {
const q = new URLSearchParams({
userAccount: user.userAccount,
ssid: user.sid,
time: String(new Date()),
});
const res = await gmRequest(
"POST",
apiUrl("/sss/service/getusercourseinfo?" + q.toString()),
"",
{ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }
);
const arr = JSON.parse(res.text);
if (Array.isArray(arr)) {
for (let i = 0; i + 1 < arr.length; i += 2) {
const k = String(arr[i]);
const v = String(arr[i + 1] || "");
if (k === `${courseId}_finished` && v) {
v.split(",").forEach((id) => id && finished.add(String(id).trim()));
}
if (k === String(courseId) && v) {
v.split(",").forEach((id) => id && studied.add(String(id).trim()));
}
}
}
} catch (e) {
console.debug("[\u6cd5\u5ba3\u52a9\u624b] getusercourseinfo", e);
}
return { finished, studied };
}
async function loadWares(courseId) {
const text = await apiGet(
`/sss/service/getcourseware?courseId=${encodeURIComponent(courseId)}&time=${encodeURIComponent(String(new Date()))}`
);
const wares = parseWareList(text);
let prog = {};
try {
prog = await loadWareProgress(courseId);
} catch (e) {
console.debug("[\u6cd5\u5ba3\u52a9\u624b] progress", e);
}
const meta = await loadUserCourseMeta(courseId);
for (const w of wares) {
const p = prog[`${w.id}_progress`];
if (p != null) w.progress = Number(p) || 0;
const finFlag = prog[`${w.id}_finished`];
if (
meta.finished.has(w.id) ||
w.progress >= 100 ||
finFlag == 1 ||
finFlag === "1" ||
finFlag === true
) {
w.finished = true;
w.progress = Math.max(w.progress, 100);
}
}
state.waresByCourse[courseId] = wares;
return wares;
}
async function getWareDetail(coursewareId) {
const text = await apiGet(
`/sss/service/getcoursecontent?coursewareId=${encodeURIComponent(coursewareId)}&time=${encodeURIComponent(String(new Date()))}`
);
const m = text.match(/\{[\s\S]*\}/);
if (!m) return null;
try {
return JSON.parse(m[0]);
} catch (_) {
return null;
}
}
async function doComeStudy(courseId, coursewareId) {
const user = state.user;
const skey = await _pf("begin_skey", {
time: Date.now(),
userAccount: user.userAccount,
sid: user.sid,
courseId,
coursewareId,
terminal: TERMINAL,
});
return apiForm("/sss/service/coursewareService!doBeginLeaningCheck.do", {
userAccount: user.userAccount,
terminalCode: TERMINAL,
skey,
time: Date.parse(new Date()),
});
}
async function _rsl(stuckCourseId, stuckWareId) {
const prevSk = state.secretSk;
const prevSkEnc = state.secretSkEnc;
try {
log(`\u9000\u51fa\u672a\u5b8c\u6210\u8bfe\u4ef6\u540e\u7ee7\u7eed`);
await doComeStudy(stuckCourseId, stuckWareId);
try {
await initStudyExam(stuckCourseId, stuckWareId);
} catch (_) {}
let end = await endStudy(stuckCourseId, stuckWareId, 0);
if (Number(end?.code) !== 200) {
await sleep(400);
end = await endStudy(stuckCourseId, stuckWareId, 0);
}
if (Number(end?.code) !== 200) {
await sleep(400);
end = await endStudy(stuckCourseId, stuckWareId, 1);
}
return Number(end?.code) === 200;
} finally {
state.secretSk = prevSk || "";
state.secretSkEnc = prevSkEnc || "";
}
}
async function initStudyExam(courseId, coursewareId) {
const user = state.user;
const t = await getSystemTime();
const secondaryKey = await _pf("init_study_secondary", {
time: t,
userAccount: user.userAccount,
sid: user.sid,
courseId,
coursewareId,
terminal: TERMINAL,
});
const j = await apiForm("/sss/service/studyExamService!doInitStudyExam.do", {
userAccount: user.userAccount,
terminalCode: TERMINAL,
type: 2,
secondaryKey,
time: t,
});
if (j?.code !== 200 || !j?.data?.secretKey) {
throw new Error(j?.msg || "\u521d\u59cb\u5316\u5b66\u4e60\u5931\u8d25");
}
state.secretSk = j.data.secretKey;
state.secretSkEnc = "";
return j.data.secretKey;
}
async function _qls(courseId, coursewareId) {
const user = state.user;
const skey = await _pf("progress_skey", {
userAccount: user.userAccount,
sid: user.sid,
courseId,
coursewareId,
time: Date.now(),
terminal: TERMINAL,
});
const j = await apiForm(
"/sss/service/coursewareService!doGetLearnProgress.do",
{
userAccount: user.userAccount,
terminalCode: TERMINAL,
skey,
}
);
let sec = null;
if (j?.code == null || j.code === 200) {
const n = Number(j?.data);
if (Number.isFinite(n)) sec = Math.max(0, n);
}
return { sec, raw: j };
}
async function _rlp(courseId, coursewareId, learnedSec, isFinished) {
const user = state.user;
const sec = Math.max(0, Math.floor(Number(learnedSec) || 0));
const packed = await _cpk([
{
kind: "learn_key",
name: "learnKey",
time: Date.now(),
sid: user.sid,
userAccount: user.userAccount,
terminal: TERMINAL,
courseId,
coursewareId,
sec,
},
]);
const j = await apiForm("/sss/service/coursewareService!doResetLearnProgress.do", {
userAccount: user.userAccount,
terminalCode: TERMINAL,
secretKey: await _wsk(),
learnKey: packed.fields.learnKey,
isFinished: isFinished ? 1 : 0,
source: 1,
});
invalidatePeriodProgressCache(courseId);
return j;
}
async function _rlt(courseId, coursewareId, targetSec, duration, wantFinish) {
const dur = Math.max(30, Math.floor(Number(duration) || 30));
const target = Math.max(0, Math.min(dur, Math.floor(Number(targetSec) || 0)));
let q0 = await _qls(courseId, coursewareId);
let cur = q0.sec != null ? q0.sec : 0;
let lastReset = null;
let lastQuery = q0;
let jumps = 0;
while (cur < target - 1 && jumps < 80) {
const next = Math.min(target, cur + LEARN_MAX_JUMP_SEC);
const fin = !!(wantFinish && next >= target - 1);
lastReset = await _rlp(courseId, coursewareId, next, fin ? 1 : 0);
jumps += 1;
await sleep(400);
lastQuery = await _qls(courseId, coursewareId);
const got = lastQuery.sec != null ? lastQuery.sec : cur;
if (got <= cur + 1 && next > cur + 5) {
const small = Math.min(target, cur + 30);
lastReset = await _rlp(courseId, coursewareId, small, 0);
await sleep(500);
lastQuery = await _qls(courseId, coursewareId);
const got2 = lastQuery.sec != null ? lastQuery.sec : cur;
if (got2 <= cur + 1) {
return {
ok: false,
before: q0.sec,
after: got2,
target,
jumps,
lastReset,
lastQuery,
};
}
cur = got2;
} else {
cur = Math.max(got, cur);
}
if (state.stopFlag) break;
}
const ok = cur >= target - 3 || cur >= dur - 3;
return {
ok,
before: q0.sec,
after: cur,
target,
jumps,
lastReset,
lastQuery,
};
}
async function endStudy(courseId, coursewareId, isFinished) {
const user = state.user;
const t = await getSystemTime();
const packed = await _cpk([
{
kind: "end_secondary",
name: "secondaryKey",
time: t,
userAccount: user.userAccount,
terminal: TERMINAL,
domainCode: user.domainCode,
sid: user.sid,
courseId,
coursewareId,
isFinished: isFinished ? 1 : 0,
},
{
kind: "end_rkey",
name: "rkey",
coursewareId,
isFinished: isFinished ? 1 : 0,
},
]);
return apiForm("/sss/service/coursewareService!endStudy.do", {
userAccount: user.userAccount,
terminalCode: TERMINAL,
secretKey: await _wsk(),
secondaryKey: packed.fields.secondaryKey,
rkey: packed.fields.rkey,
timestamp: Date.parse(new Date()),
});
}
function parseJsonLoose(text) {
const raw = String(text || "").trim();
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (_) {}
try {
return JSON.parse("[" + raw + "]");
} catch (_) {}
return null;
}
async function _lme() {
const user = state.user;
if (!user) return [];
let industry = "00000000";
try {
industry = await getIndustryCodes(user.domainCode);
} catch (_) {}
const yearTypes = [1, 0];
const seen = new Set();
const all = [];
for (const yearType of yearTypes) {
try {
const j = await apiForm("/ess/service/myexam/myExamAo!doGetPcExamList.do", {
domainCode: user.domainCode,
rank: user.rankId,
userAccount: user.userAccount,
yearType,
industryCodes: industry || "00000000",
politicsCode: user.politicsCode || "",
});
const arr = Array.isArray(j) ? j : Array.isArray(j?.data) ? j.data : [];
for (const ex of arr) {
if (!ex) continue;
const id = String(ex.id || ex.examId || "");
if (!id || seen.has(id)) continue;
seen.add(id);
all.push(ex);
}
} catch (_) {}
}
let now = 0;
try {
const sample = all.find((e) => Number(e?.sysTime) > 1e11);
if (sample) now = Number(sample.sysTime);
} catch (_) {}
if (!now) {
try {
const withSy = all.find((e) => e?.syTime);
const t = parseExamTimeMs(withSy?.syTime);
if (t) now = t;
} catch (_) {}
}
if (!now) {
try {
const st = await getSystemTime();
if (st && Number(st) > 0) now = Number(st);
} catch (_) {}
}
if (!now) now = Date.now();
const withRemain = all.filter((ex) => examHasRemainAttempts(ex));
const out = withRemain
.filter((ex) => isExamInTimeWindow(ex, now))
.sort((a, b) => {
const ea = parseExamTimeMs(a.examEndTime) || 0;
const eb = parseExamTimeMs(b.examEndTime) || 0;
if (eb !== ea) return eb - ea;
return Number(b.id || 0) - Number(a.id || 0);
});
state._examListMeta = {
total: all.length,
remain: withRemain.length,
open: out.length,
picked: out.length,
now: new Date(now).toISOString(),
};
if (!out.length && withRemain.length) {
state._examListHint = "\u6709\u6b21\u6570\u4f46\u90fd\u4e0d\u5728\u5f00\u653e\u65f6\u95f4\u5185";
} else if (!out.length && all.length) {
state._examListHint = "\u8003\u8bd5\u6b21\u6570\u5df2\u7528\u5b8c\u6216\u5df2\u53ca\u683c";
} else if (!out.length) {
state._examListHint = "\u63a5\u53e3\u672a\u8fd4\u56de\u8003\u8bd5";
} else {
state._examListHint = "";
}
console.debug("[\u6cd5\u5ba3\u52a9\u624b] exams", state._examListMeta, out.slice(0, 5).map((e) => ({
id: e.id,
name: e.examName,
join: e.examJoinNum,
commit: e.examCommitNum,
begin: e.examBeginTime,
end: e.examEndTime,
isPass: e.isPass,
score: e.examResultScore,
})));
return out;
}
function examHasRemainAttempts(ex) {
try {
const score = Number(ex?.examResultScore);
const pass = Number(ex?.examPassScore);
const passLine = Number.isFinite(pass) && pass > 0 ? pass : 60;
if (Number.isFinite(score) && score >= passLine) return false;
} catch (_) {}
try {
const join = Number(ex?.examJoinNum || 0);
const commit = Number(ex?.examCommitNum || 0);
if (commit > 0 && join >= commit) return false;
} catch (_) {}
return true;
}
function isExamInTimeWindow(ex, nowMs) {
const now = Number(nowMs) || Date.now();
const begin = parseExamTimeMs(ex?.examBeginTime);
const end = parseExamTimeMs(ex?.examEndTime);
if (end != null && now > end) return false;
if (begin != null && now < begin) return false;
if (begin == null && end == null) return true;
return true;
}
function parseExamTimeMs(raw) {
const s = String(raw || "").trim();
if (!s) return null;
if (/^\d{13}$/.test(s)) return Number(s);
if (/^\d{10}$/.test(s)) return Number(s) * 1000;
const norm = s
.replace(/T/, " ")
.replace(/-/g, "/")
.replace(/\.\d+/, "")
.replace(/Z$/, "");
let t = Date.parse(norm);
if (Number.isFinite(t)) return t;
t = Date.parse(s);
return Number.isFinite(t) ? t : null;
}
async function getMyPoint() {
return "5.00";
}
function _nea(q, raw) {
let ans = raw == null ? "" : String(raw).trim();
if (!ans) return "";
const t = String(q?.type || "");
if (t === "3") {
if (ans === "1" || ans === "\u5bf9" || /^true$/i.test(ans) || /^A$/i.test(ans))
return "1";
if (ans === "0" || ans === "\u9519" || /^false$/i.test(ans) || /^B$/i.test(ans))
return "0";
return ans;
}
if (t === "2") {
return ans.replace(/[,;/\s]/g, "").toUpperCase();
}
return ans.replace(/;/g, "");
}
async function fetchPaperAnswers(paperId, series, timeHints) {
const user = state.user;
const hints = (timeHints || []).map((x) => String(x ?? "").trim()).filter(Boolean);
hints.push(String(Date.now()), String(await getSystemTime()));
const tried = new Set();
for (const timeStr of hints) {
if (tried.has(timeStr)) continue;
tried.add(timeStr);
try {
const secretKey = await _pf("paper_answer_secret", {
userAccount: user.userAccount,
time: timeStr,
paperId,
series,
terminal: TERMINAL,
});
const j = await apiForm(
"/sss/service/coursewareService!getPaperAnswer.do",
{
secretKey,
userAccount: user.userAccount,
terminalCode: TERMINAL,
time: Date.parse(new Date()),
}
);
const enc = j?.data?.paperAnswer || j?.paperAnswer;
if (!enc || j?.code !== 200) continue;
const plain = await _ad(enc);
if (!plain) continue;
const bracket = plain.lastIndexOf("[");
const jsonStr = bracket >= 0 ? plain.slice(bracket) : plain;
let arr = [];
try {
arr = JSON.parse(jsonStr);
} catch (_) {
continue;
}
if (!Array.isArray(arr) || !arr.length) continue;
const parts = plain.split(";");
let n0 = 0;
let n1 = 0;
if (parts.length >= 3) {
n0 = Number(parts[parts.length - 3]) || 0;
n1 = Number(parts[parts.length - 2]) || 0;
}
const map = {};
for (const row of arr) {
if (!row) continue;
const qid = Number(row.questionId) + n1 - n0;
if (!Number.isFinite(qid)) continue;
map[String(qid)] = row.answerNo;
map[String(row.questionId)] = row.answerNo;
}
if (Object.keys(map).length) return map;
} catch (_) {}
}
return {};
}
async function loadPaperSkeleton(paperId, series) {
const text = await apiGet(
`/ess/service/getpapernew?paperId=${encodeURIComponent(
paperId
)}&series=${encodeURIComponent(series)}&dc=${Date.now()}`
);
const arr = parseJsonLoose(text);
return Array.isArray(arr) ? arr : [];
}
async function loadExamQuestions(questionIds, paperId, series) {
const user = state.user;
const ids = (questionIds || []).map(String).filter(Boolean);
if (!ids.length) return [];
const out = [];
const chunk = 40;
for (let i = 0; i < ids.length; i += chunk) {
const part = ids.slice(i, i + chunk);
const qs = [
`questionId=${encodeURIComponent(part.join(","))}`,
`userAccount=${encodeURIComponent(user.userAccount)}`,
`ssid=${encodeURIComponent(user.sid)}`,
`paperID=${encodeURIComponent(paperId)}`,
`series=${encodeURIComponent(series)}`,
`timestamp=${encodeURIComponent(String(new Date()))}`,
].join("&");
const text = await apiGet(`/ess/service/getexerinfo?${qs}`);
const parsed = parseJsonLoose(text);
if (Array.isArray(parsed)) {
for (const item of parsed) {
if (typeof item === "string") {
try {
out.push(JSON.parse(item));
} catch (_) {}
} else if (item && typeof item === "object") {
out.push(item);
}
}
}
}
return out;
}
async function initExamSession(examId, paperId, series) {
const user = state.user;
const t = await getSystemTime();
const token = `${examId}_${paperId}_${series}`;
const secondaryKey = await _pf("exam_init_secondary", {
time: t,
userAccount: user.userAccount,
sid: user.sid,
token,
examId,
paperId,
series,
terminal: TERMINAL,
});
const j = await apiForm("/sss/service/studyExamService!doInitStudyExam.do", {
userAccount: user.userAccount,
terminalCode: TERMINAL,
type: 1,
secondaryKey,
time: t,
});
if (j?.code !== 200 || !j?.data?.secretKey) {
throw new Error(j?.msg || j?.message || "\u521d\u59cb\u5316\u8003\u8bd5\u5931\u8d25");
}
const cipher = String(j.data.secretKey);
let flagA = 0;
let flagB = 0;
try {
const plain = await _ad(cipher);
const parts = String(plain || "").split(";");
flagA = Number(parts[3]);
flagB = Number(parts[5]);
} catch (_) {}
return { cipher, flagA, flagB, time: t, token };
}
function resolveExamSeries(exam) {
const paperType = Number(exam?.examPaperType || 0);
if (paperType === 1) {
const n = Number(EXAM_FIXED_SERIES);
return Number.isFinite(n) ? Math.max(0, Math.min(499, Math.floor(n))) : 316;
}
return 0;
}
function extractStdAnswersFromCommit(commit) {
const map = {};
const data = commit?.data;
if (!Array.isArray(data)) return map;
for (const row of data) {
if (!row || typeof row !== "object") continue;
const qid = String(row.questionID || row.questionId || "");
const ans = row.answer != null ? String(row.answer).trim() : "";
if (qid && ans !== "") map[qid] = ans;
}
return map;
}
function scoreFromCommit(commit) {
if (!commit) return "";
if (commit?.data?.score != null) return commit.data.score;
if (commit?.data?.examScore != null) return commit.data.examScore;
if (commit?.score != null) return commit.score;
const msg = String(commit?.message || commit?.msg || "");
const m = msg.match(/(\d+(?:\.\d+)?)\s*$/);
if (m) return m[1];
return msg || "";
}
async function enterExam(examId) {
const user = state.user;
return apiForm(
`/ess/service/myexam/myExamAo!doComeExam.do?userAccount_before=${encodeURIComponent(
user.userAccount
)}`,
{
examId,
userAccount: user.userAccount,
myPoint: await getMyPoint(),
}
);
}
async function _cep(examId, paperId, series, userAnswer) {
const user = state.user;
const init = await initExamSession(examId, paperId, series);
const t = Date.now();
const packed = await _cpk([
{ kind: "wrap", name: "secretKey", text: init.cipher },
{
kind: "exam_commit_secondary",
name: "secondaryKey",
time: t,
sid: user.sid,
userAccount: user.userAccount,
terminal: TERMINAL,
examId,
paperId,
series,
},
{ kind: "wrap", name: "userAnswer", text: JSON.stringify(userAnswer) },
{ kind: "vcode_null", name: "vCodes" },
]);
return apiForm("/ess/service/myexam/myExamAo!doCommitExamNew.do", {
userAccount: user.userAccount,
terminalCode: TERMINAL,
secretKey: packed.fields.secretKey,
secondaryKey: packed.fields.secondaryKey,
userAnswer: packed.fields.userAnswer,
vCodes: packed.fields.vCodes,
timestamp: Date.parse(new Date()),
});
}
async function _cem(examId, paperId, series, skeleton, answerMap) {
const user = state.user;
const init = await initExamSession(examId, paperId, series);
const t = Date.now();
const packed = await _cpk([
{ kind: "wrap", name: "secretKey", text: init.cipher },
{
kind: "exam_commit_secondary",
name: "secondaryKey",
time: t,
sid: user.sid,
userAccount: user.userAccount,
terminal: TERMINAL,
examId,
paperId,
series,
},
{
kind: "exam_user_answer",
name: "userAnswer",
skeleton: skeleton || [],
answers: answerMap || {},
},
{ kind: "vcode_null", name: "vCodes" },
]);
return apiForm("/ess/service/myexam/myExamAo!doCommitExamNew.do", {
userAccount: user.userAccount,
terminalCode: TERMINAL,
secretKey: packed.fields.secretKey,
secondaryKey: packed.fields.secondaryKey,
userAnswer: packed.fields.userAnswer,
vCodes: packed.fields.vCodes,
timestamp: Date.parse(new Date()),
});
}
function buildUserAnswerList(skeleton, answerMap) {
return (skeleton || []).map((q) => {
const qid = String(q.questionId);
const raw = answerMap ? answerMap[qid] : "";
return {
questionId: Number(q.questionId) || q.questionId,
answerNo: _nea(q, raw),
};
});
}
async function _toe(exam) {
const user = state.user;
const examId = String(exam?.id || exam?.examId || "");
const paperId = String(exam?.examPaperId || "");
const examName = String(exam?.examName || examId);
if (!examId || !paperId) return { ok: false, msg: "\u8003\u8bd5\u4fe1\u606f\u4e0d\u5b8c\u6574" };
const series = resolveExamSeries(exam);
const join = Number(exam?.examJoinNum || 0);
const commitLimit = Number(exam?.examCommitNum || 0);
const remain =
commitLimit > 0 ? Math.max(0, commitLimit - join) : 99;
if (remain <= 0) {
return { ok: false, msg: "\u8003\u8bd5\u6b21\u6570\u5df2\u7528\u5b8c", examId, examName };
}
try {
let come = await enterExam(examId);
if (Number(come?.code) !== 200) {
return { ok: false, msg: come?.message || come?.msg || "\u65e0\u6cd5\u8fdb\u5165\u8003\u8bd5", examId, examName };
}
const skeleton = await loadPaperSkeleton(paperId, series);
if (!skeleton.length) return { ok: false, msg: "\u8bd5\u5377\u4e3a\u7a7a", examId, examName };
const qids = skeleton.map((q) => String(q.questionId)).filter(Boolean);
try {
await loadExamQuestions(qids, paperId, series);
} catch (_) {}
let answerMap = await fetchPaperAnswers(paperId, series, [
exam?.sysTime,
Date.now(),
]);
let filled = Object.keys(answerMap || {}).length;
if (filled < Math.ceil(skeleton.length * 0.5)) {
if (remain < 2) {
return {
ok: false,
msg: "\u5269\u4f59\u8003\u8bd5\u6b21\u6570\u4e0d\u8db3\u4e24\u8f6e\uff0c\u65e0\u6cd5\u63a2\u7b54\u6848\u540e\u91cd\u4ea4",
examId,
examName,
};
}
const probeCommit = await _cem(
examId,
paperId,
series,
skeleton,
{}
);
if (Number(probeCommit?.code) !== 200) {
return {
ok: false,
msg: probeCommit?.message || probeCommit?.msg || "\u63a2\u5377\u4ea4\u5377\u5931\u8d25",
examId,
examName,
};
}
answerMap = extractStdAnswersFromCommit(probeCommit);
filled = Object.keys(answerMap).length;
if (!filled) {
return {
ok: false,
msg: "\u4ea4\u5377\u7ed3\u679c\u672a\u8fd4\u56de\u6807\u51c6\u7b54\u6848",
examId,
examName,
};
}
await sleep(800);
come = await enterExam(examId);
if (Number(come?.code) !== 200) {
return {
ok: false,
msg: come?.message || come?.msg || "\u4e8c\u6b21\u8fdb\u5165\u8003\u8bd5\u5931\u8d25",
examId,
examName,
probed: true,
};
}
const skeleton2 = await loadPaperSkeleton(paperId, series);
if (!skeleton2.length) {
return { ok: false, msg: "\u4e8c\u6b21\u62c9\u5377\u5931\u8d25", examId, examName, probed: true };
}
try {
await loadExamQuestions(
skeleton2.map((q) => String(q.questionId)).filter(Boolean),
paperId,
series
);
} catch (_) {}
const commit = await _cem(
examId,
paperId,
series,
skeleton2,
answerMap
);
const ok = Number(commit?.code) === 200;
return {
ok,
score: scoreFromCommit(commit),
msg: commit?.message || commit?.msg || (ok ? "\u8003\u8bd5\u5b8c\u6210" : "\u4ea4\u5377\u5931\u8d25"),
examId,
examName,
series,
pass: 2,
};
}
const commit = await _cem(
examId,
paperId,
series,
skeleton,
answerMap
);
const ok = Number(commit?.code) === 200;
return {
ok,
score: scoreFromCommit(commit),
msg: commit?.message || commit?.msg || (ok ? "\u8003\u8bd5\u5b8c\u6210" : "\u4ea4\u5377\u5931\u8d25"),
examId,
examName,
series,
pass: 1,
};
} catch (e) {
return { ok: false, msg: e?.message || "\u8003\u8bd5\u5931\u8d25", examId, examName };
}
}
function syncCloudQuotaFromResponse(data) {
if (!data || typeof data !== "object") return;
if (data.tier) state.cloudTier = String(data.tier);
const lim =
data.free_chapter_limit ?? data.free_video_limit ?? data.freeVideoLimit ?? data.limit;
const used =
data.free_used_chapters ?? data.free_used_videos ?? data.freeUsedVideos ?? data.used;
if (lim != null) state.freeVideoLimit = Number(lim) || FREE_CHAPTER_LIMIT_DEFAULT;
if (used != null) state.freeUsedVideos = Number(used);
writeCloudLastState({
tier: state.cloudTier,
freeVideoLimit: state.freeVideoLimit,
freeUsedVideos: state.freeUsedVideos,
});
_ucu();
}
async function _fes(context, cfg, kind) {
_aca();
await _el(false);
const data = await _crq("/api/fxzx/study/engine/start", "POST", {
kind: String(kind || "video"),
context: context || {},
config: cfg || {},
});
syncCloudQuotaFromResponse(data);
return data;
}
async function _fep(sessionId, event, lastResult, contextPatch) {
_aca();
await _el(false);
const data = await _crq("/api/fxzx/study/engine/step", "POST", {
session_id: String(sessionId || ""),
event: String(event || "tick"),
last_result: lastResult == null ? null : lastResult,
context_patch: contextPatch == null ? null : contextPatch,
});
syncCloudQuotaFromResponse(data);
return data;
}
async function _xfc(cmd, ctx) {
const c = cmd || {};
const t = String(c.type || "");
const courseId = ctx.courseId;
const ware = ctx.ware;
const duration = Math.max(30, Number(ctx.duration) || Number(c.duration) || 600);
if (t === "wait") {
await sleep(Math.max(300, Number(c.ms || 1000)));
return { event: "tick", lastResult: null };
}
if (t === "report_progress" || t === "fx_report") {
const sec = Math.max(
0,
Math.floor(Number(c.learned_sec ?? c.learnedSec ?? 0))
);
const fin = !!(c.is_finished ?? c.isFinished);
const rep = await _rlt(
courseId,
ware.id,
sec,
duration,
fin
);
const afterSec = Number(rep.after) || 0;
const serverPct = Math.min(100, (afterSec / duration) * 100);
const claimPct = Math.min(100, (sec / duration) * 100);
if (!rep.ok) {
log(`\u8fdb\u5ea6\u4e0a\u62a5\u672a\u751f\u6548\uff0c\u5df2\u8df3\u8fc7\u672c\u8bfe\u4ef6`);
}
const showPct = Math.max(claimPct, serverPct);
ware.progress = Math.floor(showPct);
updateProgressUi(showPct);
renderChapterPreview();
logProgress(ware.title, claimPct);
return {
event: "report_result",
lastResult: {
learned_sec: afterSec,
pct: serverPct,
claimed_sec: sec,
duration,
report_ok: !!rep.ok,
},
};
}
if (t === "end_study" || t === "fx_finish") {
const endSec = Math.max(
Math.floor(Number(c.learned_sec ?? c.learnedSec ?? duration - 1)),
Math.floor(duration) - 1,
1
);
const rep = await _rlt(
courseId,
ware.id,
Math.floor(duration),
duration,
true
);
await sleep(200);
let end = await endStudy(courseId, ware.id, 1);
if (
end?.code === 200 &&
!(end.isFinished == 1 || end.isFinished === "1")
) {
await _rlp(courseId, ware.id, Math.floor(duration), 1);
await sleep(400);
end = await endStudy(courseId, ware.id, 1);
}
let done =
end?.code === 200 &&
(end.isFinished == 1 || end.isFinished === "1");
if (!done && end?.code === 200) {
try {
const meta = await loadUserCourseMeta(courseId);
if (meta.finished.has(String(ware.id))) done = true;
} catch (_) {}
try {
const prog = await loadWareProgress(courseId);
if (
prog &&
(prog[`${ware.id}_finished`] == 1 ||
prog[`${ware.id}_finished`] === "1" ||
Number(prog[`${ware.id}_progress`]) >= 99)
) {
done = true;
}
} catch (_) {}
}
if (!done) {
const em = String(end?.msg || "\u9000\u51fa\u5b66\u4e60\u5931\u8d25")
.replace(/
/gi, " ")
.replace(/<[^>]+>/g, "");
log(em || "\u9000\u51fa\u5b66\u4e60\u5931\u8d25");
}
return {
terminal: true,
ok: done,
done,
skipped: false,
msg: done ? end?.msg || "\u5df2\u5b8c\u6210" : "",
lastResult: {
code: end?.code,
isFinished: end?.isFinished,
done,
msg: end?.msg || "",
},
};
}
if (t === "done") {
return {
terminal: true,
ok: !!c.success,
skipped: !!c.skipped,
done: !!c.success,
msg: c.message || "",
};
}
if (t === "failed" || t === "quota") {
return {
terminal: true,
ok: false,
quota: t === "quota",
msg: c.message || "failed",
};
}
if (t === "exam_list") {
const exams = await _lme();
const sanitized = (exams || []).map((ex) => {
const o = { ...(ex || {}) };
try {
const score = Number(o.examResultScore);
const pass = Number(o.examPassScore);
const line = Number.isFinite(pass) && pass > 0 ? pass : 60;
if (Number.isFinite(score) && score < line) o.isPass = 0;
} catch (_) {}
return o;
});
if (!sanitized.length) {
log(state._examListHint || "\u6682\u65e0\u5f00\u653e\u4e2d\u7684\u8003\u8bd5");
const meta = state._examListMeta;
if (meta && meta.total > 0) {
log(
`\u5217\u8868 ${meta.total} \u573a\uff0c\u6709\u6b21\u6570 ${meta.remain} \u573a\uff0c\u5f00\u653e\u4e2d ${meta.open} \u573a`
);
}
} else {
log(`\u5f85\u8003 ${sanitized.length} \u573a`);
}
return { event: "exam_list_result", lastResult: { exams: sanitized } };
}
if (t === "exam_take") {
const exam = c.exam || {};
const name = String(exam.examName || exam.id || "");
const r = await _toe(exam);
if (r?.ok) {
const score =
r.score != null && String(r.score) !== "" ? ` ${r.score}` : "";
const pass = r.pass === 2 ? "\uff08\u4e24\u8f6e\uff09" : "";
log(`\u8003\u8bd5\u5b8c\u6210\uff1a${name || ""}${score}${pass}`.trim());
} else if (r?.msg) {
const key = String(r.msg);
state._examFailLog = state._examFailLog || {};
if (!state._examFailLog[key]) {
state._examFailLog[key] = 1;
log(key);
} else {
state._examFailLog[key] += 1;
}
}
return { event: "exam_take_result", lastResult: r };
}
await sleep(400);
return { event: "tick", lastResult: null };
}
async function _fec(sessionId, result) {
_aca();
await _el(false);
const data = await _crq("/api/fxzx/study/engine/complete", "POST", {
session_id: String(sessionId || ""),
success: !!(result && (result.ok || result.done || result.success)),
skipped: !!(result && result.skipped),
});
syncCloudQuotaFromResponse(data);
return data;
}
async function _rcp(sessionId, plan, logLine, ctx, reportComplete) {
if (shouldLogCloudLine(logLine)) log(String(logLine).trim());
const cmds = (plan || []).filter((c) => c && typeof c === "object");
for (let i = 0; i < cmds.length && !state.stopFlag; i++) {
_aca();
const out = await _xfc(cmds[i], ctx);
if (out.terminal !== undefined) {
if (
reportComplete &&
(out.ok || out.done) &&
!out.skipped &&
!out.quota &&
!out.stopped
) {
try {
await _fec(sessionId, out);
} catch (_) {}
}
return out;
}
if (i === cmds.length - 1 && out.event) {
const next = await _fep(
sessionId,
out.event,
out.lastResult,
null
);
if (shouldLogCloudLine(next.log)) log(String(next.log).trim());
const nextPlan = Array.isArray(next.plan)
? next.plan.filter((c) => c && typeof c === "object")
: [];
if (nextPlan.length) {
return _rcp(
String(next.session_id || sessionId),
nextPlan,
null,
ctx,
reportComplete
);
}
if (next.command) {
return _rcp(
String(next.session_id || sessionId),
[next.command],
null,
ctx,
reportComplete
);
}
}
}
if (state.stopFlag) return { terminal: true, ok: false, stopped: true };
return { terminal: true, ok: false, msg: "\u5b66\u4e60\u672a\u5b8c\u6210" };
}
async function _rfl(startRes, ctx) {
const sessionId = String(startRes.session_id || "");
const plan = Array.isArray(startRes.plan)
? startRes.plan.filter((c) => c && typeof c === "object")
: [];
const reportComplete = String(ctx?.kind || "") !== "exam";
if (plan.length) {
return _rcp(
sessionId,
plan,
startRes.log,
ctx,
reportComplete
);
}
let cmd = startRes.command;
if (shouldLogCloudLine(startRes.log)) log(String(startRes.log).trim());
const maxSteps = 80;
for (let i = 0; i < maxSteps && cmd && !state.stopFlag; i++) {
const out = await _xfc(cmd, ctx);
if (out.terminal !== undefined) {
if (
reportComplete &&
(out.ok || out.done) &&
!out.skipped &&
!out.quota
) {
try {
await _fec(sessionId, out);
} catch (_) {}
}
return out;
}
const next = await _fep(
sessionId,
out.event,
out.lastResult,
null
);
if (shouldLogCloudLine(next.log)) log(String(next.log).trim());
const nextPlan = Array.isArray(next.plan)
? next.plan.filter((c) => c && typeof c === "object")
: [];
if (nextPlan.length) {
return _rcp(
String(next.session_id || sessionId),
nextPlan,
null,
ctx,
reportComplete
);
}
cmd = next.command;
}
if (state.stopFlag) return { terminal: true, ok: false, stopped: true };
return { terminal: true, ok: false, msg: "\u5b66\u4e60\u672a\u5b8c\u6210" };
}
async function _swc(courseId, ware, duration, learnedSec) {
const startRes = await _fes(
{
course_id: courseId,
courseware_id: ware.id,
title: ware.title,
duration,
learned_sec: learnedSec,
progress: Math.min(100, (learnedSec / duration) * 100),
learning_user_id: _glu(),
allow_exam: canTakeExam(),
},
{
allow_exam: canTakeExam(),
},
"video"
);
const startCmd = startRes.command || {};
if (String(startCmd.type || "") === "done" && startCmd.skipped) {
return { skipped: true, done: true };
}
if (String(startCmd.type || "") === "quota") {
return { skipped: false, done: false, quota: true, msg: startCmd.message };
}
const loop = await _rfl(startRes, { courseId, ware, duration });
if (loop.stopped) return { skipped: false, done: false, stopped: true };
if (loop.quota) return { skipped: false, done: false, quota: true, msg: loop.msg };
if (loop.skipped) return { skipped: true, done: true };
if (loop.ok || loop.done) return { skipped: false, done: true };
if (loop.lastResult && loop.lastResult.done) return { skipped: false, done: true };
return { skipped: false, done: !!loop.ok, msg: loop.msg };
}
async function _rec() {
if (!canTakeExam()) return { skipped: true };
const startRes = await _fes(
{
learning_user_id: _glu(),
allow_exam: true,
},
{ allow_exam: true, pro_days: PRO_DAYS },
"exam"
);
const startCmd = startRes.command || {};
if (String(startCmd.type || "") === "quota") {
log(startCmd.message || "\u514d\u8d39\u7528\u6237\u4e0d\u53ef\u8003\u8bd5");
return { skipped: true, quota: true };
}
if (String(startCmd.type || "") === "done" && startCmd.skipped) {
if (startCmd.message) log(String(startCmd.message));
return { skipped: true, done: true };
}
return _rfl(startRes, { kind: "exam" });
}
async function studyOneWare(courseId, ware) {
const title = ware.title;
state.currentTitle = title;
state.lastLoggedPctBucket = -1;
syncCurrentUi();
let serverPct = Math.max(0, Number(ware.progress) || 0);
let learnedSec = 0;
if (serverPct >= 100 || ware.finished) {
ware.finished = true;
ware.progress = 100;
log(`\u8df3\u8fc7\u5df2\u5b8c\u6210\uff1a${title}`);
renderChapterPreview();
updateQueueSummary();
return { skipped: true, done: true };
}
try {
const sp = await _gsp(courseId, ware.id, null, {
needSec: false,
});
const p = progressPctOf(sp);
if (typeof p === "number") serverPct = Math.max(serverPct, p);
} catch (_) {}
if (serverPct >= 100) {
ware.finished = true;
ware.progress = 100;
log(`\u8df3\u8fc7\u5df2\u5b8c\u6210\uff1a${title}`);
renderChapterPreview();
updateQueueSummary();
return { skipped: true, done: true };
}
if (!canStudyNextFreeVideo()) {
log("\u514d\u8d39\u4f53\u9a8c\u5df2\u7528\u5b8c\uff0c\u8bf7\u5347\u7ea7 Pro");
switchPanelTab("settings");
openProModal();
return { skipped: false, done: false, quota: true };
}
const come = await doComeStudy(courseId, ware.id);
if (come?.code === 301) {
const stuckId = String(come?.data?.coursewareId || "");
const stuckCourse = String(come?.data?.courseId || courseId);
if (stuckId && stuckId !== String(ware.id)) {
const okLeave = await _rsl(stuckCourse, stuckId);
const again = await doComeStudy(courseId, ware.id);
if (again?.code === 301) {
const s2 = String(again?.data?.coursewareId || "");
if (s2 && s2 !== String(ware.id)) {
throw new Error(
okLeave
? "\u65e7\u8bfe\u4ef6\u4ecd\u5360\u7528\uff0c\u8bf7\u624b\u52a8\u9000\u51fa\u540e\u518d\u5b66"
: "\u65e0\u6cd5\u9000\u51fa\u65e7\u8bfe\u4ef6\uff0c\u8bf7\u624b\u52a8\u9000\u51fa\u540e\u518d\u5b66"
);
}
} else if (again?.code && again.code !== 200) {
throw new Error(
String(again.msg || "")
.replace(/
/gi, " ")
.replace(/<[^>]+>/g, "") || "\u5f00\u59cb\u5b66\u4e60\u6821\u9a8c\u5931\u8d25"
);
}
}
} else if (come?.code && come.code !== 200) {
throw new Error(
String(come.msg || "")
.replace(/
/gi, " ")
.replace(/<[^>]+>/g, "") || "\u5f00\u59cb\u5b66\u4e60\u6821\u9a8c\u5931\u8d25"
);
}
await initStudyExam(courseId, ware.id);
let detail = null;
try {
detail = await getWareDetail(ware.id);
} catch (_) {}
const duration = Math.max(30, Number(detail?.courseDuration || 600));
try {
const sec = await getLearnedSeconds(courseId, ware.id);
if (typeof sec === "number") learnedSec = sec;
} catch (_) {}
if (learnedSec <= 0 && serverPct > 0) {
learnedSec = Math.floor((serverPct / 100) * duration);
}
const cloudOut = await _swc(
courseId,
ware,
duration,
learnedSec
);
if (cloudOut.quota) {
log(cloudOut.msg || "\u514d\u8d39\u4f53\u9a8c\u5df2\u7528\u5b8c\uff0c\u8bf7\u5347\u7ea7 Pro");
switchPanelTab("settings");
openProModal();
return { skipped: false, done: false, quota: true };
}
if (cloudOut.stopped) return { skipped: false, done: false, stopped: true };
if (cloudOut.skipped) {
ware.finished = true;
ware.progress = 100;
log(`\u8df3\u8fc7\u5df2\u5b8c\u6210\uff1a${title}`);
renderChapterPreview();
updateQueueSummary();
return cloudOut;
}
if (cloudOut.done) {
ware.finished = true;
ware.progress = 100;
log(`\u5df2\u5b8c\u6210\uff1a${title}`);
markFreeVideoUsed();
renderChapterPreview();
updateQueueSummary();
return cloudOut;
}
if (cloudOut.msg) log(cloudOut.msg);
else log("\u4e91\u7aef\u7f16\u6392\u672a\u5b8c\u6210");
renderChapterPreview();
updateQueueSummary();
return { skipped: false, done: false, msg: cloudOut.msg };
}
async function runStudy() {
if (state.running) return;
state.running = true;
state.stopFlag = false;
syncRunButtons();
setStatus("\u5b66\u4e60\u4e2d");
try {
await ensureCryptoJs();
refreshUser();
if (!state.user) {
log("\u8bf7\u5148\u767b\u5f55");
return;
}
if (!(await _ecr())) return;
if (!state.courses.length) await loadCourses();
const queue = loadQueue();
if (!queue.length) {
log("\u8bf7\u5148\u52fe\u9009\u8bfe\u7a0b");
return;
}
log("\u5f00\u59cb\u5b66\u4e60");
for (const courseId of queue) {
if (state.stopFlag) break;
const course = state.courses.find((c) => c.courseId === courseId);
state.currentCourseName = course?.courseName || courseId;
syncCurrentUi();
const wares = await loadWares(courseId);
renderChapterPreview();
updateQueueSummary();
const pending = wares.filter(
(w) => !w.finished && Number(w.progress) < 100
);
for (const w of pending) {
if (state.stopFlag) break;
if (!canStudyNextFreeVideo()) {
log("\u514d\u8d39\u4f53\u9a8c\u5df2\u7528\u5b8c\uff0c\u8bf7\u5347\u7ea7 Pro");
switchPanelTab("settings");
openProModal();
state.stopFlag = true;
break;
}
const result = await studyOneWare(courseId, w);
updateQueueSummary();
if (result?.quota || result?.stopped) break;
}
}
if (!state.stopFlag && canTakeExam()) {
await _rec();
}
if (state.stopFlag) log("\u5df2\u505c\u6b62");
else log("\u5168\u90e8\u5b8c\u6210");
} catch (e) {
log("\u8fd0\u884c\u51fa\u9519");
console.debug("[\u6cd5\u5ba3\u52a9\u624b] runStudy", e);
} finally {
state.running = false;
setStatus("\u5df2\u505c\u6b62");
syncRunButtons();
}
}
function stopStudy() {
state.stopFlag = true;
log("\u5df2\u505c\u6b62");
setStatus("\u6b63\u5728\u505c\u6b62…");
syncRunButtons();
}
function setStatus(text) {
const el = document.getElementById("fx-auto-status");
if (el) el.textContent = text;
}
function updateProgressUi(pct) {
state.progressPct = Math.max(0, Math.min(100, Number(pct) || 0));
const num = document.getElementById("fx-progress-num");
if (num) num.innerHTML = `${Math.floor(state.progressPct)}%`;
}
function syncCurrentUi() {
const a = document.getElementById("fx-cur-course");
const b = document.getElementById("fx-cur-doc");
if (a) a.textContent = state.currentCourseName || "-";
if (b) b.textContent = state.currentTitle || "-";
}
function syncRunButtons() {
const start = document.getElementById("fx-btn-start");
const stop = document.getElementById("fx-btn-stop");
if (start) {
start.disabled = state.running;
start.classList.toggle("fx-btn-off", state.running);
}
if (stop) {
stop.disabled = !state.running;
stop.classList.toggle("fx-btn-off", !state.running);
}
}
function switchPanelTab(name) {
document.querySelectorAll(".fx-tab-btn").forEach((el) => {
el.classList.toggle("active", el.dataset.tab === name);
});
document.querySelectorAll(".fx-pane").forEach((el) => {
el.classList.toggle("active", el.dataset.pane === name);
});
}
function updateQueueSummary() {
let total = 0;
let done = 0;
const q = loadQueue();
for (const id of q) {
const wares = state.waresByCourse[id];
if (!wares) continue;
total += wares.length;
done += wares.filter((w) => w.finished || w.progress >= 100).length;
}
state.queueVideoTotal = total;
state.queueVideoDone = done;
const doneEl = document.getElementById("fx-queue-done");
const totalEl = document.getElementById("fx-queue-total");
const pctEl = document.getElementById("fx-queue-percent");
const barEl = document.getElementById("fx-queue-progress");
const textEl = document.getElementById("fx-queue-text");
if (doneEl) doneEl.textContent = String(done);
if (totalEl) totalEl.textContent = String(total);
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
if (pctEl) pctEl.textContent = `${pct}%`;
if (barEl) barEl.style.width = `${pct}%`;
if (textEl) {
if (!q.length) textEl.textContent = "\u672a\u9009\u62e9";
else if (total > 0)
textEl.textContent = `\u89c6\u9891\u5df2\u5b66\u4e60 ${done}/${total}\uff08\u5df2\u9009 ${q.length} \u95e8\uff09`;
else textEl.textContent = `\u5df2\u9009 ${q.length} \u95e8`;
}
}
function courseHasUnfinished(courseId) {
const wares = state.waresByCourse[courseId];
if (!wares || !wares.length) return true;
return wares.some((w) => !w.finished && Number(w.progress) < 100);
}
async function selectUnfinishedCourses() {
refreshUser();
if (!state.user) {
log("\u8bf7\u5148\u767b\u5f55");
return;
}
if (!state.courses.length) await loadCourses();
log("\u6b63\u5728\u7b5b\u9009\u672a\u5b8c\u6210\u8bfe\u7a0b…");
const ids = [];
for (const c of state.courses) {
if (state.stopFlag) break;
try {
if (!state.waresByCourse[c.courseId]) await loadWares(c.courseId);
} catch (e) {
console.debug(e);
continue;
}
if (courseHasUnfinished(c.courseId)) ids.push(c.courseId);
}
saveQueue(ids);
renderCourseList();
await refreshChapterPreview();
updateQueueSummary();
log(ids.length ? `\u5df2\u52fe\u9009\u672a\u5b8c\u6210 ${ids.length} \u95e8` : "\u6ca1\u6709\u672a\u5b8c\u6210\u8bfe\u7a0b");
}
function renderCourseList() {
const box = document.getElementById("fx-course-list");
if (!box) return;
const selected = new Set(loadQueue());
if (!state.courses.length) {
box.innerHTML = `\u767b\u5f55\u540e\u5c06\u81ea\u52a8\u52a0\u8f7d\u8bfe\u7a0b
`;
return;
}
box.innerHTML = state.courses
.map((c) => {
const checked = selected.has(c.courseId) ? "checked" : "";
const wares = state.waresByCourse[c.courseId];
let pct = 0;
if (wares && wares.length) {
const d = wares.filter((w) => w.finished || w.progress >= 100).length;
pct = Math.round((d / wares.length) * 100);
}
const kind = courseKindLabel(c);
const period = formatCoursePeriod(c.courseWarePeriod);
const tags = [];
if (kind) {
const kindClass =
kind === "\u5fc5\u4fee\u8bfe"
? "fx-tag-required"
: kind === "\u9009\u4fee\u8bfe"
? "fx-tag-elective"
: "fx-tag-other";
tags.push(
`${escapeHtml(kind)}`
);
}
if (period) {
tags.push(
`${escapeHtml(period)}`
);
}
const metaBits = [`${c.courseWareCount || "?"} \u8bfe\u4ef6`];
if (wares) metaBits.push(`${pct}%`);
return `
`;
})
.join("");
box.querySelectorAll("input[type=checkbox]").forEach((el) => {
el.addEventListener("change", () => {
const ids = [];
box.querySelectorAll("input[type=checkbox]").forEach((cb) => {
if (cb.checked && cb.dataset.cid) ids.push(cb.dataset.cid);
});
saveQueue(ids);
void refreshChapterPreview();
updateQueueSummary();
});
});
}
function renderChapterPreview() {
const box = document.getElementById("fx-chapter-preview");
if (!box) return;
const q = loadQueue();
if (!q.length) {
box.innerHTML = `\u8bf7\u52fe\u9009\u8bfe\u7a0b
`;
return;
}
const parts = [];
for (const id of q) {
const course = state.courses.find((c) => c.courseId === id);
const wares = state.waresByCourse[id] || [];
const kind = courseKindLabel(course);
const period = formatCoursePeriod(course?.courseWarePeriod);
const headBits = [];
if (kind) headBits.push(kind);
if (period) headBits.push(period);
const headExtra = headBits.length
? ` · ${escapeHtml(headBits.join(" · "))}`
: "";
parts.push(
`${escapeHtml(
course?.courseName || id
)}${headExtra}
`
);
if (!wares.length) {
parts.push(`
\u52a0\u8f7d\u4e2d…
`);
} else {
for (const w of wares) {
const tag =
w.finished || w.progress >= 100
? "\u5b8c\u6210"
: `${Math.floor(w.progress || 0)}%`;
parts.push(
`
${escapeHtml(
w.title
)}${escapeHtml(tag)}
`
);
}
}
parts.push(`
`);
}
box.innerHTML = parts.join("");
}
async function refreshChapterPreview() {
const q = loadQueue();
for (const id of q) {
if (state.stopFlag && state.running) break;
try {
if (!state.waresByCourse[id]) await loadWares(id);
} catch (e) {
console.debug("[\u6cd5\u5ba3\u52a9\u624b] loadWares", id, e);
}
}
renderChapterPreview();
updateQueueSummary();
renderCourseList();
}
function injectPanelStyles() {
if (document.getElementById("fx-panel-style")) return;
const st = document.createElement("style");
st.id = "fx-panel-style";
st.textContent = `
#fx-auto-panel{position:fixed;right:20px;top:80px;z-index:999999;width:412px;display:flex;flex-direction:column;background:#f4f7fb;border:1px solid #dbe4f0;border-radius:16px;box-shadow:0 16px 40px rgba(15,23,42,.17);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;font-size:12px;color:#0f172a;overflow:hidden;max-height:min(92vh,780px);}
#fx-auto-panel.fx-min #fx-panel-body,#fx-auto-panel.fx-min #fx-panel-footer,#fx-auto-panel.fx-min .fx-footer-extra{display:none !important;}
#fx-panel-header{padding:8px 11px;background:linear-gradient(180deg,#f8ecd5,#f4e8cf);border-bottom:1px solid #e5dbc6;display:flex;justify-content:space-between;align-items:center;cursor:move;user-select:none;}
#fx-panel-brand{display:flex;align-items:center;gap:9px;min-width:0;flex:1;}
#fx-panel-logo{width:30px;height:30px;border-radius:9px;object-fit:cover;border:1px solid rgba(148,163,184,.45);background:#fff;}
#fx-panel-title{font-size:13px;font-weight:900;color:#9a3412;line-height:1.26;display:flex;align-items:center;gap:5px;flex-wrap:wrap;}
.fx-panel-version{font-size:11px;font-weight:900;color:#64748b;padding:2px 7px;border-radius:999px;background:#f1f5f9;border:1px solid #e2e8f0;}
#fx-panel-sub{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px;}
.fx-sub-chip{font-size:11px;color:#7c2d12;background:rgba(255,255,255,.62);padding:2px 7px;border-radius:999px;border:1px solid rgba(180,83,9,.18);font-weight:700;}
.fx-sub-chip-em{color:#9a3412;border-color:rgba(154,52,18,.28);background:rgba(255,247,237,.9);}
.fx-ctl{border:none;background:#fff;color:#64748b;width:28px;height:28px;border-radius:999px;cursor:pointer;box-shadow:0 1px 2px rgba(15,23,42,.1);font-weight:900;}
#fx-panel-body{flex:1;overflow-y:auto;padding:8px;min-height:0;}
.fx-card{background:#fff;border:1px solid #d9e2ee;border-radius:12px;padding:7px 9px;margin-bottom:7px;}
.fx-status-row{display:flex;justify-content:space-between;align-items:center;gap:7px;}
.fx-status-label{font-size:11px;color:#64748b;font-weight:700;}
#fx-auto-status{padding:2px 7px;border-radius:999px;font-weight:900;font-size:11px;background:#fff;border:1px solid #cbd5e1;}
.fx-status-metrics{display:flex;align-items:center;gap:5px;font-size:12px;color:#475569;flex:1;margin-top:4px;}
.fx-status-metrics em{font-style:normal;font-weight:900;color:#0f172a;}
.fx-progress-pct{font-size:14px;font-weight:900;color:#0369a1;}
.fx-progress-bar{height:5px;border-radius:999px;background:#e2e8f0;overflow:hidden;margin-top:4px;}
.fx-progress-bar>span{display:block;height:100%;width:0;background:linear-gradient(90deg,#22d3ee,#2563eb);transition:width .2s;}
.fx-tabbar{display:flex;gap:6px;margin-bottom:7px;}
.fx-tab-btn{flex:1;border:1px solid #cbd5e1;background:#f8fafc;color:#475569;padding:4px;border-radius:9px;cursor:pointer;font-weight:700;font-size:11px;}
.fx-tab-btn.active{background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;border-color:transparent;}
.fx-pane{display:none;}.fx-pane.active{display:block;}
.fx-list-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;gap:6px;}
.fx-list-head-actions{display:flex;align-items:center;gap:5px;flex-shrink:0;}
.fx-list-title{font-size:12px;color:#64748b;font-weight:700;}
.fx-list-tag{font-size:11px;color:#92400e;background:#ffedd5;border:1px solid #fdba74;border-radius:999px;padding:2px 7px;}
.fx-log-clear-btn{border:1px solid #cbd5e1;background:#fff;color:#64748b;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;cursor:pointer;}
#fx-course-list,#fx-chapter-preview,#fx-run-log{max-height:188px;overflow-y:auto;background:#f8fafc;border:1px solid #dbe4f0;border-radius:11px;padding:5px;}
.fx-course-item{display:flex;gap:7px;align-items:flex-start;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:7px;margin-bottom:6px;cursor:pointer;}
.fx-course-name{display:block;font-size:12px;color:#0f172a;line-height:1.38;}
.fx-course-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px;}
.fx-course-tag{font-style:normal;font-size:10px;font-weight:800;padding:1px 6px;border-radius:999px;border:1px solid transparent;line-height:1.5;}
.fx-tag-required{color:#9a3412;background:#ffedd5;border-color:#fdba74;}
.fx-tag-elective{color:#1d4ed8;background:#dbeafe;border-color:#93c5fd;}
.fx-tag-other{color:#334155;background:#e2e8f0;border-color:#cbd5e1;}
.fx-tag-period{color:#065f46;background:#d1fae5;border-color:#6ee7b7;}
.fx-course-meta{display:block;font-size:11px;color:#64748b;margin-top:2px;}
.fx-chapter-course{margin-bottom:7px;border:1px solid #dbe4f0;border-radius:9px;background:#fff;}
.fx-chapter-title{padding:5px 8px;background:#eaf1ff;border-bottom:1px solid #dbe4f0;color:#1d4ed8;font-size:11px;font-weight:700;}
.fx-chapter-item{padding:4px 8px;font-size:11px;line-height:1.35;display:flex;justify-content:space-between;gap:6px;}
.fx-chapter-item span:first-child{flex:1;min-width:0;word-break:break-all;}
.fx-chapter-item em{font-style:normal;color:#0369a1;font-weight:800;font-size:10px;flex-shrink:0;}
.fx-log-row{padding:4px 6px;border-bottom:1px dashed #d4deea;font-size:12px;line-height:1.42;}
.fx-empty-state{padding:12px 7px;text-align:center;color:#94a3b8;font-size:12px;font-weight:900;}
.fx-meta-row{display:flex;justify-content:space-between;gap:7px;font-size:12px;margin-bottom:5px;align-items:center;}
.fx-meta-label{color:#64748b;}.fx-meta-value{font-weight:700;text-align:right;word-break:break-all;}
.fx-auth-badge{padding:2px 7px;border-radius:999px;border:1px solid #cbd5e1;font-size:11px;font-weight:900;}
.fx-btn-row{display:flex;gap:7px;}
.fx-btn{flex:1;border:none;color:#fff;padding:7px 9px;border-radius:10px;cursor:pointer;font-weight:800;font-size:12px;}
.fx-btn-start{background:#16a34a;}.fx-btn-stop{background:#ef4444;}.fx-btn-ghost{border:1px solid #cbd5e1;background:#fff;color:#0f172a;flex:0 0 auto;}
.fx-btn-off,.fx-btn:disabled{background:#cbd5e1 !important;color:#64748b !important;cursor:not-allowed;}
.fx-btn-pro{flex:0 0 auto;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;border:none;padding:4px 8px;font-size:12px;border-radius:10px;cursor:pointer;font-weight:800;}
.fx-hidden{display:none !important;}
.fx-footer-extra{padding:6px 9px;background:#f8fafc;border-top:1px solid #e2e8f0;}
.fx-ann{position:relative;font-size:12px;color:#334155;line-height:1.42;background:linear-gradient(180deg,#fffdf5,#fff7e6);border:1px solid #fcd34d;border-radius:11px;padding:8px 9px 8px 11px;}
.fx-ann::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:linear-gradient(180deg,#f59e0b,#ef4444);border-radius:11px 0 0 11px;}
.fx-qq-row{display:flex;align-items:center;justify-content:space-between;gap:9px;margin-top:5px;}
.fx-qq-text{font-size:12px;color:#475569;}
.fx-qq-title{font-size:13px;font-weight:800;color:#0f172a;}
.fx-qq-btn{border:none;background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;padding:5px 12px;border-radius:999px;font-size:12px;font-weight:800;cursor:pointer;}
#fx-panel-footer{padding:7px 11px;background:#eef2f7;border-top:1px solid #dbe4f0;font-size:12px;color:#64748b;}
#fx-pro-modal{position:fixed;inset:0;background:rgba(15,23,42,.45);z-index:1000001;display:none;align-items:center;justify-content:center;padding:20px;}
.fx-pro-card{width:min(560px,92vw);max-height:88vh;overflow:auto;background:#fff;border-radius:18px;border:1px solid #dbe4f0;box-shadow:0 18px 46px rgba(15,23,42,.25);padding:16px;}
.fx-pro-title{font-size:24px;font-weight:900;}.fx-pro-sub{font-size:13px;color:#64748b;margin-top:4px;}
.fx-pro-sec{margin-top:12px;border:1px solid #dbe4f0;border-radius:12px;padding:12px;background:#f8fafc;}
.fx-pro-sec h4{margin:0 0 8px;font-size:15px;}.fx-pro-sec p,.fx-pro-sec li{font-size:13px;color:#334155;line-height:1.5;}
.fx-pro-buy{display:flex;flex-direction:column;gap:10px;margin-top:8px;}
.fx-pro-muted{color:#64748b;font-size:12px;}
.fx-pro-buy-btn{background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;padding:10px 16px;border-radius:12px;font-size:14px;font-weight:800;border:none;cursor:pointer;align-self:flex-start;}
.fx-pro-actions{margin-top:14px;display:flex;justify-content:flex-end;}
.fx-pro-close{border:none;background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;padding:10px 18px;border-radius:12px;font-size:14px;font-weight:800;cursor:pointer;}
.fx-progress-num{font-size:18px;font-weight:900;}
.fx-progress-num small{font-size:12px;color:#64748b;}
`;
(document.head || document.documentElement).appendChild(st);
}
function writePanelPos(left, top) {
try {
localStorage.setItem(PANEL_POS_KEY, JSON.stringify({ left, top }));
} catch (_) {}
}
function readPanelPos() {
try {
return JSON.parse(localStorage.getItem(PANEL_POS_KEY) || "null");
} catch (_) {
return null;
}
}
function _cpn() {
if (document.getElementById("fx-auto-panel")) return;
injectPanelStyles();
const panel = document.createElement("div");
panel.id = "fx-auto-panel";
if (localStorage.getItem(PANEL_COLLAPSED_KEY) === "1")
panel.classList.add("fx-min");
const pos = readPanelPos();
if (pos && Number.isFinite(pos.left) && Number.isFinite(pos.top)) {
panel.style.left = pos.left + "px";
panel.style.top = pos.top + "px";
panel.style.right = "auto";
}
panel.innerHTML = `
\u8fd0\u884c\u72b6\u6001
\u5df2\u505c\u6b62
0/0 \u89c6\u9891\u5df2\u5b66\u4e60
0%
\u8bfe\u7a0b\u9009\u62e9
\u767b\u5f55\u540e\u5c06\u81ea\u52a8\u52a0\u8f7d\u8bfe\u7a0b
\u7ae0\u8282\u9884\u89c8
\u5b9e\u65f6\u8fdb\u5ea6
\u8bf7\u52fe\u9009\u8bfe\u7a0b
\u8fd0\u884c\u65e5\u5fd7
\u6388\u6743\u4e0e\u9009\u9879
\u8bbe\u7f6e
\u7528\u6237\u7c7b\u578b
\u672a\u6821\u9a8c
\u514d\u8d39\u4f53\u9a8c\u7ae0\u8282
0/3
Token
\u4e91\u7aef\u670d\u52a1
${DEFAULT_CLOUD_API_BASE}
\u5f53\u524d\u8bfe\u7a0b-
\u5f53\u524d\u8bfe\u4ef6-
\u8bfe\u4ef6\u8fdb\u5ea60%
`;
document.documentElement.appendChild(panel);
createProModal();
const header = panel.querySelector("#fx-panel-header");
let dragging = false,
sx = 0,
sy = 0,
ol = 0,
ot = 0;
header.addEventListener("mousedown", (e) => {
if (e.target.closest("button")) return;
dragging = true;
const rect = panel.getBoundingClientRect();
sx = e.clientX;
sy = e.clientY;
ol = rect.left;
ot = rect.top;
e.preventDefault();
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
panel.style.left = Math.max(0, ol + e.clientX - sx) + "px";
panel.style.top = Math.max(0, ot + e.clientY - sy) + "px";
panel.style.right = "auto";
});
window.addEventListener("mouseup", () => {
if (!dragging) return;
dragging = false;
const rect = panel.getBoundingClientRect();
writePanelPos(rect.left, rect.top);
});
document.getElementById("fx-btn-min").onclick = () => {
panel.classList.toggle("fx-min");
localStorage.setItem(
PANEL_COLLAPSED_KEY,
panel.classList.contains("fx-min") ? "1" : "0"
);
};
panel.querySelectorAll(".fx-tab-btn").forEach((btn) => {
btn.addEventListener("click", () => switchPanelTab(btn.dataset.tab));
});
document.getElementById("fx-clear-log")?.addEventListener("click", clearRunLog);
document.getElementById("fx-btn-start").onclick = () => runStudy();
document.getElementById("fx-btn-stop").onclick = () => stopStudy();
document.getElementById("fx-btn-refresh").onclick = async () => {
try {
await ensureCryptoJs();
refreshUser();
await loadCourses();
} catch (e) {
log("\u5237\u65b0\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5");
console.debug(e);
}
};
document.getElementById("fx-btn-select-pending").onclick = () => {
selectUnfinishedCourses().catch((e) => {
log("\u52fe\u9009\u5931\u8d25");
console.debug(e);
});
};
document.getElementById("fx-join-qq")?.addEventListener("click", () => {
try {
GM_openInTab(QQ_GROUP_LINK, { active: true, insert: true, setParent: true });
} catch (_) {
window.open(QQ_GROUP_LINK, "_blank");
}
});
document.getElementById("fx-open-pro")?.addEventListener("click", openProModal);
document.getElementById("fx-cloud-save")?.addEventListener("click", async () => {
const input = document.getElementById("fx-cloud-token");
state.cloudToken = String((input && input.value) || "").trim();
state.cloudRevoked = false;
localStorage.setItem(CLOUD_TOKEN_KEY, state.cloudToken);
writeCloudLeaseCache("", 0);
state.cloudLease = "";
state.cloudLeaseExp = 0;
try {
refreshUser();
if (!state.user) {
log("\u8bf7\u5148\u767b\u5f55");
return;
}
if (state.cloudToken) await cloudVerifyToken();
await _el(true);
log("\u6388\u6743\u5df2\u66f4\u65b0");
} catch (err) {
log("\u6388\u6743\u65e0\u6548\u8bf7\u68c0\u67e5 Token");
console.debug(err);
}
_ucu();
});
const logBox = document.getElementById("fx-run-log");
if (logBox) {
logBox.innerHTML = state.logs
.slice(0, 40)
.map((s) => `${escapeHtml(s)}
`)
.join("");
}
const tokenInput = document.getElementById("fx-cloud-token");
if (tokenInput) tokenInput.value = state.cloudToken;
_ucu();
syncRunButtons();
updateQueueSummary();
void _fcc();
}
async function boot() {
try {
localStorage.removeItem("fx_speed_mode_v1");
} catch (_) {}
_cpn();
refreshUser();
await ensureCryptoJs();
if (state.user) {
loadCourses().catch((e) => {
console.debug("[\u6cd5\u5ba3\u52a9\u624b] auto load", e);
});
_el(false).catch(() => {});
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();