// ==UserScript==
// @name 超星自行火炮
// @namespace chaoxing-helper
// @version 4.5.0
// @author isMobile
// @description 超星自行火炮——自动化完成超星视频/音频/文档/答题(选择/简答)任务,不连接题库,使用大模型完成答题,仅供内部使用,用以测试课程流程,不外传
// @license MIT
// @icon https://vitejs.dev/logo.svg
// @match *://*.chaoxing.com/*
// @match *://*.nbdlib.cn/*
// @match *://*.hnsyu.net/*
// @match *://*.gdhkmooc.com/*
// @require https://lib.baomitu.com/vue/3.4.31/vue.global.prod.js
// @require https://lib.baomitu.com/vue-demi/0.14.7/index.iife.js
// @require data:application/javascript,window.Vue%3DVue%3B
// @require https://lib.baomitu.com/element-plus/2.7.2/index.full.min.js
// @require https://lib.baomitu.com/pinia/2.1.7/pinia.iife.min.js
// @require https://lib.baomitu.com/blueimp-md5/2.19.0/js/md5.min.js
// @resource ElementPlusStyle https://lib.baomitu.com/element-plus/2.8.2/index.min.css
// @resource ttf https://www.forestpolice.org/ttf/2.0/table.json
// @connect localhost
// @connect chaoxing-artillery.cloud.caqing.top
// @connect *
// @grant GM_getResourceText
// @grant GM_getValue
// @grant GM_info
// @grant GM_setValue
// @grant GM_addValueChangeListener
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function(vue, pinia, md5, ElementPlus) {
'use strict';
// ==================== 0. 环境钩子与防检测 ====================
const patchVisibilityProps = (doc, win) => {
if (!doc || !win) return;
try {
const docProto = win.Document?.prototype;
if (docProto && !docProto.__cxHelperVisibilityPatched) {
Object.defineProperty(docProto, 'hidden', { get: () => false, configurable: true });
Object.defineProperty(docProto, 'visibilityState', { get: () => 'visible', configurable: true });
docProto.__cxHelperVisibilityPatched = true;
}
} catch {}
try { Object.defineProperty(doc, 'hidden', { get: () => false, configurable: true }); } catch {}
try { Object.defineProperty(doc, 'visibilityState', { get: () => 'visible', configurable: true }); } catch {}
try { doc.hasFocus = () => true; } catch {}
try { doc.onvisibilitychange = null; } catch {}
try { Object.defineProperty(doc, 'onvisibilitychange', { get: () => null, set: () => {}, configurable: true }); } catch {}
try {
win.onblur = null;
win.onpagehide = null;
} catch {}
};
const applyDocumentHooks = (doc, win) => {
if (!doc || !win) return;
patchVisibilityProps(doc, win);
};
// ==================== 1. 顶层窗口检测 ====================
const isTopWindow = (function() {
try {
return window.self === window.top;
} catch (e) {
return false;
}
})();
// ==================== 2. 单例检查,防止多次初始化 ====================
const SCRIPT_ID = 'chaoxing-helper-initialized-v3';
const getTopWindow = () => {
try {
return window.top;
} catch (e) {
return window;
}
};
const topWin = getTopWindow();
if (topWin[SCRIPT_ID]) {
console.log('[超星自行火炮] 已初始化,跳过重复加载');
return;
}
if (isTopWindow) {
topWin[SCRIPT_ID] = true;
}
// ==================== 3. GM API 封装 ====================
const _GM_getResourceText = typeof GM_getResourceText !== 'undefined' ? GM_getResourceText : (name) => '';
const _GM_getValue = typeof GM_getValue !== 'undefined' ? GM_getValue : (key, defaultVal) => defaultVal;
const _GM_info = typeof GM_info !== 'undefined' ? GM_info : { script: { name: '超星自行火炮', author: 'isMobile', version: '4.5.0' } };
const _GM_setValue = typeof GM_setValue !== 'undefined' ? GM_setValue : () => {};
const _GM_addValueChangeListener = typeof GM_addValueChangeListener !== 'undefined' ? GM_addValueChangeListener : null;
const _GM_xmlhttpRequest = typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : () => {};
const _unsafeWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
// ==================== 4. 工具函数 ====================
const sleep = (second) => new Promise(resolve => setTimeout(resolve, second * 1000));
const getScriptInfo = () => ({
name: _GM_info.script.name || '超星自行火炮',
author: _GM_info.script.author || 'isMobile',
namespace: _GM_info.script.namespace || 'chaoxing-helper',
version: _GM_info.script.version || '4.5.0'
});
const formatDateTime = (dt) => {
const pad = n => n < 10 ? "0" + n : n.toString();
return `${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
};
const getDateTime = () => formatDateTime(new Date());
const normalizeApiUrl = (url) => {
if (!url) return '';
url = url.trim();
url = url.replace(/\/+$/, '');
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'http://' + url;
}
return url;
};
// ==================== 5. 自定义样式 ====================
const customStyles = `
.script-container {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* 日志组件 */
.script-log-container {
font-size: 13px;
padding: 4px;
}
.script-log-container .log-group-header {
cursor: pointer;
user-select: none;
}
.script-log-container .log-group-count {
font-size: 11px;
color: #909399;
}
.script-log-container .log-group-toggle {
margin-left: auto;
font-size: 11px;
color: #409eff;
}
.script-log-container .log-item {
padding: 8px 10px;
margin: 4px 0;
border-radius: 6px;
background: #ffffff;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
line-height: 1.4;
word-break: break-all;
display: flex;
align-items: center;
gap: 8px;
border-left: 3px solid transparent;
}
.script-log-container .log-time {
color: #909399;
font-family: 'Menlo', 'Monaco', monospace;
font-size: 10px;
flex-shrink: 0;
background: #f4f6f8;
padding: 2px 5px;
border-radius: 3px;
}
.script-log-container .log-message {
flex: 1;
font-size: 12px;
}
.log-item.type-success { border-left-color: #67c23a; }
.log-item.type-warning { border-left-color: #e6a23c; }
.log-item.type-danger { border-left-color: #f56c6c; }
.log-item.type-primary { border-left-color: #409eff; }
.log-item.type-info { border-left-color: #909399; }
.script-log-container .empty-log {
text-align: center;
color: #909399;
padding: 30px 0;
font-size: 12px;
}
/* 设置组件 */
.script-setting {
font-size: 13px;
padding: 4px 0;
}
.script-setting .setting-section {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 10px;
}
.script-setting .section-title {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f2f5;
display: flex;
align-items: center;
gap: 6px;
}
.script-setting .setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px dashed #f0f2f5;
}
.script-setting .setting-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.script-setting .setting-label {
font-size: 12px;
color: #606266;
}
.script-setting .setting-tip {
font-size: 10px;
color: #909399;
margin-top: 2px;
}
.script-setting .el-switch {
--el-switch-on-color: #764ba2;
}
.script-setting .el-input-number {
width: 90px;
}
/* API管理 */
.api-manager {
padding: 4px 0;
}
.api-manager .api-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
margin: 4px 0;
background: #f8f9fa;
border-radius: 6px;
border: 1px solid #ebeef5;
cursor: pointer;
transition: all 0.2s;
}
.api-manager .api-item:hover {
background: #f0f2f5;
}
.api-manager .api-item.active {
background: linear-gradient(135deg, #667eea20 0%, #764ba220 100%);
border-color: #764ba2;
}
.api-manager .api-item .api-url {
flex: 1;
font-size: 12px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-manager .api-item.active .api-url {
color: #764ba2;
font-weight: 500;
}
.api-manager .api-item .action-btns {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.api-manager .api-item:hover .action-btns {
opacity: 1;
}
.api-manager .api-item .action-btn {
cursor: pointer;
padding: 2px;
border-radius: 3px;
transition: background 0.2s;
}
.api-manager .api-item .action-btn:hover {
background: rgba(0,0,0,0.05);
}
.api-manager .add-api {
display: flex;
gap: 8px;
margin-top: 10px;
}
.api-manager .add-api .el-input {
flex: 1;
}
.api-manager .edit-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
margin: 4px 0;
background: #fff;
border-radius: 6px;
border: 1px solid #764ba2;
box-shadow: 0 2px 8px rgba(118, 75, 162, 0.15);
}
.api-manager .edit-row .el-input {
flex: 1;
}
.api-manager .edit-row .edit-actions {
display: flex;
gap: 4px;
}
.api-manager .edit-row .edit-action-btn {
width: 24px;
height: 24px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.api-manager .edit-row .edit-action-btn.confirm {
background: #67c23a;
color: #fff;
}
.api-manager .edit-row .edit-action-btn.confirm:hover {
background: #85ce61;
}
.api-manager .edit-row .edit-action-btn.cancel {
background: #f0f2f5;
color: #909399;
}
.api-manager .edit-row .edit-action-btn.cancel:hover {
background: #e4e7ed;
color: #606266;
}
.api-manager .empty-tip {
text-align: center;
color: #909399;
padding: 20px 0;
font-size: 12px;
}
/* 题目表格 */
.script-question-table {
width: 100%;
}
.script-question-table .el-table {
font-size: 11px;
border-radius: 6px;
--el-table-header-bg-color: #f5f7fa;
}
/* 主面板 */
.main-page {
z-index: 100003;
position: fixed;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.main-page * {
box-sizing: border-box;
}
/* 最小化圆环 */
.mini-circle {
width: 18px;
height: 18px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.25) 0%, rgba(118, 75, 162, 0.25) 100%);
box-shadow: 0 1px 4px rgba(118, 75, 162, 0.1);
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
opacity: 0.4;
user-select: none;
}
.mini-circle:hover {
transform: scale(1.2);
opacity: 0.6;
box-shadow: 0 2px 8px rgba(118, 75, 162, 0.2);
}
.mini-circle:active {
cursor: grabbing;
}
.mini-circle::after {
content: '';
width: 6px;
height: 6px;
border: 1.5px solid rgba(255,255,255,0.5);
border-radius: 50%;
}
.mini-circle.paused {
background: linear-gradient(135deg, rgba(245, 108, 108, 0.3) 0%, rgba(230, 162, 60, 0.3) 100%);
}
/* 展开面板 */
.main-panel {
width: 340px;
}
.main-panel .el-card {
border: none;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
overflow: hidden;
background: #fff;
}
.main-panel .el-card__header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
padding: 12px 16px;
border-bottom: none;
}
.main-panel .el-card__header.paused {
background: linear-gradient(135deg, #f56c6c 0%, #e6a23c 100%);
}
.main-panel .el-card__body {
padding: 12px;
height: 320px;
overflow: hidden;
}
.main-panel .card-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: move;
user-select: none;
}
.main-panel .card-header .title {
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.main-panel .card-header .header-btns {
display: flex;
align-items: center;
gap: 8px;
}
.main-panel .pause-btn,
.main-panel .minimize-btn {
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255,255,255,0.2);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.main-panel .pause-btn:hover,
.main-panel .minimize-btn:hover {
background: rgba(255,255,255,0.3);
}
.main-panel .minimize-btn::after {
content: '';
width: 10px;
height: 2px;
background: #fff;
border-radius: 1px;
}
.main-panel .pause-btn .pause-icon {
width: 8px;
height: 10px;
display: flex;
justify-content: space-between;
}
.main-panel .pause-btn .pause-icon::before,
.main-panel .pause-btn .pause-icon::after {
content: '';
width: 3px;
height: 100%;
background: #fff;
border-radius: 1px;
}
.main-panel .pause-btn.playing .pause-icon {
width: 0;
height: 0;
border-style: solid;
border-width: 5px 0 5px 8px;
border-color: transparent transparent transparent #fff;
}
.main-panel .pause-btn.playing .pause-icon::before,
.main-panel .pause-btn.playing .pause-icon::after {
display: none;
}
/* 标签页 */
.main-panel .el-tabs {
height: 100%;
display: flex;
flex-direction: column;
}
.main-panel .el-tabs__header {
margin-bottom: 8px;
flex-shrink: 0;
}
.main-panel .el-tabs__content {
flex: 1;
overflow: hidden;
}
.main-panel .el-tab-pane {
height: 100%;
}
.main-panel .el-tabs__nav-wrap::after {
height: 1px;
background: #ebeef5;
}
.main-panel .el-tabs__active-bar {
background: linear-gradient(90deg, #667eea, #764ba2);
height: 2px;
border-radius: 2px;
}
.main-panel .el-tabs__item {
font-size: 13px;
color: #606266;
padding: 0 16px;
height: 36px;
line-height: 36px;
}
.main-panel .el-tabs__item.is-active {
color: #764ba2;
font-weight: 600;
}
/* 滚动条 */
.main-panel .el-scrollbar__bar.is-vertical {
width: 4px;
}
.main-panel .el-scrollbar__thumb {
background-color: #c0c4cc;
}
/* 按钮 */
.main-panel .el-button--primary {
--el-button-bg-color: #764ba2;
--el-button-border-color: #764ba2;
}
/* 状态指示器 */
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
margin-bottom: 8px;
border-radius: 6px;
font-size: 12px;
}
.status-indicator.running {
background: linear-gradient(135deg, #67c23a20 0%, #85ce6120 100%);
color: #67c23a;
}
.status-indicator.paused {
background: linear-gradient(135deg, #e6a23c20 0%, #f56c6c20 100%);
color: #e6a23c;
}
.status-indicator .status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse 1.5s ease-in-out infinite;
}
.status-indicator.paused .status-dot {
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
`;
// ==================== 6. Element Plus 图标 ====================
const createIconComponent = (name, pathD) => ({
name,
render() {
return vue.h('svg', {
xmlns: "http://www.w3.org/2000/svg",
viewBox: "0 0 1024 1024",
style: { width: '1em', height: '1em', fill: 'currentColor' }
}, [vue.h('path', { fill: "currentColor", d: pathD })]);
}
});
const DeleteIcon = createIconComponent('Delete',
"M160 256H96a32 32 0 0 1 0-64h256V95.936a32 32 0 0 1 32-32h256a32 32 0 0 1 32 32V192h256a32 32 0 1 1 0 64h-64v672a32 32 0 0 1-32 32H192a32 32 0 0 1-32-32V256zm448-64v-64H416v64h192zM224 896h576V256H224v640zm192-128a32 32 0 0 1-32-32V416a32 32 0 0 1 64 0v320a32 32 0 0 1-32 32zm192 0a32 32 0 0 1-32-32V416a32 32 0 0 1 64 0v320a32 32 0 0 1-32 32z");
const CheckIcon = createIconComponent('Check',
"M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768zm0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896zm-55.808-536.384l-99.52-99.584a38.4 38.4 0 1 0-54.336 54.336l126.72 126.72a38.272 38.272 0 0 0 54.336 0l262.4-262.464a38.4 38.4 0 1 0-54.272-54.336L456.192 423.616z");
const EditIcon = createIconComponent('Edit',
"M832 512a32 32 0 1 1 64 0v352a32 32 0 0 1-32 32H160a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32h352a32 32 0 0 1 0 64H192v640h640V512z M469.952 554.24l52.8-7.552L847.104 222.4a32 32 0 1 0-45.248-45.248L477.504 501.44l-7.552 52.8zm422.4-422.4a96 96 0 0 1 0 135.808l-331.84 331.84a32 32 0 0 1-18.112 9.088L436.8 623.68a32 32 0 0 1-36.224-36.224l15.104-105.6a32 32 0 0 1 9.024-18.112l331.904-331.84a96 96 0 0 1 135.744 0z");
const CloseIcon = createIconComponent('Close',
"M764.288 214.592 512 466.88 259.712 214.592a31.936 31.936 0 0 0-45.12 45.12L466.752 512 214.528 764.224a31.936 31.936 0 1 0 45.12 45.184L512 557.184l252.288 252.288a31.936 31.936 0 0 0 45.12-45.12L557.12 512.064l252.288-252.352a31.936 31.936 0 1 0-45.12-45.184z");
// ==================== 7. Pinia Stores ====================
// 全局暂停状态 (响应式)
const globalPauseState = vue.reactive({ isPaused: false });
// 媒体处理标记 (WeakMap 防止内存泄漏)
const __mediaProcessedMap = new WeakMap();
const DEFAULT_API_BASE_URL = "https://chaoxing-artillery.cloud.caqing.top";
const OTHER_PARAM_KEYS = {
operationIntervalSec: "operationIntervalSec",
retryCount: "retryCount",
licenseKey: "licenseKey",
enableWebSearch: "enableWebSearch"
};
const CX_PART_KEYS = {
chapter: "chapter",
exam: "exam",
video: "video"
};
const CX_PARAM_KEYS = {
chapter: {
autoSubmit: "autoSubmit",
autoNext: "autoNext",
skipCompleted: "skipCompleted",
onlyQuiz: "onlyQuiz"
},
exam: {
autoSwitchQuestion: "autoSwitchQuestion"
},
video: {
muted: "muted"
}
};
const CX_PART_KEY_ORDER = [
CX_PART_KEYS.chapter,
CX_PART_KEYS.exam,
CX_PART_KEYS.video
];
const CX_PARAM_KEY_ORDER = {
[CX_PART_KEYS.chapter]: [
CX_PARAM_KEYS.chapter.autoSubmit,
CX_PARAM_KEYS.chapter.autoNext,
CX_PARAM_KEYS.chapter.skipCompleted,
CX_PARAM_KEYS.chapter.onlyQuiz
],
[CX_PART_KEYS.exam]: [
CX_PARAM_KEYS.exam.autoSwitchQuestion
],
[CX_PART_KEYS.video]: [
CX_PARAM_KEYS.video.muted
]
};
const getDefaultCxPartKey = (partIndex) => CX_PART_KEY_ORDER[partIndex] || `part-${partIndex}`;
const getDefaultCxParamKey = (partKey, paramIndex) =>
CX_PARAM_KEY_ORDER[partKey]?.[paramIndex] || `${partKey}-param-${paramIndex}`;
const assignCxConfigKeys = (parts = []) => {
parts.forEach((part, partIndex) => {
if (!part || typeof part !== "object") return;
const partKey = typeof part.key === "string" && part.key
? part.key
: getDefaultCxPartKey(partIndex);
part.key = partKey;
if (!Array.isArray(part.params)) {
part.params = [];
return;
}
part.params.forEach((param, paramIndex) => {
if (!param || typeof param !== "object") return;
if (typeof param.key === "string" && param.key) return;
param.key = getDefaultCxParamKey(partKey, paramIndex);
});
});
return parts;
};
const findCxPart = (parts = [], partKey) => {
if (!Array.isArray(parts)) return null;
return parts.find((part) => part?.key === partKey)
|| parts[CX_PART_KEY_ORDER.indexOf(partKey)]
|| null;
};
const findCxParam = (parts = [], partKey, paramKey) => {
const part = findCxPart(parts, partKey);
if (!part?.params) return null;
return part.params.find((param) => param?.key === paramKey)
|| part.params[CX_PARAM_KEY_ORDER[partKey]?.indexOf(paramKey)]
|| null;
};
const getCxParamValue = (parts = [], partKey, paramKey, fallback) => {
const param = findCxParam(parts, partKey, paramKey);
return param?.value ?? fallback;
};
const mergeCxPartValues = (targetParts = [], sourceParts = []) => {
assignCxConfigKeys(targetParts);
assignCxConfigKeys(sourceParts);
sourceParts.forEach((sourcePart, partIndex) => {
const partKey = sourcePart?.key || getDefaultCxPartKey(partIndex);
const targetPart = findCxPart(targetParts, partKey);
if (!targetPart?.params || !Array.isArray(sourcePart?.params)) return;
sourcePart.params.forEach((sourceParam, paramIndex) => {
const paramKey = sourceParam?.key || getDefaultCxParamKey(partKey, paramIndex);
const targetParam = findCxParam(targetParts, partKey, paramKey);
if (!targetParam) return;
targetParam.value = sourceParam.value;
});
});
return targetParts;
};
const LICENSE_STATES = {
CHECKING: "checking",
VALID: "valid",
INVALID: "invalid"
};
const LicenseService = {
state: LICENSE_STATES.INVALID,
pendingPromise: null,
lockedByLicense: false,
validListeners: new Set(),
isValid() {
return this.state === LICENSE_STATES.VALID;
},
markDirty() {
if (this.state !== LICENSE_STATES.CHECKING) {
this.state = LICENSE_STATES.INVALID;
}
this.lockedByLicense = false;
},
onValid(listener) {
if (typeof listener !== "function") return () => {};
this.validListeners.add(listener);
return () => this.validListeners.delete(listener);
},
notifyValid() {
this.validListeners.forEach((listener) => {
try { listener(); } catch {}
});
},
setInvalid(message, addLog) {
this.state = LICENSE_STATES.INVALID;
this.lockedByLicense = true;
globalPauseState.isPaused = true;
if (typeof addLog === "function") {
addLog(message || "许可验证失败,功能已锁定", "danger");
return;
}
try {
useLogStore().addLog(message || "许可验证失败,功能已锁定", "danger");
} catch {}
},
async ensureValid({ force = false, addLog } = {}) {
const configStore = useConfigStore();
const logStore = useLogStore();
const log = typeof addLog === "function"
? addLog
: (message, type = "info") => logStore.addLog(message, type);
const apiUrl = configStore.currentApiUrl;
const licenseKey = String(configStore.licenseKey || configStore.otherSettings.licenseKey || "").trim();
if (!licenseKey) {
this.setInvalid("未填写许可密钥,功能已锁定", log);
return false;
}
if (this.state === LICENSE_STATES.VALID && !force) return true;
if (this.pendingPromise) return this.pendingPromise;
this.state = LICENSE_STATES.CHECKING;
this.pendingPromise = new Promise((resolve) => {
_GM_xmlhttpRequest({
url: `${apiUrl}/licenses/validate`,
method: "POST",
headers: { "Content-Type": "application/json" },
data: JSON.stringify({ licenseKey }),
timeout: 15000,
onload: (resp) => {
if (resp.status === 200) {
this.state = LICENSE_STATES.VALID;
if (this.lockedByLicense) {
globalPauseState.isPaused = false;
}
this.lockedByLicense = false;
log("许可验证通过", "success");
this.notifyValid();
resolve(true);
return;
}
this.setInvalid(`许可验证失败:HTTP ${resp.status}`, log);
resolve(false);
},
onerror: () => {
this.setInvalid("许可验证失败:网络错误", log);
resolve(false);
},
ontimeout: () => {
this.setInvalid("许可验证失败:请求超时", log);
resolve(false);
}
});
}).finally(() => {
this.pendingPromise = null;
});
return this.pendingPromise;
}
};
const KEEPALIVE_CHECKPOINT_KEY = "cx_keepalive_checkpoint_v2";
const KeepAliveEngine = {
stateMap: (() => {
try {
const raw = _GM_getValue(KEEPALIVE_CHECKPOINT_KEY, "{}");
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (parsed && typeof parsed === "object") return parsed;
} catch {}
return {};
})(),
saveTimer: null,
scheduleSave() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => {
this.saveTimer = null;
try {
_GM_setValue(KEEPALIVE_CHECKPOINT_KEY, JSON.stringify(this.stateMap));
} catch {}
}, 600);
},
touch(taskMeta, patch = {}) {
const key = taskMeta?.taskKey;
if (!key) return;
const prev = this.stateMap[key] || {};
this.stateMap[key] = {
...prev,
taskKey: key,
taskLabel: taskMeta?.taskLabel || prev.taskLabel || "",
taskType: taskMeta?.taskType || prev.taskType || "",
updatedAt: Date.now(),
...patch
};
this.scheduleSave();
},
clear(taskMetaOrKey) {
const key = typeof taskMetaOrKey === "string"
? taskMetaOrKey
: taskMetaOrKey?.taskKey;
if (!key || !this.stateMap[key]) return;
delete this.stateMap[key];
this.scheduleSave();
},
applyCheckpoint(taskMeta, mediaEl, log) {
const key = taskMeta?.taskKey;
if (!key || !mediaEl) return;
const snapshot = this.stateMap[key];
if (!snapshot) return;
const savedTime = Number(snapshot.currentTime || 0);
const ageMs = Date.now() - Number(snapshot.updatedAt || 0);
if (!savedTime || ageMs > 12 * 60 * 60 * 1000) return;
try {
if (savedTime > mediaEl.currentTime + 2) {
mediaEl.currentTime = savedTime;
if (typeof log === "function") {
log(`检测到断点,已恢复到 ${Math.floor(savedTime)} 秒`, "primary");
}
}
} catch {}
}
};
const BackgroundStability = (() => {
const state = {
initialized: false,
audioCtx: null,
audioOscillator: null,
audioGainNode: null,
ensureTimer: null,
log: null,
audioWarned: false
};
const log = (message, type = "info") => {
if (typeof state.log === "function") state.log(message, type);
};
const ensureAudioContext = async () => {
try {
if (!state.audioCtx) {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return;
state.audioCtx = new AudioContextClass();
state.audioOscillator = state.audioCtx.createOscillator();
state.audioGainNode = state.audioCtx.createGain();
state.audioOscillator.type = "sine";
state.audioOscillator.frequency.value = 18000;
state.audioGainNode.gain.value = 0.00001;
state.audioOscillator.connect(state.audioGainNode);
state.audioGainNode.connect(state.audioCtx.destination);
state.audioOscillator.start();
}
if (state.audioCtx.state !== "running") {
await state.audioCtx.resume();
}
} catch {
if (!state.audioWarned) {
state.audioWarned = true;
log("AudioContext 保活启动失败,继续使用其他保活机制", "warning");
}
}
};
const ensure = async () => {
await Promise.allSettled([ensureAudioContext()]);
};
const handleForeground = () => { ensure(); };
const init = (addLog) => {
if (state.initialized) return;
state.initialized = true;
state.log = addLog;
ensure();
document.addEventListener("visibilitychange", handleForeground);
window.addEventListener("focus", handleForeground);
window.addEventListener("pageshow", handleForeground);
state.ensureTimer = setInterval(ensure, 45000);
};
return { init, ensure };
})();
const useConfigStore = pinia.defineStore("configStore", {
state: () => {
const scriptInfo = getScriptInfo();
const defaultConfig = {
version: scriptInfo.version,
isMinus: false,
licenseKey: "",
position: { x: "calc(100vw - 380px)", y: "100px" },
menuIndex: "0",
platformName: "cx",
platformParams: {
cx: {
name: "超星自行火炮",
parts: [
{
name: "章节设置",
params: [
{ name: "章节作业自动提交", value: false, type: "boolean", tip: "答题完成后自动提交" },
{ name: "是否自动下一章节", value: true, type: "boolean", tip: "完成后自动跳转" },
{ name: "跳过已完成任务点", value: true, type: "boolean", tip: "已完成的不再处理" },
{ name: "只答题,不做其他", value: false, type: "boolean", tip: "跳过视频音频等任务" }
]
},
{
name: "考试设置",
params: [
{ name: "是否自动切换题目", value: true, type: "boolean", tip: "答完自动下一题" }
]
},
{
name: "视频设置",
params: [
{ name: "静音播放", value: true, type: "boolean", tip: "播放时静音" }
]
}
]
}
},
otherParams: {
name: "其他参数",
params: [
{
key: OTHER_PARAM_KEYS.operationIntervalSec,
name: "操作间隔(秒)",
value: 3,
type: "number",
min: 1,
max: 30,
tip: "每次操作的等待时间"
},
{
key: OTHER_PARAM_KEYS.retryCount,
name: "重试次数",
value: 3,
type: "number",
min: 1,
max: 10,
tip: "失败后的重试次数"
},
{
key: OTHER_PARAM_KEYS.enableWebSearch,
name: "联网搜索",
value: false,
type: "boolean",
tip: "默认关闭,开启后会在答题前联网搜索"
},
{
key: OTHER_PARAM_KEYS.licenseKey,
name: "许可密钥",
value: "",
type: "string",
tip: "后台发放的许可 Key,必填"
}
]
},
apiList: [
"http://localhost:3000",
DEFAULT_API_BASE_URL
],
currentApiIndex: 1
};
assignCxConfigKeys(defaultConfig.platformParams.cx.parts);
let globalConfig = defaultConfig;
const storedConfig = _GM_getValue("config", null);
if (storedConfig) {
try {
const parsed = typeof storedConfig === 'string' ? JSON.parse(storedConfig) : storedConfig;
globalConfig = {
...defaultConfig,
...parsed,
version: scriptInfo.version,
platformParams: defaultConfig.platformParams,
otherParams: defaultConfig.otherParams
};
assignCxConfigKeys(globalConfig.platformParams.cx.parts);
if (parsed.platformParams?.cx?.parts) {
mergeCxPartValues(globalConfig.platformParams.cx.parts, parsed.platformParams.cx.parts);
}
if (parsed.otherParams?.params) {
const incoming = Array.isArray(parsed.otherParams.params) ? parsed.otherParams.params : [];
const valuesByKey = new Map();
incoming.forEach((param, i) => {
const mappedKey = typeof param?.key === "string" && param.key
? param.key
: globalConfig.otherParams.params[i]?.key;
if (!mappedKey) return;
valuesByKey.set(mappedKey, param.value);
});
globalConfig.otherParams.params.forEach((param) => {
if (valuesByKey.has(param.key)) {
param.value = valuesByKey.get(param.key);
}
});
}
if (Array.isArray(parsed.apiList) && parsed.apiList.length > 0) {
const normalizedList = parsed.apiList
.map((item) => normalizeApiUrl(item))
.filter(Boolean);
if (normalizedList.length > 0) {
globalConfig.apiList = Array.from(new Set(normalizedList));
}
}
if (typeof parsed.currentApiIndex === "number") {
globalConfig.currentApiIndex = parsed.currentApiIndex;
}
if (typeof parsed.apiBaseUrl === "string" && parsed.apiBaseUrl.trim()) {
const normalizedApiBaseUrl = normalizeApiUrl(parsed.apiBaseUrl);
if (normalizedApiBaseUrl) {
if (!globalConfig.apiList.includes(normalizedApiBaseUrl)) {
globalConfig.apiList.push(normalizedApiBaseUrl);
}
globalConfig.currentApiIndex = globalConfig.apiList.indexOf(normalizedApiBaseUrl);
}
}
if (parsed.licenseKey) {
globalConfig.licenseKey = parsed.licenseKey;
}
const storedLicenseParam = globalConfig.otherParams.params.find(
(param) => param.key === OTHER_PARAM_KEYS.licenseKey
);
if (storedLicenseParam?.value) {
globalConfig.licenseKey = storedLicenseParam.value;
}
} catch (e) {
console.error("配置解析错误:", e);
}
}
if (!Array.isArray(globalConfig.apiList)) {
globalConfig.apiList = [...defaultConfig.apiList];
}
assignCxConfigKeys(globalConfig.platformParams?.cx?.parts);
globalConfig.apiList = globalConfig.apiList
.map((item) => normalizeApiUrl(item))
.filter(Boolean);
if (globalConfig.apiList.length === 0) {
globalConfig.apiList = [...defaultConfig.apiList];
}
globalConfig.currentApiIndex = Math.max(
0,
Math.min(
Number(globalConfig.currentApiIndex) || 0,
globalConfig.apiList.length - 1
)
);
const licenseParam = globalConfig.otherParams.params.find(
(param) => param.key === OTHER_PARAM_KEYS.licenseKey
);
if (licenseParam) {
licenseParam.value = globalConfig.licenseKey || "";
}
const webSearchParam = globalConfig.otherParams.params.find(
(param) => param.key === OTHER_PARAM_KEYS.enableWebSearch
);
if (!webSearchParam) {
globalConfig.otherParams.params.push({
key: OTHER_PARAM_KEYS.enableWebSearch,
name: "联网搜索",
value: false,
type: "boolean",
tip: "默认关闭,开启后会在答题前联网搜索"
});
}
return globalConfig;
},
getters: {
currentApiUrl: (state) => {
const idx = state.currentApiIndex;
if (idx >= 0 && idx < state.apiList.length) {
return normalizeApiUrl(state.apiList[idx]);
}
return state.apiList[0] ? normalizeApiUrl(state.apiList[0]) : DEFAULT_API_BASE_URL;
},
isPaused: () => globalPauseState.isPaused,
chapterSettings: (state) => {
return {
autoSubmit: !!getCxParamValue(state.platformParams?.cx?.parts, CX_PART_KEYS.chapter, CX_PARAM_KEYS.chapter.autoSubmit, false),
autoNext: !!getCxParamValue(state.platformParams?.cx?.parts, CX_PART_KEYS.chapter, CX_PARAM_KEYS.chapter.autoNext, true),
skipCompleted: !!getCxParamValue(state.platformParams?.cx?.parts, CX_PART_KEYS.chapter, CX_PARAM_KEYS.chapter.skipCompleted, true),
onlyQuiz: !!getCxParamValue(state.platformParams?.cx?.parts, CX_PART_KEYS.chapter, CX_PARAM_KEYS.chapter.onlyQuiz, false)
};
},
videoSettings: (state) => {
return {
muted: !!getCxParamValue(state.platformParams?.cx?.parts, CX_PART_KEYS.video, CX_PARAM_KEYS.video.muted, true)
};
},
examSettings: (state) => {
return {
autoSwitchQuestion: !!getCxParamValue(state.platformParams?.cx?.parts, CX_PART_KEYS.exam, CX_PARAM_KEYS.exam.autoSwitchQuestion, true)
};
},
otherSettings: (state) => {
const getVal = (key, fallback) => {
const target = state.otherParams.params.find((param) => param.key === key);
return target?.value ?? fallback;
};
return {
operationIntervalSec: Number(getVal(OTHER_PARAM_KEYS.operationIntervalSec, 3)) || 3,
retryCount: Number(getVal(OTHER_PARAM_KEYS.retryCount, 3)) || 3,
licenseKey: String(getVal(OTHER_PARAM_KEYS.licenseKey, "") || ""),
enableWebSearch: !!getVal(OTHER_PARAM_KEYS.enableWebSearch, false)
};
}
},
actions: {
addApi(url) {
const normalized = normalizeApiUrl(url);
if (!normalized || this.apiList.includes(normalized)) return false;
this.apiList.push(normalized);
return true;
},
updateApi(index, url) {
const normalized = normalizeApiUrl(url);
if (index < 0 || index >= this.apiList.length || !normalized) return false;
const isDuplicate = this.apiList.some((api, i) => i !== index && api === normalized);
if (isDuplicate) return false;
this.apiList[index] = normalized;
if (index === this.currentApiIndex) {
LicenseService.markDirty();
}
return true;
},
removeApi(index) {
if (this.apiList.length <= 1 || index < 0 || index >= this.apiList.length) return false;
const removingCurrent = index === this.currentApiIndex;
this.apiList.splice(index, 1);
if (this.currentApiIndex >= this.apiList.length) {
this.currentApiIndex = this.apiList.length - 1;
} else if (index < this.currentApiIndex) {
this.currentApiIndex -= 1;
}
if (removingCurrent) {
LicenseService.markDirty();
}
return true;
},
selectApi(index) {
if (index < 0 || index >= this.apiList.length) return;
if (index === this.currentApiIndex) return;
this.currentApiIndex = index;
LicenseService.markDirty();
},
setPlatformParam(partKey, paramKey, value) {
const target = findCxParam(this.platformParams?.cx?.parts, partKey, paramKey);
if (!target) return false;
target.value = value;
return true;
},
setOtherParam(key, value) {
const target = this.otherParams.params.find((param) => param.key === key);
if (!target) return false;
target.value = value;
if (key === OTHER_PARAM_KEYS.licenseKey) {
this.licenseKey = String(value || "");
LicenseService.markDirty();
}
return true;
},
togglePause() {
globalPauseState.isPaused = !globalPauseState.isPaused;
return globalPauseState.isPaused;
},
setPaused(value) {
globalPauseState.isPaused = value;
}
}
});
const useLogStore = pinia.defineStore("logStore", {
state: () => ({ logList: [] }),
actions: {
addLog(message, type = 'info', meta = {}) {
if (type === "info" && !meta.force) return;
const now = Date.now();
const taskKey = meta.taskKey || "";
const last = this.logList[this.logList.length - 1];
if (last && last.message === message && last.type === type && last.taskKey === taskKey) {
const lastTs = Number(last._ts) || 0;
if (now - lastTs < 3000) return;
}
const entry = {
message,
time: getDateTime(),
type,
taskKey,
taskLabel: meta.taskLabel || "",
_ts: now
};
this.logList.push(entry);
if (this.logList.length > 200) {
this.logList = this.logList.slice(-200);
}
},
clearLogs() {
this.logList = [];
}
}
});
const useQuestionStore = pinia.defineStore("questionStore", {
state: () => ({ questionList: [] }),
actions: {
addQuestion(question) {
this.questionList.push(question);
},
clearQuestion() {
this.questionList = [];
}
}
});
// ==================== 8. IframeUtils ====================
class IframeUtils {
static getIframes(element) {
return Array.from(element.querySelectorAll("iframe"));
}
static getAllNestedIframes(element, visited = new Set()) {
const result = [];
const iframes = IframeUtils.getIframes(element);
for (const iframe of iframes) {
if (!iframe || visited.has(iframe)) continue;
visited.add(iframe);
result.push(iframe);
try {
const doc = iframe.contentDocument;
const root = doc?.documentElement;
if (root) {
const nested = IframeUtils.getAllNestedIframes(root, visited);
result.push(...nested);
}
} catch {}
}
return result;
}
}
// ==================== 9. Typr 字体解析库 ====================
const Typr = {};
Typr._bin = {
readFixed: (data, o) => (data[o] << 8 | data[o + 1]) + (data[o + 2] << 8 | data[o + 3]) / (256 * 256 + 4),
readF2dot14: (data, o) => Typr._bin.readShort(data, o) / 16384,
readInt: (buff, p) => Typr._bin._view(buff).getInt32(p),
readInt8: (buff, p) => Typr._bin._view(buff).getInt8(p),
readShort: (buff, p) => Typr._bin._view(buff).getInt16(p),
readUshort: (buff, p) => Typr._bin._view(buff).getUint16(p),
readUshorts: (buff, p, len) => {
const arr = [];
for (let i = 0; i < len; i++) arr.push(Typr._bin.readUshort(buff, p + i * 2));
return arr;
},
readUint: (buff, p) => Typr._bin._view(buff).getUint32(p),
readUint64: (buff, p) => Typr._bin.readUint(buff, p) * (4294967295 + 1) + Typr._bin.readUint(buff, p + 4),
readASCII: (buff, p, l) => {
let s = "";
for (let i = 0; i < l; i++) s += String.fromCharCode(buff[p + i]);
return s;
},
readBytes: (buff, p, l) => {
const arr = [];
for (let i = 0; i < l; i++) arr.push(buff[p + i]);
return arr;
},
_view: (buff) => buff._dataView || (buff._dataView = buff.buffer
? new DataView(buff.buffer, buff.byteOffset, buff.byteLength)
: new DataView(new Uint8Array(buff).buffer))
};
Typr.parse = function(buff) {
const bin = Typr._bin;
const data = new Uint8Array(buff);
const tag = bin.readASCII(data, 0, 4);
if (tag === "ttcf") {
let offset = 4;
offset += 4;
const numF = bin.readUint(data, offset); offset += 4;
const fnts = [];
for (let i = 0; i < numF; i++) {
const foff = bin.readUint(data, offset); offset += 4;
fnts.push(Typr._readFont(data, foff));
}
return fnts;
}
return [Typr._readFont(data, 0)];
};
Typr._readFont = function(data, offset) {
const bin = Typr._bin;
const ooff = offset;
offset += 4;
const numTables = bin.readUshort(data, offset); offset += 8;
const tags = ["cmap", "head", "hhea", "maxp", "hmtx", "loca", "glyf"];
const obj = { _data: data, _offset: ooff };
const tabs = {};
for (let i = 0; i < numTables; i++) {
const tag = bin.readASCII(data, offset, 4); offset += 4;
offset += 4;
const toffset = bin.readUint(data, offset); offset += 4;
const length = bin.readUint(data, offset); offset += 4;
tabs[tag] = { offset: toffset, length };
}
for (const t of tags) {
if (tabs[t] && Typr[t]) {
obj[t] = Typr[t].parse(data, tabs[t].offset, tabs[t].length, obj);
}
}
return obj;
};
Typr._tabOffset = function(data, tab, foff) {
const bin = Typr._bin;
const numTables = bin.readUshort(data, foff + 4);
let offset = foff + 12;
for (let i = 0; i < numTables; i++) {
const tag = bin.readASCII(data, offset, 4); offset += 4;
offset += 4;
const toffset = bin.readUint(data, offset); offset += 4;
offset += 4;
if (tag === tab) return toffset;
}
return 0;
};
Typr.cmap = {
parse: function(data, offset, length) {
data = new Uint8Array(data.buffer, offset, length);
offset = 0;
const bin = Typr._bin;
const obj = { tables: [] };
offset += 2;
const numTables = bin.readUshort(data, offset); offset += 2;
const offs = [];
for (let i = 0; i < numTables; i++) {
const platformID = bin.readUshort(data, offset); offset += 2;
const encodingID = bin.readUshort(data, offset); offset += 2;
const noffset = bin.readUint(data, offset); offset += 4;
const id = "p" + platformID + "e" + encodingID;
let tind = offs.indexOf(noffset);
if (tind === -1) {
tind = obj.tables.length;
offs.push(noffset);
const format = bin.readUshort(data, noffset);
let subt;
if (format === 4) subt = Typr.cmap.parse4(data, noffset);
else if (format === 12) subt = Typr.cmap.parse12(data, noffset);
else subt = { format };
obj.tables.push(subt);
}
if (obj[id] == null) obj[id] = tind;
}
return obj;
},
parse4: function(data, offset) {
const bin = Typr._bin;
const offset0 = offset;
const obj = {};
obj.format = bin.readUshort(data, offset); offset += 2;
const length = bin.readUshort(data, offset); offset += 2;
offset += 2;
const segCountX2 = bin.readUshort(data, offset); offset += 2;
const segCount = segCountX2 / 2;
offset += 6;
obj.endCount = bin.readUshorts(data, offset, segCount); offset += segCount * 2;
offset += 2;
obj.startCount = bin.readUshorts(data, offset, segCount); offset += segCount * 2;
obj.idDelta = [];
for (let i = 0; i < segCount; i++) { obj.idDelta.push(bin.readShort(data, offset)); offset += 2; }
obj.idRangeOffset = bin.readUshorts(data, offset, segCount); offset += segCount * 2;
obj.glyphIdArray = [];
while (offset < offset0 + length) { obj.glyphIdArray.push(bin.readUshort(data, offset)); offset += 2; }
return obj;
},
parse12: function(data, offset) {
const bin = Typr._bin;
const obj = {};
obj.format = bin.readUshort(data, offset); offset += 2;
offset += 10;
const nGroups = bin.readUint(data, offset); offset += 4;
obj.groups = [];
for (let i = 0; i < nGroups; i++) {
const off = offset + i * 12;
obj.groups.push([bin.readUint(data, off), bin.readUint(data, off + 4), bin.readUint(data, off + 8)]);
}
return obj;
}
};
Typr.head = {
parse: function(data, offset) {
const bin = Typr._bin;
const obj = {};
offset += 18;
obj.unitsPerEm = bin.readUshort(data, offset); offset += 2;
offset += 30;
obj.indexToLocFormat = bin.readShort(data, offset);
return obj;
}
};
Typr.hhea = {
parse: function(data, offset) {
const bin = Typr._bin;
const obj = {};
offset += 34;
obj.numberOfHMetrics = bin.readUshort(data, offset);
return obj;
}
};
Typr.maxp = {
parse: function(data, offset) {
const bin = Typr._bin;
return { numGlyphs: bin.readUshort(data, offset + 4) };
}
};
Typr.hmtx = {
parse: function(data, offset, length, font) {
const bin = Typr._bin;
const obj = { aWidth: [], lsBearing: [] };
let aw = 0, lsb = 0;
for (let i = 0; i < font.maxp.numGlyphs; i++) {
if (i < font.hhea.numberOfHMetrics) {
aw = bin.readUshort(data, offset); offset += 2;
lsb = bin.readShort(data, offset); offset += 2;
}
obj.aWidth.push(aw);
obj.lsBearing.push(lsb);
}
return obj;
}
};
Typr.loca = {
parse: function(data, offset, length, font) {
const bin = Typr._bin;
const obj = [];
const ver = font.head.indexToLocFormat;
const len = font.maxp.numGlyphs + 1;
if (ver === 0) for (let i = 0; i < len; i++) obj.push(bin.readUshort(data, offset + (i << 1)) << 1);
if (ver === 1) for (let i = 0; i < len; i++) obj.push(bin.readUint(data, offset + (i << 2)));
return obj;
}
};
Typr.glyf = {
parse: function(data, offset, length, font) {
const obj = [];
for (let g = 0; g < font.maxp.numGlyphs; g++) obj.push(null);
return obj;
},
_parseGlyf: function(font, g) {
const bin = Typr._bin;
const data = font._data;
let offset = Typr._tabOffset(data, "glyf", font._offset) + font.loca[g];
if (font.loca[g] === font.loca[g + 1]) return null;
const gl = {};
gl.noc = bin.readShort(data, offset); offset += 2;
gl.xMin = bin.readShort(data, offset); offset += 2;
gl.yMin = bin.readShort(data, offset); offset += 2;
gl.xMax = bin.readShort(data, offset); offset += 2;
gl.yMax = bin.readShort(data, offset); offset += 2;
if (gl.xMin >= gl.xMax || gl.yMin >= gl.yMax) return null;
if (gl.noc > 0) {
gl.endPts = [];
for (let i = 0; i < gl.noc; i++) { gl.endPts.push(bin.readUshort(data, offset)); offset += 2; }
const instructionLength = bin.readUshort(data, offset); offset += 2;
if (data.length - offset < instructionLength) return null;
offset += instructionLength;
const crdnum = gl.endPts[gl.noc - 1] + 1;
gl.flags = [];
for (let i = 0; i < crdnum; i++) {
const flag = data[offset]; offset++;
gl.flags.push(flag);
if ((flag & 8) !== 0) {
const rep = data[offset]; offset++;
for (let j = 0; j < rep; j++) { gl.flags.push(flag); i++; }
}
}
gl.xs = [];
for (let i = 0; i < crdnum; i++) {
const i8 = (gl.flags[i] & 2) !== 0, same = (gl.flags[i] & 16) !== 0;
if (i8) { gl.xs.push(same ? data[offset] : -data[offset]); offset++; }
else { if (same) gl.xs.push(0); else { gl.xs.push(bin.readShort(data, offset)); offset += 2; } }
}
gl.ys = [];
for (let i = 0; i < crdnum; i++) {
const i8 = (gl.flags[i] & 4) !== 0, same = (gl.flags[i] & 32) !== 0;
if (i8) { gl.ys.push(same ? data[offset] : -data[offset]); offset++; }
else { if (same) gl.ys.push(0); else { gl.ys.push(bin.readShort(data, offset)); offset += 2; } }
}
let x = 0, y = 0;
for (let i = 0; i < crdnum; i++) { x += gl.xs[i]; y += gl.ys[i]; gl.xs[i] = x; gl.ys[i] = y; }
} else {
gl.parts = [];
let flags;
do {
flags = bin.readUshort(data, offset); offset += 2;
const part = { m: { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 }, p1: -1, p2: -1 };
gl.parts.push(part);
part.glyphIndex = bin.readUshort(data, offset); offset += 2;
let arg1, arg2;
if (flags & 1) { arg1 = bin.readShort(data, offset); offset += 2; arg2 = bin.readShort(data, offset); offset += 2; }
else { arg1 = bin.readInt8(data, offset); offset++; arg2 = bin.readInt8(data, offset); offset++; }
if (flags & 2) { part.m.tx = arg1; part.m.ty = arg2; }
else { part.p1 = arg1; part.p2 = arg2; }
if (flags & 8) { part.m.a = part.m.d = Typr._bin.readF2dot14(data, offset); offset += 2; }
else if (flags & 64) {
part.m.a = Typr._bin.readF2dot14(data, offset); offset += 2;
part.m.d = Typr._bin.readF2dot14(data, offset); offset += 2;
} else if (flags & 128) {
part.m.a = Typr._bin.readF2dot14(data, offset); offset += 2;
part.m.b = Typr._bin.readF2dot14(data, offset); offset += 2;
part.m.c = Typr._bin.readF2dot14(data, offset); offset += 2;
part.m.d = Typr._bin.readF2dot14(data, offset); offset += 2;
}
} while (flags & 32);
}
return gl;
}
};
Typr.U = {
codeToGlyph: function(font, code) {
const cmap = font.cmap;
let tind = -1;
if (cmap.p0e4 != null) tind = cmap.p0e4;
else if (cmap.p3e1 != null) tind = cmap.p3e1;
else if (cmap.p1e0 != null) tind = cmap.p1e0;
else if (cmap.p0e3 != null) tind = cmap.p0e3;
if (tind === -1) return 0;
const tab = cmap.tables[tind];
if (tab.format === 4) {
let sind = -1;
for (let i = 0; i < tab.endCount.length; i++) { if (code <= tab.endCount[i]) { sind = i; break; } }
if (sind === -1 || tab.startCount[sind] > code) return 0;
let gli = 0;
if (tab.idRangeOffset[sind] !== 0) {
gli = tab.glyphIdArray[code - tab.startCount[sind] + (tab.idRangeOffset[sind] >> 1) - (tab.idRangeOffset.length - sind)];
} else { gli = code + tab.idDelta[sind]; }
return gli & 65535;
} else if (tab.format === 12) {
if (code > tab.groups[tab.groups.length - 1][1]) return 0;
for (const grp of tab.groups) {
if (grp[0] <= code && code <= grp[1]) return grp[2] + (code - grp[0]);
}
}
return 0;
},
glyphToPath: function(font, gid) {
const path = { cmds: [], crds: [] };
if (font.glyf) Typr.U._drawGlyf(gid, font, path);
return path;
},
_drawGlyf: function(gid, font, path) {
let gl = font.glyf[gid];
if (gl == null) gl = font.glyf[gid] = Typr.glyf._parseGlyf(font, gid);
if (gl != null) {
if (gl.noc > -1) Typr.U._simpleGlyph(gl, path);
else Typr.U._compoGlyph(gl, font, path);
}
},
_simpleGlyph: function(gl, p) {
for (let c = 0; c < gl.noc; c++) {
const i0 = c === 0 ? 0 : gl.endPts[c - 1] + 1;
const il = gl.endPts[c];
for (let i = i0; i <= il; i++) {
const pr = i === i0 ? il : i - 1;
const nx = i === il ? i0 : i + 1;
const onCurve = gl.flags[i] & 1;
const prOnCurve = gl.flags[pr] & 1;
const nxOnCurve = gl.flags[nx] & 1;
const x = gl.xs[i], y = gl.ys[i];
if (i === i0) {
if (onCurve) {
if (prOnCurve) { p.cmds.push("M"); p.crds.push(gl.xs[pr], gl.ys[pr]); }
else { p.cmds.push("M"); p.crds.push(x, y); continue; }
} else {
if (prOnCurve) { p.cmds.push("M"); p.crds.push(gl.xs[pr], gl.ys[pr]); }
else { p.cmds.push("M"); p.crds.push((gl.xs[pr] + x) / 2, (gl.ys[pr] + y) / 2); }
}
}
if (onCurve) { if (prOnCurve) { p.cmds.push("L"); p.crds.push(x, y); } }
else {
if (nxOnCurve) { p.cmds.push("Q"); p.crds.push(x, y, gl.xs[nx], gl.ys[nx]); }
else { p.cmds.push("Q"); p.crds.push(x, y, (x + gl.xs[nx]) / 2, (y + gl.ys[nx]) / 2); }
}
}
p.cmds.push("Z");
}
},
_compoGlyph: function(gl, font, p) {
for (const prt of gl.parts) {
const path = { cmds: [], crds: [] };
Typr.U._drawGlyf(prt.glyphIndex, font, path);
const m = prt.m;
for (let i = 0; i < path.crds.length; i += 2) {
const x = path.crds[i], y = path.crds[i + 1];
p.crds.push(x * m.a + y * m.b + m.tx);
p.crds.push(x * m.c + y * m.d + m.ty);
}
for (const cmd of path.cmds) p.cmds.push(cmd);
}
}
};
// ==================== 11. Font 类 ====================
class Font {
constructor(data) {
const obj = Typr.parse(data);
if (!obj?.length || typeof obj[0] !== "object") throw new Error("unable to parse font");
Object.assign(this, obj[0]);
}
codeToGlyph(code) { return Typr.U.codeToGlyph(this, code); }
glyphToPath(gid) { return Typr.U.glyphToPath(this, gid); }
}
// ==================== 12. 字体解密 ====================
const FONT_TABLE_CACHE_KEY = "cx_font_table_cache_v1";
let cachedFontTable = null;
const getFontTable = () => {
if (cachedFontTable) return cachedFontTable;
let tableText = _GM_getResourceText("ttf");
if (!tableText) {
tableText = _GM_getValue(FONT_TABLE_CACHE_KEY, "");
}
if (!tableText) return null;
try {
const table = JSON.parse(tableText);
if (table && typeof table === "object") {
_GM_setValue(FONT_TABLE_CACHE_KEY, tableText);
cachedFontTable = table;
return table;
}
} catch {}
return null;
};
const FontDecoderModule = (() => {
const decrypt = (iframeDocument) => {
try {
const styles = iframeDocument.querySelectorAll("style");
let tip = null;
for (const style of styles) {
if (style.textContent?.includes("font-cxsecret")) { tip = style; break; }
}
if (!tip) return;
const fontMatch = tip.textContent.match(/base64,([\w\W]+?)'/);
if (!fontMatch?.[1]) return;
const fontArray = Uint8Array.from(atob(fontMatch[1]), c => c.charCodeAt(0));
const font = new Font(fontArray);
const table = getFontTable();
if (!table) return;
const match = {};
for (let i = 19968; i < 40870; i++) {
const glyph = font.codeToGlyph(i);
if (!glyph) continue;
const path = font.glyphToPath(glyph);
const hash = md5(JSON.stringify(path)).slice(24);
if (table[hash]) match[i] = table[hash];
}
for (const el of iframeDocument.querySelectorAll(".font-cxsecret")) {
let html = el.innerHTML;
for (const key in match) {
html = html.replace(new RegExp(String.fromCharCode(Number(key)), "g"), String.fromCharCode(match[key]));
}
el.innerHTML = html;
el.classList.remove("font-cxsecret");
}
} catch (e) {
console.warn("字体解密失败:", e);
}
};
return {
decryptDocument(iframeDocument) {
if (!iframeDocument?.querySelector?.(".font-cxsecret")) return;
decrypt(iframeDocument);
}
};
})();
// ==================== 13. API call ====================
const validateLicenseState = async ({ force = false, addLog } = {}) =>
LicenseService.ensureValid({ force, addLog });
const getAnswer = async (question, addLog) => {
const configStore = useConfigStore();
const logStore = useLogStore();
const log = typeof addLog === "function"
? addLog
: (message, type = "info") => logStore.addLog(message, type);
const apiUrl = configStore.currentApiUrl + '/search';
const retryCount = configStore.otherSettings.retryCount;
const licenseKey = String(configStore.licenseKey || configStore.otherSettings.licenseKey || "").trim();
const licenseOk = await validateLicenseState({ addLog: log });
if (!licenseOk || !LicenseService.isValid()) {
log("许可验证失败,已取消请求", "danger");
return { code: 403, msg: "许可无效", data: { answer: [] } };
}
const data = JSON.stringify({
question: question.title,
options: question.optionsText,
type: question.type,
questionData: question.element?.outerHTML || "",
workType: question.workType,
licenseKey,
enableWebSearch: !!configStore.otherSettings.enableWebSearch
});
await sleep(configStore.otherSettings.operationIntervalSec);
const tryRequest = async (attempt = 1) => {
return new Promise(resolve => {
_GM_xmlhttpRequest({
url: apiUrl,
method: "POST",
headers: { "Content-Type": "application/json" },
data,
timeout: 60000,
onload: response => {
try {
resolve(JSON.parse(response.responseText));
} catch (e) {
if (attempt < retryCount) {
log(`响应解析失败,重试 ${attempt}/${retryCount}`, "warning");
setTimeout(() => resolve(tryRequest(attempt + 1)), 1000);
} else {
log("响应解析失败", "danger");
resolve({ code: 500, data: { answer: [] }, msg: "解析失败" });
}
}
},
onerror: () => {
if (attempt < retryCount) {
log(`API请求失败,重试 ${attempt}/${retryCount}`, "warning");
setTimeout(() => resolve(tryRequest(attempt + 1)), 1000);
} else {
log("API请求失败", "danger");
resolve({ code: 500, data: { answer: [] }, msg: "请求失败" });
}
},
ontimeout: () => {
if (attempt < retryCount) {
log(`API请求超时,重试 ${attempt}/${retryCount}`, "warning");
setTimeout(() => resolve(tryRequest(attempt + 1)), 1000);
} else {
log("API请求超时", "warning");
resolve({ code: 500, data: { answer: [] }, msg: "请求超时" });
}
}
});
});
};
return tryRequest();
};
// ==================== 14. 暂停检查工具 ====================
const waitIfPaused = async (addLog) => {
const logStore = useLogStore();
const log = typeof addLog === "function"
? addLog
: (message, type = "info") => logStore.addLog(message, type);
if (globalPauseState.isPaused) {
log("脚本已暂停,等待继续...", "warning");
while (globalPauseState.isPaused) {
await sleep(0.5);
}
log("脚本继续运行", "success");
}
};
// ==================== 15. 题目处理器 ====================
const CX_PAGE_ROUTE_RULES = Object.freeze([
Object.freeze({ keyword: "/mycourse/studentstudy", mode: "chapter" }),
Object.freeze({ keyword: "/mooc2/work/dowork", mode: "work" }),
Object.freeze({ keyword: "/exam-ans/exam", mode: "exam" }),
Object.freeze({ keyword: "mycourse/stu?courseid", mode: "course" })
]);
const CX_QUESTION_RULES = Object.freeze({
zj: Object.freeze({
questionSelector: ".TiMu",
titleSelector: ".fontLabel",
typeSelector: ".newZy_TItle",
optionSelector: '[class*="before-after"]',
optionTextSelector: ".fl.after",
textareaSelector: "textarea"
}),
zy: Object.freeze({
questionSelector: ".questionLi",
titleSelector: "h3",
typeSelector: ".colorShallow",
optionSelector: ".answerBg",
optionTextSelector: ".answer_p",
textareaSelector: "textarea"
}),
ks: Object.freeze({
questionSelector: ".questionLi",
titleSelector: "h3",
typeSelector: ".colorShallow",
optionSelector: ".answerBg",
optionTextSelector: ".answer_p",
textareaSelector: "textarea"
})
});
const CX_TASK_TYPE_RULES = Object.freeze({
docModules: Object.freeze(["ppt", "doc", "pptx", "docx", "pdf"]),
map: Object.freeze([
Object.freeze({ type: "work", keywords: ["api/work", "ananas/modules/work/index.html"] }),
Object.freeze({ type: "video", keywords: ["video"] }),
Object.freeze({ type: "audio", keywords: ["audio"] }),
Object.freeze({ type: "doc", keywords: ["modules/ppt", "modules/doc", "modules/pptx", "modules/docx", "modules/pdf"] }),
Object.freeze({ type: "book", keywords: ["modules/innerbook"] })
])
});
const CX_SELECTORS = Object.freeze({
mainIframe: Object.freeze(["#iframe", "iframe"]),
pageLabel: Object.freeze([
"#coursetree .currents .posCatalog_name",
"#coursetree .currents .catalog_name",
"#coursetree .currents",
"#coursetree .current",
".courseChapter .currents",
".courseChapter .current",
".catalog .currents",
".catalog .current",
".prev_next .cur",
".prev_next .cur .catalog_name",
".breadcrumb .active",
".catalog_name",
".chapterName",
".courseName",
".course-title",
".article-title",
".title",
"h1",
"h2"
]),
activeChapter: Object.freeze([
".posCatalog_select.posCatalog_active .posCatalog_name",
".posCatalog_select.posCatalog_active"
]),
chapterCode: ".posCatalog_sbar",
chapterId: Object.freeze(["#chapterIdid", "#curChapterId"]),
jobIcon: ".ans-job-icon",
jobFinishedClass: "ans-job-finished",
jobFinishTips: Object.freeze([".jobFinishTip", "#jobFinishTip"]),
jobFinishButtons: "a, button, span, input[type='button'], input[type='submit']",
jobFinishSafeClose: ".popClose, .btnBlue.btn_92_cancel, .graybtn02.nextbutton, .popMoveDele",
nextChapter: Object.freeze(["#prevNextFocusNext", ".jb_btn.jb_btn_92.fr.fs14.nextChapter"]),
currentChapter: Object.freeze([
".posCatalog_select.posCatalog_active",
"#coursetree .currents",
"#coursetree .current",
".courseChapter .currents",
".courseChapter .current"
]),
currentChapterTarget: Object.freeze([".posCatalog_name", ".catalog_name", "a"]),
pagerList: "#prev_tab .prev_ul, .prev_tab .prev_ul, .prev_ul",
pagerItemTarget: "a, button, span",
workDoneAttr: Object.freeze([
"[title*='已完成']", "[title*='已提交']", "[title*='已交卷']", "[title*='已批阅']", "[title*='待批阅']",
"[alt*='已完成']", "[alt*='已提交']", "[alt*='已交卷']", "[alt*='已批阅']", "[alt*='待批阅']"
]),
workSubmit: "#btnBlueSubmit, .btnBlueSubmit, .btnBlue, .bluebtn, .btnSubmit, button[type='submit'], input[type='submit']",
workAction: "button, a, input[type='button'], input[type='submit']",
workResultPanels: Object.freeze([
".score", ".score-wrap", ".analysis", ".work-result", ".ans-job-finished",
".workResult", ".exam-result", ".paper-result", ".resultScore",
".scoreView", ".scoreCon", ".analysisResult", ".analysis_wrap",
".check_answer", ".check_answer_dx", ".answer-analysis", ".analysis_cont",
".analysisCont", ".right_answer", ".correctAnswer", ".answerKey"
]),
workQuestion: ".TiMu, .questionLi",
workInput: "input, textarea, select",
workSuccessTip: "#successTip, .tjSucess, .tjSuccess",
workErrorTip: "#errorTip, .errorTip",
workCaptcha: "#validate, .yzmLayer, .captcha, [id*='captcha']",
media: Object.freeze({
video: "video",
audio: "audio"
}),
documentViewer: "#panView",
examCurrentQuestion: ".topicNumber_list .current",
examQuestionItems: ".topicNumber_list li"
});
class BaseQuestionHandler {
constructor(options = {}) {
this._document = document;
this._window = _unsafeWindow;
this.questions = [];
const logStore = useLogStore();
const questionStore = useQuestionStore();
this.addLog = typeof options.addLog === "function"
? options.addLog
: logStore.addLog.bind(logStore);
this.addQuestion = typeof options.addQuestion === "function"
? options.addQuestion
: questionStore.addQuestion.bind(questionStore);
}
questionType = {
"单选题": "0", "A1型题": "0",
"多选题": "1", "X型题": "1",
"填空题": "2", "判断题": "3",
"简答题": "4", "名词解释": "5",
"论述题": "6", "计算题": "7"
};
removeHtml(html) {
if (!html) return "";
return html
.replace(/<((?!img|sub|sup|br)[^>]+)>/g, "")
.replace(/ /g, " ")
.replace(/\s+/g, " ")
.replace(/
/g, "\n")
.replace(//g, '
')
.trim();
}
clean(str) {
if (!str) return "";
return str.replace(/^【.*?】\s*/, "").replace(/\s*(\d+(\.\d+)?分)$/, "").trim();
}
}
class CxQuestionHandler extends BaseQuestionHandler {
constructor(type, iframe, options = {}) {
super(options);
this.type = type;
if (iframe) {
this._document = iframe.contentDocument;
this._window = iframe.contentWindow;
}
}
async init() {
this.questions = [];
this.parseHtml();
const total = this.questions.length;
if (!total) {
this.addLog("未解析到题目", "warning");
return { total: 0, hit: 0, hitRate: 0 };
}
this.addLog(`解析到 ${total} 道题目`, "primary");
let hitCount = 0;
for (const [index, question] of this.questions.entries()) {
await waitIfPaused(this.addLog);
const answerData = await getAnswer(question, this.addLog);
if (answerData.code === 200 && answerData.data.answer?.length) {
hitCount += 1;
question.answer = answerData.data.answer;
this.fillQuestion(question);
this.addLog(`第 ${index + 1} 题已作答`, "success");
} else {
this.addLog(`第 ${index + 1} 题未找到答案`, "warning");
question.answer = [answerData.msg || "未找到答案"];
}
this.addQuestion(question);
}
const hitRate = total ? Math.round((hitCount / total) * 100) : 0;
this.addLog(`答题完成,命中率 ${hitCount}/${total} (${hitRate}%)`, hitCount ? "success" : "warning");
return { total, hit: hitCount, hitRate };
}
parseHtml() {
if (!this._document) return;
let questionElements;
if (this.type === "zj") {
questionElements = this._document.querySelectorAll(CX_QUESTION_RULES.zj.questionSelector);
} else if (["zy", "ks"].includes(this.type)) {
questionElements = this._document.querySelectorAll(CX_QUESTION_RULES[this.type].questionSelector);
}
if (questionElements?.length) {
this.addQuestions(questionElements);
}
}
extractOptions(optionElements, optionSelector) {
const optionsObject = {};
const optionTexts = [];
optionElements.forEach(optionElement => {
const selectorEl = optionElement.querySelector(optionSelector);
const optionTextContent = this.removeHtml(selectorEl?.innerHTML || "");
optionsObject[optionTextContent] = optionElement;
optionTexts.push(optionTextContent);
});
return [optionsObject, optionTexts];
}
addQuestions(questionElements) {
questionElements.forEach(questionElement => {
let questionTitle = "";
let questionTypeText = "";
let optionElements;
let optionsObject = {};
let optionTexts = [];
if (["zy", "ks"].includes(this.type)) {
const questionRule = CX_QUESTION_RULES[this.type];
const h3El = questionElement.querySelector(questionRule.titleSelector);
const titleElement = h3El?.innerHTML || "";
const colorShallowEl = questionElement.querySelector(questionRule.typeSelector);
const colorShallowElement = colorShallowEl?.outerHTML || "";
if (this.type === "zy") {
questionTypeText = questionElement.getAttribute("typename") || "";
} else if (this.type === "ks") {
questionTypeText = this.removeHtml(colorShallowElement).slice(1, 4) || "";
}
questionTitle = this.removeHtml(titleElement.split(colorShallowElement || "@@NOSPLIT@@")[1] || titleElement);
optionElements = questionElement.querySelectorAll(questionRule.optionSelector);
[optionsObject, optionTexts] = this.extractOptions(optionElements, questionRule.optionTextSelector);
} else if (this.type === "zj") {
const questionRule = CX_QUESTION_RULES.zj;
const fontLabelEl = questionElement.querySelector(questionRule.titleSelector);
const zyTitleEl = questionElement.querySelector(questionRule.typeSelector);
questionTitle = this.removeHtml(fontLabelEl?.innerHTML || "");
questionTypeText = this.removeHtml(zyTitleEl?.innerHTML || "");
optionElements = questionElement.querySelectorAll(questionRule.optionSelector);
[optionsObject, optionTexts] = this.extractOptions(optionElements, questionRule.optionTextSelector);
}
const cleanType = questionTypeText.replace(/【|】/g, "").trim();
this.questions.push({
element: questionElement,
type: this.questionType[cleanType] || "999",
title: this.clean(questionTitle),
optionsText: optionTexts,
options: optionsObject,
answer: [],
workType: this.type,
refer: this._window?.location.href || window.location.href
});
});
}
fillQuestion(question) {
if (!this._window) return;
try {
if (question.type === "0" || question.type === "1") {
question.answer.forEach(answer => {
const cleanAnswer = this.removeHtml(answer);
for (const key in question.options) {
if (key === cleanAnswer) {
const optionElement = question.options[key];
if (["zj", "zy"].includes(this.type)) {
if (optionElement.getAttribute("aria-checked") !== "true") optionElement.click();
} else if (this.type === "ks") {
if (!optionElement.querySelector(".check_answer, .check_answer_dx")) optionElement.click();
}
}
}
});
} else if (question.type === "2") {
const textareaElements = question.element.querySelectorAll(CX_QUESTION_RULES[this.type]?.textareaSelector || "textarea");
textareaElements.forEach((textareaElement, index) => {
const answerText = question.answer[index] || "";
try {
const ueditor = this._window.UE?.getEditor(textareaElement.name);
if (ueditor?.setContent) { ueditor.setContent(answerText); return; }
} catch (e) {}
textareaElement.value = answerText;
});
} else if (question.type === "3") {
let answer = "true";
const firstAnswer = question.answer[0] || "";
if (firstAnswer.match(/(^|,)(正确|是|对|√|T|ri|right|true)(,|$)/i)) answer = "true";
else if (firstAnswer.match(/(^|,)(错误|否|错|×|F|wr|wrong|false)(,|$)/i)) answer = "false";
const trueOrFalse = { "true": "对", "false": "错" };
for (const key in question.options) {
const optEl = question.options[key];
if (["zj", "zy"].includes(this.type)) {
const ariaLabel = optEl.getAttribute("aria-label") || "";
if (ariaLabel.includes(`${trueOrFalse[answer]}选择`)) {
if (optEl.getAttribute("aria-checked") !== "true") optEl.click();
}
} else if (this.type === "ks") {
const optionElement = optEl.querySelector(`span[data='${answer}']`);
if (optionElement && !optionElement.querySelector(".check_answer")) optionElement.click();
}
}
} else if (question.type === "4" || question.type === "6") {
const textareaElement = question.element.querySelector(CX_QUESTION_RULES[this.type]?.textareaSelector || "textarea");
if (!textareaElement) return;
const answerText = question.answer[0] || "";
try {
const ueditor = this._window.UE?.getEditor(textareaElement.name);
if (ueditor?.setContent) { ueditor.setContent(answerText); return; }
} catch (e) {}
textareaElement.value = answerText;
}
} catch (e) {
console.warn("填写答案出错:", e);
}
}
}
// ==================== 16. 章节学习逻辑(简化重写)====================
const useCxChapterLogic = () => {
const logStore = useLogStore();
const configStore = useConfigStore();
if (!LicenseService.isValid()) {
logStore.addLog("许可验证未通过,功能已锁定", "danger");
return;
}
let currentTaskId = 0;
const MAIN_LOOP_CONFIG = {
tickMs: 2000
};
const WATCHDOG_CONFIG = {
checkMs: 3000,
staleActivityMs: 15000,
staleTaskMs: 20000,
cooldownMs: 10000
};
let mainLoopTimer = null;
let watchdogTimer = null;
let lastActivityAt = Date.now();
let lastPendingLogAt = 0;
let lastWatchdogRecoverAt = 0;
let watchdogRecoveryLevel = 0;
let activeTaskKey = "";
let activeTaskLabel = "";
let activeTaskFrame = null;
let activeTaskStartedAt = 0;
const TASK_STATE_TTL_MS = 10 * 60 * 1000;
const TASK_MAX_UNRESOLVED_ATTEMPTS = 3;
const PAGE_TURN_GUARD_MS = 2500;
const taskRegistry = new Map();
let currentPageTaskKeys = [];
const CHAPTER_PHASES = {
DISCOVER: "DISCOVER",
EXECUTE: "EXECUTE",
VERIFY: "VERIFY",
NEXT: "NEXT"
};
let chapterPhase = CHAPTER_PHASES.DISCOVER;
let phaseBusy = false;
let queuedTaskFrames = [];
let pendingJumpStats = null;
let lastAutoNextHintAt = 0;
let lastInnerPageTurnAt = 0;
let pageContext = {
url: window.location.href,
chapterId: "",
iframeSrc: ""
};
let jobTipTimer = null;
const clearJumpTimers = () => {
if (jobTipTimer) {
clearTimeout(jobTipTimer);
jobTipTimer = null;
}
};
const touchActivity = () => {
lastActivityAt = Date.now();
};
const touchProgress = () => { lastActivityAt = Date.now(); };
const isPageVisible = () => {
try {
const rootDoc = getRootDoc();
return !rootDoc.hidden && rootDoc.visibilityState !== "hidden";
} catch {
return !document.hidden && document.visibilityState !== "hidden";
}
};
const resetWatchdogRecovery = () => {
watchdogRecoveryLevel = 0;
};
const clearActiveTaskState = () => {
activeTaskKey = "";
activeTaskLabel = "";
activeTaskFrame = null;
activeTaskStartedAt = 0;
};
const getTaskKeysFromDetails = (details = []) => Array.from(new Set(
details.map((detail) => detail?.meta?.taskKey).filter(Boolean)
));
const syncCurrentPageTaskKeys = (details = []) => {
const nextKeys = getTaskKeysFromDetails(details);
if (nextKeys.length > 0) {
currentPageTaskKeys = nextKeys;
}
return currentPageTaskKeys;
};
const logPendingTasks = (stats) => {
if (!stats || stats.total <= 0) return;
const now = Date.now();
if (now - lastPendingLogAt < 4000) return;
lastPendingLogAt = now;
logStore.addLog(`检测到仍有未完成任务点(${stats.pendingCount}/${stats.total}),暂不跳转`, "warning");
};
const isMainIframeReady = () => {
const mainIframe = getMainIframe();
if (!mainIframe) return false;
try {
const doc = mainIframe.contentDocument;
if (!doc) return false;
return doc.readyState === "interactive" || doc.readyState === "complete";
} catch {
return false;
}
};
const evaluateJumpDecision = (stats) => {
const resolvedCount = (stats?.completedCount || 0) + (stats?.ignoredCount || 0);
const total = Number(stats?.total || 0);
const mainReady = isMainIframeReady();
if (!mainReady) {
return { canJump: false, reason: "main_not_ready", resolvedCount };
}
if (total > 0 && resolvedCount !== total) {
return { canJump: false, reason: "pending_tasks", resolvedCount };
}
return { canJump: true, reason: total > 0 ? "all_resolved" : "no_tasks", resolvedCount };
};
const touchTaskState = (meta, patch = {}) => {
if (!meta?.taskKey) return;
const now = Date.now();
const current = taskRegistry.get(meta.taskKey) || {
firstSeenAt: now,
lastSeenAt: now,
lastProgressAt: now,
finished: false,
saved: false,
ignored: false,
unresolvedCount: 0
};
current.lastSeenAt = now;
if (patch.progress) current.lastProgressAt = now;
if (patch.finished === true) {
current.finished = true;
current.unresolvedCount = 0;
}
if (patch.finished === false) current.finished = false;
if (patch.saved === true) current.saved = true;
if (patch.saved === false) current.saved = false;
if (patch.saved === true) current.unresolvedCount = 0;
if (patch.ignored === true) {
current.ignored = true;
current.finished = true;
}
if (patch.ignored === false) current.ignored = false;
if (patch.awaitingForeground === true) current.awaitingForeground = true;
if (patch.awaitingForeground === false) current.awaitingForeground = false;
if (patch.waitVisibleLogged === true) current.waitVisibleLogged = true;
if (patch.waitVisibleLogged === false) current.waitVisibleLogged = false;
if (patch.reloadPending === true) current.reloadPending = true;
if (patch.reloadPending === false) current.reloadPending = false;
if (typeof patch.reloadRequestedAt === "number") current.reloadRequestedAt = patch.reloadRequestedAt;
if (patch.giveUpLogged === true) current.giveUpLogged = true;
if (patch.type) current.type = patch.type;
taskRegistry.set(meta.taskKey, current);
};
const markTaskUnresolved = (meta, reason = "任务处理后仍未完成") => {
if (!meta?.taskKey) return { attempts: 0, gaveUp: false, gaveUpNow: false };
const now = Date.now();
const current = taskRegistry.get(meta.taskKey) || {
firstSeenAt: now,
lastSeenAt: now,
lastProgressAt: now,
finished: false,
saved: false,
ignored: false,
unresolvedCount: 0
};
current.lastSeenAt = now;
if (meta.taskType) current.type = meta.taskType;
if (current.ignored) {
taskRegistry.set(meta.taskKey, current);
return {
attempts: Number(current.unresolvedCount || TASK_MAX_UNRESOLVED_ATTEMPTS),
gaveUp: true,
gaveUpNow: false
};
}
if (current.finished) {
taskRegistry.set(meta.taskKey, current);
return { attempts: Number(current.unresolvedCount || 0), gaveUp: false, gaveUpNow: false };
}
current.unresolvedCount = Number(current.unresolvedCount || 0) + 1;
current.lastUnresolvedReason = reason;
let gaveUpNow = false;
if (current.unresolvedCount >= TASK_MAX_UNRESOLVED_ATTEMPTS) {
current.ignored = true;
current.finished = true;
current.giveUpAt = now;
current.giveUpReason = reason;
gaveUpNow = true;
}
taskRegistry.set(meta.taskKey, current);
return {
attempts: current.unresolvedCount,
gaveUp: current.ignored,
gaveUpNow
};
};
const markTaskAttemptResult = (meta, log, reason, type) => {
const unresolved = markTaskUnresolved(meta, reason);
touchTaskState(meta, {
ignored: unresolved.gaveUp,
giveUpLogged: unresolved.gaveUp,
type
});
if (unresolved.gaveUpNow || unresolved.gaveUp) {
log(`任务点连续 ${TASK_MAX_UNRESOLVED_ATTEMPTS} 次未完成,已放弃`, "warning");
return;
}
log(`${reason}(${unresolved.attempts}/${TASK_MAX_UNRESOLVED_ATTEMPTS})`, "warning");
};
const markTaskProgress = (meta) => {
touchProgress();
resetWatchdogRecovery();
touchTaskState(meta, { progress: true, type: meta?.taskType });
};
const setActiveTask = (meta, iframe = null) => {
const nextKey = meta?.taskKey || "";
if (nextKey && nextKey !== activeTaskKey) {
activeTaskStartedAt = Date.now();
}
activeTaskKey = nextKey;
activeTaskLabel = meta?.taskLabel || "";
activeTaskFrame = iframe || activeTaskFrame;
if (nextKey && isPageVisible()) {
touchTaskState(meta, { awaitingForeground: false, waitVisibleLogged: false, type: meta?.taskType });
}
// 一旦开始处理真实任务,取消遗留的跳章/弹窗回调,避免跨页误触发。
clearJumpTimers();
markTaskProgress(meta);
};
const clearActiveTask = (meta) => {
if (!meta?.taskKey || meta.taskKey === activeTaskKey) {
clearActiveTaskState();
resetWatchdogRecovery();
touchProgress();
}
};
const resetChapterRuntime = () => {
touchActivity();
currentTaskId += 1;
taskRegistry.clear();
clearActiveTaskState();
resetWatchdogRecovery();
queuedTaskFrames = [];
currentPageTaskKeys = [];
pendingJumpStats = null;
chapterPhase = CHAPTER_PHASES.DISCOVER;
clearJumpTimers();
};
const syncPageContext = () => {
const nextContext = {
url: window.location.href,
chapterId: getChapterId(),
iframeSrc: getMainIframe()?.src || ""
};
const changed = (
pageContext.url !== nextContext.url
|| pageContext.chapterId !== nextContext.chapterId
|| pageContext.iframeSrc !== nextContext.iframeSrc
);
pageContext = nextContext;
if (changed) {
resetChapterRuntime();
}
return changed;
};
const init = () => {
const currentUrl = window.location.href;
if (!currentUrl.includes("&mooc2=1")) {
window.location.href = currentUrl + "&mooc2=1";
}
pageContext = {
url: currentUrl,
chapterId: getChapterId(),
iframeSrc: getMainIframe()?.src || ""
};
logStore.addLog("检测到用户进入到章节学习页面", "primary");
logStore.addLog("正在解析任务点,请稍等5-10秒(如果长时间没有反应,请刷新页面)", "warning");
touchActivity();
};
const runDiscoverPhase = async () => {
const mainIframe = getMainIframe();
if (!mainIframe) return;
const innerDoc = safeDoc(mainIframe);
const taskEntries = getTaskEntries(innerDoc).filter((entry) => {
const type = getTaskTypeFromSrc(entry?.iframe?.src || "");
if (configStore.chapterSettings.onlyQuiz) return type === "work";
return true;
});
const discoveredDetails = [];
taskEntries.forEach((entry, index) => {
const iframe = entry.iframe;
iframe.__cxTaskOrder = index + 1;
iframe.__cxTaskTotal = taskEntries.length;
const meta = buildTaskMeta(iframe);
touchTaskState(meta, { type: meta.taskType });
discoveredDetails.push({ meta });
});
syncCurrentPageTaskKeys(discoveredDetails);
queuedTaskFrames = taskEntries.map((entry) => entry.iframe);
chapterPhase = queuedTaskFrames.length > 0 ? CHAPTER_PHASES.EXECUTE : CHAPTER_PHASES.VERIFY;
touchActivity();
};
const runExecutePhase = async () => {
if (queuedTaskFrames.length <= 0) {
chapterPhase = CHAPTER_PHASES.VERIFY;
return;
}
const iframe = queuedTaskFrames.shift();
const taskVersion = ++currentTaskId;
await processIframe(iframe, taskVersion);
if (queuedTaskFrames.length <= 0) {
chapterPhase = CHAPTER_PHASES.VERIFY;
}
};
const runVerifyPhase = async () => {
const stats = await getTaskStats();
const decision = evaluateJumpDecision(stats);
if (!decision.canJump) {
if (decision.reason === "pending_tasks") {
logPendingTasks(stats);
}
chapterPhase = CHAPTER_PHASES.DISCOVER;
return;
}
// 分页视为同一章节内的连续页面,优先进入下一分页。
if (getNextTaskPageAction()) {
pendingJumpStats = stats;
chapterPhase = CHAPTER_PHASES.NEXT;
return;
}
if (!configStore.chapterSettings.autoNext) {
const now = Date.now();
if (now - lastAutoNextHintAt > 10000) {
lastAutoNextHintAt = now;
logStore.addLog("已经关闭自动下一章节,在设置里可更改", "warning");
}
chapterPhase = CHAPTER_PHASES.DISCOVER;
return;
}
pendingJumpStats = stats;
chapterPhase = CHAPTER_PHASES.NEXT;
};
const runNextPhase = async () => {
const stats = pendingJumpStats || await getTaskStats();
pendingJumpStats = null;
const pageJump = await tryJumpNextTaskPage();
if (pageJump.jumped) {
logStore.addLog(
`本分页任务点共${stats.total}个,完成${stats.completedCount}个,放弃${stats.ignoredCount}个,继续处理下一分页(${pageJump.pageLabel})`,
"success"
);
resetChapterRuntime();
return;
}
const jumpContext = {
chapterId: getChapterId(),
pageUrl: window.location.href,
iframeSrc: getMainIframe()?.src || "",
taskVersion: currentTaskId
};
const jumped = await tryJumpNextChapter();
if (!jumped) {
logStore.addLog("未找到下一章节入口,稍后将继续尝试", "warning");
chapterPhase = CHAPTER_PHASES.DISCOVER;
return;
}
logStore.addLog(
`本页任务点共${stats.total}个,完成${stats.completedCount}个,放弃${stats.ignoredCount}个,正前往下一章节`,
"success"
);
if (jobTipTimer) clearTimeout(jobTipTimer);
jobTipTimer = setTimeout(() => {
jobTipTimer = null;
handleJobFinishTip(jumpContext);
}, 1500);
chapterPhase = CHAPTER_PHASES.DISCOVER;
};
const runChapterTick = async () => {
if (globalPauseState.isPaused) return;
syncPageContext();
if (chapterPhase === CHAPTER_PHASES.DISCOVER) {
await runDiscoverPhase();
return;
}
if (chapterPhase === CHAPTER_PHASES.EXECUTE) {
await runExecutePhase();
return;
}
if (chapterPhase === CHAPTER_PHASES.VERIFY) {
await runVerifyPhase();
return;
}
if (chapterPhase === CHAPTER_PHASES.NEXT) {
await runNextPhase();
}
};
const getActiveTaskState = () => {
if (!activeTaskKey) return null;
return taskRegistry.get(activeTaskKey) || null;
};
const recoverWithLevel = (level, reason) => {
const now = Date.now();
if (now - lastWatchdogRecoverAt < WATCHDOG_CONFIG.cooldownMs) return false;
lastWatchdogRecoverAt = now;
watchdogRecoveryLevel = Math.max(watchdogRecoveryLevel, level);
logStore.addLog("检测到任务停滞,准备恢复", "warning");
if (level === 1) {
const taskFrame = activeTaskFrame;
currentTaskId += 1;
clearActiveTaskState();
if (taskFrame) {
if (!queuedTaskFrames.includes(taskFrame)) {
queuedTaskFrames.unshift(taskFrame);
}
chapterPhase = CHAPTER_PHASES.EXECUTE;
touchActivity();
logStore.addLog(`恢复 L1:重新执行当前任务(${reason})`, "warning");
return true;
}
}
if (level === 2) {
currentTaskId += 1;
clearActiveTaskState();
queuedTaskFrames = [];
pendingJumpStats = null;
chapterPhase = CHAPTER_PHASES.DISCOVER;
touchActivity();
logStore.addLog(`恢复 L2:重新扫描当前页面任务(${reason})`, "warning");
return true;
}
if (level === 3) {
currentTaskId += 1;
const taskFrame = activeTaskFrame;
clearActiveTaskState();
queuedTaskFrames = [];
pendingJumpStats = null;
chapterPhase = CHAPTER_PHASES.DISCOVER;
try {
if (taskFrame?.src) {
taskFrame.src = taskFrame.src;
touchActivity();
logStore.addLog(`恢复 L3:重载当前任务 iframe(${reason})`, "warning");
return true;
}
} catch {}
}
currentTaskId += 1;
clearActiveTaskState();
queuedTaskFrames = [];
pendingJumpStats = null;
chapterPhase = CHAPTER_PHASES.DISCOVER;
try {
const mainIframe = getMainIframe();
if (mainIframe?.src) {
mainIframe.src = mainIframe.src;
touchActivity();
logStore.addLog(`恢复 L4:重载主章节 iframe(${reason})`, "warning");
return true;
}
} catch {}
return false;
};
const runWatchdogTick = () => {
if (globalPauseState.isPaused) return;
const now = Date.now();
const activeState = getActiveTaskState();
const taskMissing = !!activeTaskKey && (!activeTaskFrame || !activeTaskFrame.isConnected || !safeDoc(activeTaskFrame));
if (taskMissing) {
recoverWithLevel(Math.min(watchdogRecoveryLevel + 1, 4), "当前任务已失效");
return;
}
if (activeTaskKey && activeState) {
const lastProgressAt = Number(activeState.lastProgressAt || activeTaskStartedAt || now);
if (now - lastProgressAt > WATCHDOG_CONFIG.staleTaskMs) {
recoverWithLevel(Math.min(watchdogRecoveryLevel + 1, 4), "当前任务长时间无进展");
return;
}
}
if (now - lastActivityAt > WATCHDOG_CONFIG.staleActivityMs) {
recoverWithLevel(Math.min(watchdogRecoveryLevel + 1, 4), "章节状态机长时间无响应");
}
};
const startMainLoop = () => {
if (mainLoopTimer) clearInterval(mainLoopTimer);
mainLoopTimer = setInterval(() => {
if (phaseBusy) return;
phaseBusy = true;
Promise.resolve(runChapterTick())
.catch((e) => console.warn("章节状态机异常:", e))
.finally(() => { phaseBusy = false; });
}, MAIN_LOOP_CONFIG.tickMs);
};
const startWatchdog = () => {
if (watchdogTimer) clearInterval(watchdogTimer);
watchdogTimer = setInterval(() => {
try {
runWatchdogTick();
} catch (e) {
console.warn("章节 watchdog 异常:", e);
}
}, WATCHDOG_CONFIG.checkMs);
};
// 安全获取 iframe 的 contentDocument
const safeDoc = (iframe) => {
try { return iframe.contentDocument; } catch { return null; }
};
const waitIframeLoad = (iframe) => {
return new Promise((resolve) => {
let timeoutId = null;
let settled = false;
const done = () => {
if (settled) return;
settled = true;
if (timeoutId) clearTimeout(timeoutId);
iframe.removeEventListener("load", onLoad);
resolve();
};
const onLoad = () => done();
const doc = safeDoc(iframe);
if (doc && (doc.readyState === "interactive" || doc.readyState === "complete")) {
done();
return;
}
timeoutId = setTimeout(done, 30000);
iframe.addEventListener("load", onLoad, { once: true });
});
};
const isDocTask = (src) => CX_TASK_TYPE_RULES.docModules.some((type) => src.includes(`modules/${type}`));
const isBookTask = (src) => src.includes("modules/innerbook");
let taskSeq = 0;
const cleanText = (text) => {
if (!text) return "";
return String(text).replace(/\s+/g, " ").replace(/\u00a0/g, " ").trim();
};
const isVisibleElement = (el) => {
if (!el || !el.ownerDocument) return false;
const doc = el.ownerDocument;
if (!doc.documentElement?.contains(el)) return false;
try {
const style = (doc.defaultView || window).getComputedStyle(el);
if (!style) return false;
if (style.display === "none" || style.visibility === "hidden") return false;
if (Number(style.opacity) === 0) return false;
if (el.offsetParent === null && style.position !== "fixed") return false;
const rect = el.getBoundingClientRect?.();
if (rect && rect.width <= 0 && rect.height <= 0) return false;
return true;
} catch {
return true;
}
};
const includesAny = (text, items) => items.some((item) => text.includes(item));
const shortText = (text, max = 24) => {
if (!text) return "";
const clean = cleanText(text);
if (clean.length <= max) return clean;
return clean.slice(0, max) + "...";
};
const getRootDoc = () => {
try { return window.top.document; } catch { return document; }
};
const queryFirst = (root, selectors) => {
for (const selector of selectors) {
const matched = root.querySelector(selector);
if (matched) return matched;
}
return null;
};
const queryAny = (root, selectors) => selectors.some((selector) => root.querySelector(selector));
const getMainIframe = () => {
const rootDoc = getRootDoc();
return queryFirst(rootDoc, CX_SELECTORS.mainIframe);
};
const getPageLabel = () => {
const rootDoc = getRootDoc();
for (const sel of CX_SELECTORS.pageLabel) {
const el = rootDoc.querySelector(sel);
const text = cleanText(el?.textContent || "");
if (text) return shortText(text, 32);
}
return shortText(rootDoc.title || document.title || "", 32);
};
const getChapterInfo = () => {
const rootDoc = getRootDoc();
const activeNode = queryFirst(rootDoc, CX_SELECTORS.activeChapter);
const code = cleanText(activeNode?.querySelector(CX_SELECTORS.chapterCode)?.textContent || "");
let title = cleanText(activeNode?.getAttribute("title") || activeNode?.textContent || "");
if (code && title.includes(code)) {
title = cleanText(title.replace(code, ""));
}
if (!title) {
title = getPageLabel();
}
const parts = code ? code.split(".").filter(Boolean) : [];
let chapterLabel = "";
if (parts[0]) chapterLabel += `第${parts[0]}章`;
if (parts[1]) chapterLabel += `第${parts[1]}节`;
return { code, title, chapterLabel };
};
const getChapterId = () => {
const rootDoc = getRootDoc();
return queryFirst(rootDoc, CX_SELECTORS.chapterId)?.value || "";
};
const hasDirectJobIcon = (node) => {
if (!node?.querySelectorAll) return false;
return Array.from(node.querySelectorAll(CX_SELECTORS.jobIcon)).some((icon) => icon.parentElement === node);
};
const getTaskContainerFromIframe = (iframe) => {
let node = iframe?.parentElement || null;
while (node && node.nodeType === 1) {
if (hasDirectJobIcon(node)) return node;
node = node.parentElement;
}
return null;
};
const isTaskFinished = (iframe) => {
const container = getTaskContainerFromIframe(iframe);
if (!container) return false;
return container.classList.contains("ans-job-finished");
};
const getJobCounts = () => {
const mainIframe = getMainIframe();
const innerDoc = safeDoc(mainIframe);
if (!innerDoc) return { total: 0, pending: 0 };
const jobIcons = Array.from(innerDoc.querySelectorAll(CX_SELECTORS.jobIcon));
const total = jobIcons.length;
const pending = jobIcons.filter((icon) => !icon.parentElement?.classList.contains(CX_SELECTORS.jobFinishedClass)).length;
return { total, pending };
};
const scrollToUnfinishedJob = () => {
try {
if (typeof _unsafeWindow.scroll2Job === "function") {
_unsafeWindow.scroll2Job();
return true;
}
} catch {}
const mainIframe = getMainIframe();
const innerDoc = safeDoc(mainIframe);
if (!innerDoc) return false;
const jobIcons = Array.from(innerDoc.querySelectorAll(CX_SELECTORS.jobIcon));
const target = jobIcons.find((icon) => !icon.parentElement?.classList.contains(CX_SELECTORS.jobFinishedClass));
if (!target) return false;
const node = target.parentElement || target;
try {
node.scrollIntoView({ behavior: "smooth", block: "center" });
} catch {
node.scrollIntoView();
}
return true;
};
let lastJobTipAt = 0;
const isExpectedContextMatched = (expectedContext) => {
if (!expectedContext) return true;
if (typeof expectedContext === "string") {
return !expectedContext || getChapterId() === expectedContext;
}
if (expectedContext.chapterId && getChapterId() !== expectedContext.chapterId) return false;
if (expectedContext.pageUrl && window.location.href !== expectedContext.pageUrl) return false;
if (expectedContext.iframeSrc && (getMainIframe()?.src || "") !== expectedContext.iframeSrc) return false;
if (typeof expectedContext.taskVersion === "number" && currentTaskId !== expectedContext.taskVersion) return false;
return true;
};
const handleJobFinishTip = (expectedContext = null) => {
if (!isExpectedContextMatched(expectedContext)) return false;
const now = Date.now();
if (now - lastJobTipAt < 3000) return false;
const rootDoc = getRootDoc();
const tips = CX_SELECTORS.jobFinishTips.map((selector) => rootDoc.querySelector(selector)).filter(Boolean);
if (!tips.length) return false;
const isVisible = (el) => {
try {
if (el.style?.display === "none") return false;
const style = getComputedStyle(el);
return style.display !== "none" && style.visibility !== "hidden";
} catch {
return true;
}
};
const textOf = (el) => cleanText(
el?.textContent
|| el?.innerText
|| el?.value
|| el?.getAttribute?.("title")
|| ""
);
const pickContinueBtn = (tip) => {
const btns = Array.from(tip.querySelectorAll(CX_SELECTORS.jobFinishButtons));
if (!btns.length) return null;
const denyWords = ["返回", "继续学习", "取消", "关闭", "放弃"];
const preferWords = ["继续", "下一章", "下一节", "跳转", "确认", "确定"];
const filtered = btns.filter((el) => {
const t = textOf(el);
if (!t) return false;
return !denyWords.some((word) => t.includes(word));
});
return filtered.find((el) => {
const t = textOf(el);
return preferWords.some((word) => t.includes(word));
}) || filtered[0] || null;
};
let handled = false;
const taskRunning = !!activeTaskKey;
for (const tip of tips) {
if (!isVisible(tip)) continue;
if (taskRunning) {
const safeClose = tip.querySelector(CX_SELECTORS.jobFinishSafeClose);
if (safeClose?.click) {
safeClose.click();
handled = true;
break;
}
continue;
}
const continueBtn = pickContinueBtn(tip);
if (continueBtn?.click) {
continueBtn.click();
handled = true;
break;
}
const closeBtn = tip.querySelector(".popClose");
if (closeBtn?.click) {
closeBtn.click();
handled = true;
break;
}
}
if (handled) {
lastJobTipAt = now;
try { _unsafeWindow.WAY?.box?.hide?.(); } catch {}
if (taskRunning) {
logStore.addLog("检测到遗留提示,已关闭并继续当前任务", "warning");
return true;
}
logStore.addLog("检测到未完成提示,已关闭,等待任务扫描结果后再决定是否跳章", "warning");
}
return handled;
};
const getAllIframes = async (scanRoot) => {
if (!scanRoot) return [];
try {
return IframeUtils.getAllNestedIframes(scanRoot) || [];
} catch {
return [];
}
};
const getTaskTypeFromSrc = (src) => {
for (const rule of CX_TASK_TYPE_RULES.map) {
if (rule.keywords.some((keyword) => src.includes(keyword))) {
return rule.type;
}
}
return "task";
};
const isSupportedTaskType = (taskType) => ["work", "video", "audio", "doc", "book"].includes(taskType);
const getTaskIframePriority = (src) => {
if (!src || src.includes("javascript:")) return 0;
if (src.includes("api/work")) return 100;
if (src.includes("ananas/modules/work/index.html")) return 90;
if (src.includes("video")) return 80;
if (src.includes("audio")) return 70;
if (isDocTask(src)) return 60;
if (isBookTask(src)) return 50;
return 10;
};
const getTaskContainers = (rootDoc) => {
if (!rootDoc) return [];
const seen = new Set();
const containers = [];
const icons = Array.from(rootDoc.querySelectorAll(CX_SELECTORS.jobIcon));
icons.forEach((icon) => {
const container = icon.parentElement;
if (!container || seen.has(container)) return;
if (!isVisibleElement(container)) return;
seen.add(container);
containers.push(container);
});
return containers;
};
const getPrimaryTaskIframe = (container) => {
if (!container) return null;
let iframes = [];
try {
iframes = IframeUtils.getAllNestedIframes(container) || [];
} catch {}
if (!iframes.length) return null;
const candidates = iframes.filter((iframe) => {
const src = iframe?.src || "";
return src && !src.includes("javascript:");
});
if (!candidates.length) return null;
return candidates
.slice()
.sort((left, right) => {
const priorityDiff = getTaskIframePriority(right?.src || "") - getTaskIframePriority(left?.src || "");
if (priorityDiff !== 0) return priorityDiff;
const visibilityDiff = Number(isVisibleElement(right)) - Number(isVisibleElement(left));
if (visibilityDiff !== 0) return visibilityDiff;
return 0;
})[0] || null;
};
const getTaskEntries = (rootDoc) => {
return getTaskContainers(rootDoc)
.map((container) => {
const iframe = getPrimaryTaskIframe(container);
if (!iframe) return null;
return { container, iframe };
})
.filter(Boolean);
};
const getDocTextHint = (doc) => {
const body = doc?.body;
if (!body) return "";
const text = typeof body.innerText === "string" ? body.innerText : (body.textContent || "");
return text.length > 8000 ? text.slice(0, 8000) : text;
};
const getMediaDuration = (mediaEl) => {
const duration = Number.isFinite(mediaEl?.duration) ? mediaEl.duration : null;
if (!duration || duration <= 0) return null;
return duration;
};
const isMediaFinished = (mediaEl) => {
if (!mediaEl) return false;
if (mediaEl.ended) return true;
const duration = getMediaDuration(mediaEl);
if (!duration) return false;
const threshold = Math.max(duration - 0.5, duration * 0.98);
return mediaEl.currentTime >= threshold;
};
const isWorkFinishedByDoc = (doc) => {
if (!doc) return false;
const hint = cleanText(getDocTextHint(doc));
if (!hint) return false;
if (queryAny(doc, CX_SELECTORS.workDoneAttr)) return true;
const submitBtn = doc.querySelector(CX_SELECTORS.workSubmit);
const submitText = cleanText(submitBtn?.textContent || submitBtn?.value || "");
const submitDisabled = !!submitBtn && (
submitBtn.disabled
|| submitBtn.getAttribute("disabled") !== null
|| submitBtn.getAttribute("aria-disabled") === "true"
|| submitBtn.classList?.contains("disabled")
|| submitBtn.classList?.contains("btn-disabled")
);
const redoHints = ["重新作答", "再做", "重做", "重新答题", "继续作答", "继续答题", "再次作答", "再次答题", "重新做题", "查看解析", "查看答案", "查看成绩", "查看结果", "查看报告"];
if (submitText && includesAny(submitText, redoHints)) return true;
const actionEls = Array.from(doc.querySelectorAll(CX_SELECTORS.workAction));
const hasRedoAction = actionEls.some((el) => {
const text = cleanText(el?.textContent || el?.value || "");
return text && includesAny(text, redoHints);
});
if (hasRedoAction) return true;
const hasResultPanel = queryAny(doc, CX_SELECTORS.workResultPanels);
const hasScoreText = /得分\s*\d+|成绩\s*[::]?\s*\d+|分数\s*[::]?\s*\d+/.test(hint);
const questions = doc.querySelectorAll(CX_SELECTORS.workQuestion);
const inputs = doc.querySelectorAll(CX_SELECTORS.workInput);
const hasInputs = inputs.length > 0;
const hasEditableInput = hasInputs && Array.from(inputs).some((el) => {
const disabled = el.disabled || el.getAttribute("disabled") !== null;
const readonly = el.readOnly || el.getAttribute("readonly") !== null;
return !(disabled || readonly);
});
if (questions.length > 0 && hasInputs && !hasEditableInput) return true;
if ((hasResultPanel || hasScoreText) && (!submitBtn || submitDisabled || !hasEditableInput)) return true;
const doneHints = [
"作业已完成", "本次作业已完成", "已提交", "已交卷", "已批阅", "待批阅",
"已评分", "成绩", "得分", "提交时间", "完成率100", "答题完成", "提交成功",
"正确答案", "查看解析"
];
const hasDoneHint = includesAny(hint, doneHints);
if (hasDoneHint) {
const notDoneHints = ["未完成", "未提交", "未作答", "未答题", "未交", "未做", "未交卷", "未开始"];
if (!includesAny(hint, notDoneHints) || hasResultPanel || hasScoreText || submitDisabled) return true;
}
return false;
};
const WORK_FLOW_STATES = {
PENDING: "pending",
SAVED: "saved",
FINISHED: "finished",
CAPTCHA: "captcha",
ERROR: "error",
TIMEOUT: "timeout"
};
const getWorkActionStatus = (doc) => {
const docs = [];
if (doc) docs.push(doc);
const rootDoc = getRootDoc();
if (rootDoc && rootDoc !== doc) docs.push(rootDoc);
for (const currentDoc of docs) {
if (!currentDoc) continue;
const successTips = Array.from(currentDoc.querySelectorAll(CX_SELECTORS.workSuccessTip));
const visibleSuccessTip = successTips.find((el) => isVisibleElement(el) && cleanText(el.textContent || "").length > 0);
if (visibleSuccessTip) {
return { state: WORK_FLOW_STATES.SAVED, message: cleanText(visibleSuccessTip.textContent || "") };
}
const errorTips = Array.from(currentDoc.querySelectorAll(CX_SELECTORS.workErrorTip));
const visibleErrorTip = errorTips.find((el) => isVisibleElement(el) && cleanText(el.textContent || "").length > 0);
if (visibleErrorTip) {
return { state: WORK_FLOW_STATES.ERROR, message: cleanText(visibleErrorTip.textContent || "") };
}
const captchaEls = Array.from(currentDoc.querySelectorAll(CX_SELECTORS.workCaptcha));
const visibleCaptcha = captchaEls.find((el) => isVisibleElement(el));
if (visibleCaptcha) {
return { state: WORK_FLOW_STATES.CAPTCHA, message: "检测到验证码弹窗" };
}
}
return { state: WORK_FLOW_STATES.PENDING, message: "" };
};
const waitWorkActionResult = async (doc, mode = "save", timeoutMs = 12000, context = {}) => {
const initialDoc = doc || null;
const getLiveDoc = () => {
if (context?.iframe) {
const live = safeDoc(context.iframe);
if (live) return live;
}
return doc || null;
};
const getLiveHref = () => {
try {
return context?.iframe?.contentWindow?.location?.href || "";
} catch {
return "";
}
};
const initialHref = getLiveHref();
let observedDocSwap = false;
let observedHrefSwap = false;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const liveDoc = getLiveDoc();
if (liveDoc && initialDoc && liveDoc !== initialDoc) {
observedDocSwap = true;
}
const liveHref = getLiveHref();
if (initialHref && liveHref && liveHref !== initialHref) {
observedHrefSwap = true;
}
if (isWorkFinishedByDoc(liveDoc || doc)) return { state: WORK_FLOW_STATES.FINISHED, message: "" };
const status = getWorkActionStatus(liveDoc || doc);
if (status.state === WORK_FLOW_STATES.CAPTCHA || status.state === WORK_FLOW_STATES.ERROR) return status;
if (mode === "save" && status.state === WORK_FLOW_STATES.SAVED) return status;
if (mode === "save" && (observedDocSwap || observedHrefSwap)) {
return { state: WORK_FLOW_STATES.SAVED, message: "暂存后页面已刷新" };
}
await sleep(0.5);
}
const finalDoc = getLiveDoc() || doc;
if (isWorkFinishedByDoc(finalDoc)) return { state: WORK_FLOW_STATES.FINISHED, message: "" };
const finalStatus = getWorkActionStatus(finalDoc);
if (mode === "save" && (finalStatus.state === WORK_FLOW_STATES.SAVED || observedDocSwap || observedHrefSwap)) {
return { state: WORK_FLOW_STATES.SAVED, message: finalStatus.message || "暂存后页面已刷新" };
}
if (finalStatus.state === WORK_FLOW_STATES.CAPTCHA || finalStatus.state === WORK_FLOW_STATES.ERROR) {
return finalStatus;
}
return { state: WORK_FLOW_STATES.TIMEOUT, message: "" };
};
const getTaskDetail = (iframe) => {
const src = iframe?.src || "";
const doc = safeDoc(iframe);
const type = getTaskTypeFromSrc(src);
const meta = buildTaskMeta(iframe);
const state = taskRegistry.get(meta.taskKey) || {};
const saved = !!state.saved;
const ignored = !!state.ignored;
const finishedByDom = isTaskFinished(iframe);
let finishedByContent = false;
let mediaTime = null;
if (doc) {
if (type === "work") {
finishedByContent = isWorkFinishedByDoc(doc);
} else if (type === "video" || type === "audio") {
const mediaEl = doc.querySelector(type === "video" ? CX_SELECTORS.media.video : CX_SELECTORS.media.audio);
if (mediaEl) {
mediaTime = Number.isFinite(mediaEl.currentTime) ? mediaEl.currentTime : null;
finishedByContent = isMediaFinished(mediaEl);
}
} else if (type === "doc" || type === "book") {
const hint = getDocTextHint(doc);
finishedByContent = /已完成/.test(hint);
}
}
return {
iframe,
meta,
type,
src,
finished: finishedByDom || finishedByContent || !!state.finished || saved || ignored,
saved,
ignored,
mediaTime
};
};
const updateTaskRegistry = (details) => {
const now = Date.now();
const seen = new Set();
details.forEach((detail) => {
const key = detail.meta?.taskKey;
if (!key) return;
seen.add(key);
const current = taskRegistry.get(key) || {
firstSeenAt: now,
lastSeenAt: now,
lastProgressAt: now,
lastMediaTime: null,
finished: false,
saved: false,
ignored: false,
unresolvedCount: 0
};
current.lastSeenAt = now;
if (detail.mediaTime != null) {
if (current.lastMediaTime == null || detail.mediaTime > current.lastMediaTime + 0.1) {
current.lastMediaTime = detail.mediaTime;
current.lastProgressAt = now;
}
}
if (detail.saved) current.saved = true;
if (detail.finished && !current.finished) {
current.completedAt = now;
}
if (detail.finished) current.unresolvedCount = 0;
current.ignored = current.ignored || !!detail.ignored;
current.finished = current.finished || current.saved || detail.finished || current.ignored;
current.type = detail.type;
taskRegistry.set(key, current);
});
for (const [key, state] of taskRegistry) {
if (!seen.has(key) && now - state.lastSeenAt > TASK_STATE_TTL_MS) {
taskRegistry.delete(key);
}
}
};
const hasPendingWork = () => {
for (const state of taskRegistry.values()) {
if (state.type === "work" && !state.finished && !state.ignored) return true;
}
return false;
};
const getTaskStats = async () => {
const mainIframe = getMainIframe();
const innerDoc = safeDoc(mainIframe);
const taskEntries = getTaskEntries(innerDoc).filter((entry) => {
const type = getTaskTypeFromSrc(entry?.iframe?.src || "");
if (configStore.chapterSettings.onlyQuiz) return type === "work";
return true;
});
const details = taskEntries.map((entry) => getTaskDetail(entry.iframe));
updateTaskRegistry(details);
syncCurrentPageTaskKeys(details);
const totalCount = currentPageTaskKeys.length;
let pendingCount = 0;
let ignoredCount = 0;
let completedCount = 0;
currentPageTaskKeys.forEach((taskKey) => {
const state = taskRegistry.get(taskKey);
if (state?.ignored) {
ignoredCount += 1;
return;
}
if (state?.finished) {
completedCount += 1;
return;
}
pendingCount += 1;
});
return { total: totalCount, pendingCount, completedCount, ignoredCount };
};
const findNextChapterTarget = () => {
const rootDoc = getRootDoc();
const current = queryFirst(rootDoc, CX_SELECTORS.currentChapter);
if (!current) return null;
let node = current.nextElementSibling;
while (node) {
if (node.nodeType !== 1) {
node = node.nextElementSibling;
continue;
}
const disabled = node.classList.contains("disabled")
|| node.classList.contains("posCatalog_disabled")
|| node.classList.contains("posCatalog_locked");
const target = queryFirst(node, CX_SELECTORS.currentChapterTarget) || node;
if (!disabled && target) {
return target;
}
node = node.nextElementSibling;
}
return null;
};
const isPagerItemActive = (item) => {
if (!item) return false;
const className = item.className || "";
if (/(^|\s)(active|current|currents|cur|on|select|selected)(\s|$)/i.test(className)) return true;
if (item.getAttribute?.("aria-current") === "true") return true;
const a = item.querySelector?.(CX_SELECTORS.pagerItemTarget);
if (a && /(active|current|currents|cur|on|select|selected)/i.test(a.className || "")) return true;
return false;
};
const getTaskPagerCandidates = () => {
const docs = [];
const rootDoc = getRootDoc();
const mainDoc = safeDoc(getMainIframe());
if (mainDoc) docs.push(mainDoc);
if (rootDoc && rootDoc !== mainDoc) docs.push(rootDoc);
const results = [];
const seen = new Set();
for (const doc of docs) {
if (!doc) continue;
const pagers = doc.querySelectorAll(CX_SELECTORS.pagerList);
for (const pager of pagers) {
if (!pager || seen.has(pager)) continue;
seen.add(pager);
if (!isVisibleElement(pager)) continue;
results.push({ doc, pager });
}
}
return results;
};
const getNextTaskPageAction = () => {
const candidates = getTaskPagerCandidates();
for (const { pager } of candidates) {
let items = Array.from(pager.children || []).filter((el) => el?.nodeType === 1);
if (!items.length) {
items = Array.from(pager.querySelectorAll("li"));
}
items = items.filter((item) => isVisibleElement(item));
if (items.length < 2) continue;
let activeIndex = items.findIndex((item) => isPagerItemActive(item));
if (activeIndex < 0) {
// 未识别激活页时,避免误跳,交给章节跳转分支。
continue;
}
const nextIndex = activeIndex + 1;
if (nextIndex >= items.length) continue;
const nextItem = items[nextIndex];
const nextClass = nextItem.className || "";
if (/(^|\s)(disabled|ban|forbid)(\s|$)/i.test(nextClass)) continue;
const nextTarget = nextItem.querySelector(CX_SELECTORS.pagerItemTarget) || nextItem;
if (!nextTarget?.click) continue;
const nextLabel = cleanText(nextItem.textContent || "") || `第${nextIndex + 1}页`;
return {
pageLabel: nextLabel,
click: () => {
try {
nextTarget.click();
return true;
} catch {
return false;
}
}
};
}
return null;
};
const tryJumpNextTaskPage = async () => {
const now = Date.now();
if (now - lastInnerPageTurnAt < PAGE_TURN_GUARD_MS) {
return { jumped: false, pageLabel: "" };
}
const action = getNextTaskPageAction();
if (!action) {
return { jumped: false, pageLabel: "" };
}
const clicked = action.click();
if (!clicked) {
return { jumped: false, pageLabel: "" };
}
lastInnerPageTurnAt = Date.now();
touchProgress();
return { jumped: true, pageLabel: action.pageLabel };
};
const tryJumpNextChapter = async () => {
const rootDoc = getRootDoc();
const nextBtn = rootDoc.querySelector(CX_SELECTORS.nextChapter[0]);
if (nextBtn && isVisibleElement(nextBtn)) {
nextBtn.click();
return true;
}
const jumpBtn = rootDoc.querySelector(CX_SELECTORS.nextChapter[1]);
if (jumpBtn && isVisibleElement(jumpBtn)) {
jumpBtn.click();
return true;
}
const target = findNextChapterTarget();
if (target?.click) {
target.click();
return true;
}
return false;
};
const extractChapterSection = (text) => {
const clean = cleanText(text);
if (!clean) return "";
const chapterMatch = clean.match(/第\s*([0-9一二三四五六七八九十百]+)\s*章/);
const sectionMatch = clean.match(/第\s*([0-9一二三四五六七八九十百]+)\s*节/);
let chapter = chapterMatch?.[1] || "";
let section = sectionMatch?.[1] || "";
if (!chapter || !section) {
const numberMatch = clean.match(/(\d+(?:\.\d+)+)/);
if (numberMatch) {
const parts = numberMatch[1].split(".").filter(Boolean);
if (!chapter && parts[0]) chapter = parts[0];
if (!section && parts[1]) section = parts[1];
}
}
if (!chapter && !section) return "";
return `${chapter ? `第${chapter}章` : ""}${section ? `第${section}节` : ""}`;
};
const getIframeJobInfo = (iframe) => {
let jobId = iframe?.getAttribute?.("jobid") || "";
let objectId = iframe?.getAttribute?.("objectid") || "";
let mid = iframe?.getAttribute?.("mid") || "";
let title = iframe?.getAttribute?.("title") || "";
const dataAttr = iframe?.getAttribute?.("data") || "";
if (dataAttr) {
try {
const data = JSON.parse(dataAttr);
jobId = jobId || data.jobid || data._jobid || "";
objectId = objectId || data.objectid || data.objectId || "";
mid = mid || data.mid || "";
title = title || data.name || data.title || "";
} catch {}
}
return { jobId, objectId, mid, title };
};
const buildTaskMeta = (iframe) => {
const src = iframe?.src || "";
const taskType = getTaskTypeFromSrc(src);
const taskContainer = getTaskContainerFromIframe(iframe);
let typeName = "任务点";
if (taskType === "work") typeName = "作业";
else if (taskType === "video") typeName = "视频";
else if (taskType === "audio") typeName = "音频";
else if (taskType === "doc") typeName = "文档";
else if (taskType === "book") typeName = "电子书";
const { jobId, objectId, mid } = getIframeJobInfo(iframe);
let id = "";
try {
const url = new URL(src, window.location.href);
id = url.searchParams.get("jobid")
|| url.searchParams.get("jobId")
|| url.searchParams.get("objectid")
|| url.searchParams.get("objectId")
|| url.searchParams.get("mid")
|| url.searchParams.get("knowledgeId")
|| "";
} catch {}
if (!id) {
id = jobId || objectId || mid || "";
}
const taskHolder = taskContainer || iframe;
if (!taskHolder.__cxTaskKey) {
const chapterId = getChapterId();
const keyParts = [typeName, chapterId, jobId || objectId || mid || ""].filter(Boolean);
if (keyParts.length >= 2) {
taskHolder.__cxTaskKey = keyParts.join("-");
} else if (id) {
taskHolder.__cxTaskKey = `${typeName}-${id}`;
} else if (iframe?.__cxTaskOrder) {
taskHolder.__cxTaskKey = `${typeName}-idx-${iframe.__cxTaskOrder}`;
} else if (src) {
taskHolder.__cxTaskKey = `${typeName}-${src}`;
} else {
taskHolder.__cxTaskKey = `task-${++taskSeq}`;
}
}
const labelParts = [];
const order = Number(iframe?.__cxTaskOrder || 0);
const total = Number(iframe?.__cxTaskTotal || 0);
const chapterInfo = getChapterInfo();
const chapterSection = chapterInfo.chapterLabel || extractChapterSection(chapterInfo.title || "");
if (chapterSection) labelParts.push(chapterSection);
if (order) {
labelParts.push(total ? `第${order}个任务点 共${total}个` : `第${order}个任务点`);
} else {
labelParts.push("任务点");
}
return {
taskKey: taskHolder.__cxTaskKey,
taskLabel: labelParts.join(" ").trim() || "任务点",
taskType
};
};
const createTaskLogger = (iframe) => {
const meta = buildTaskMeta(iframe);
const log = (message, type = "info") => {
touchActivity();
logStore.addLog(message, type, meta);
};
return { log, meta };
};
// 核心:处理视频/音频(心跳 + 恢复梯度 + 断点恢复)
const processMedia = (mediaType, doc, win, iframe, taskId, addLog, taskMeta) => {
return new Promise((resolve) => {
const log = addLog || ((message, type = "info") => logStore.addLog(message, type));
const typeName = mediaType === "video" ? "视频" : "音频";
const ABORTED = Symbol("aborted");
const isTaskAlive = () => taskId === currentTaskId;
const checkpoint = (patch = {}) => KeepAliveEngine.touch(taskMeta, patch);
const getLiveDoc = () => safeDoc(iframe) || doc || null;
const findMedia = () => {
try {
const currentDoc = getLiveDoc();
return currentDoc?.querySelector?.(mediaType) || null;
} catch {
return null;
}
};
const ensurePlaying = async (mediaEl, hard = false) => {
if (!mediaEl) return;
if (configStore.videoSettings.muted) {
mediaEl.muted = true;
mediaEl.volume = 0;
}
mediaEl.autoplay = true;
if (hard) {
try {
mediaEl.setAttribute("playsinline", "playsinline");
mediaEl.setAttribute("webkit-playsinline", "true");
mediaEl.preload = "auto";
} catch {}
}
try {
await mediaEl.play();
} catch {
try {
mediaEl.muted = true;
mediaEl.volume = 0;
await mediaEl.play();
} catch {}
}
};
const waitMedia = async () => {
const startedAt = Date.now();
while (Date.now() - startedAt < 30000) {
if (!isTaskAlive()) return ABORTED;
const mediaEl = findMedia();
if (mediaEl) return mediaEl;
await sleep(0.5);
}
return null;
};
const run = async () => {
BackgroundStability.ensure();
let media = findMedia();
if (!media) {
media = await waitMedia();
}
if (media === ABORTED) {
resolve();
return;
}
if (!media) {
checkpoint({ status: "not_found", message: `${typeName}元素未找到` });
log(`未找到${typeName}元素`, "warning");
resolve();
return;
}
setActiveTask(taskMeta, iframe);
KeepAliveEngine.applyCheckpoint(taskMeta, media, log);
if (isMediaFinished(media)) {
touchTaskState(taskMeta, { finished: true, type: taskMeta?.taskType || mediaType });
KeepAliveEngine.clear(taskMeta);
log(`${typeName}已完成`, "success");
resolve();
return;
}
if (__mediaProcessedMap.has(media)) {
resolve();
return;
}
__mediaProcessedMap.set(media, true);
if (win && !win.__cxHelperAlertHooked) {
try {
win.alert = () => {};
win.confirm = () => true;
win.prompt = () => "";
win.__cxHelperAlertHooked = true;
} catch {}
}
const bindLatestMedia = async ({ reason = "", forcePlay = false, hard = false } = {}) => {
const nextMedia = findMedia();
if (!nextMedia) return false;
if (nextMedia === media) {
if (forcePlay) await ensurePlaying(media, hard);
return true;
}
__mediaProcessedMap.delete(media);
media = nextMedia;
__mediaProcessedMap.set(media, true);
bindMediaEvents();
KeepAliveEngine.applyCheckpoint(taskMeta, media, log);
lastTime = Number(media.currentTime) || 0;
lastProgressAt = Date.now();
if (forcePlay) await ensurePlaying(media, hard);
checkpoint({
status: "media_rebind",
currentTime: lastTime,
recoveryLevel,
lastReason: reason || "media_rebind"
});
return true;
};
log(`开始播放${typeName}`, "primary");
await ensurePlaying(media, true);
touchTaskState(taskMeta, {
reloadPending: false,
reloadRequestedAt: 0,
awaitingForeground: false,
waitVisibleLogged: false,
type: taskMeta?.taskType || mediaType
});
checkpoint({
status: "running",
mediaType,
currentTime: Number(media.currentTime) || 0,
recoveryLevel: 0,
lastHeartbeat: Date.now()
});
let closed = false;
let mediaTimer = null;
let taskTimer = null;
let timeoutId = null;
const listeners = [];
let mediaListeners = [];
let recoveryLevel = 0;
let lastRecoveryAt = 0;
let lastMediaBeatAt = Date.now();
let lastTaskBeatAt = Date.now();
let lastProgressAt = Date.now();
let lastTime = Number(media.currentTime) || 0;
const addEvent = (target, eventName, handler) => {
if (!target?.addEventListener) return;
target.addEventListener(eventName, handler);
listeners.push(() => {
try { target.removeEventListener(eventName, handler); } catch {}
});
};
const clearMediaEvents = () => {
mediaListeners.forEach((off) => off());
mediaListeners = [];
};
const addMediaEvent = (target, eventName, handler) => {
if (!target?.addEventListener) return;
target.addEventListener(eventName, handler);
mediaListeners.push(() => {
try { target.removeEventListener(eventName, handler); } catch {}
});
};
const isPlaybackHealthy = () => !!media && !media.ended && !media.paused && Number(media.readyState || 0) >= 2;
const recordMediaProgress = (currentTime = Number(media?.currentTime) || 0, allowStaticHeartbeat = false) => {
const now = Date.now();
lastMediaBeatAt = now;
if (allowStaticHeartbeat && !isPlaybackHealthy()) return;
lastProgressAt = now;
recoveryLevel = 0;
if (currentTime > lastTime) {
lastTime = currentTime;
}
markTaskProgress(taskMeta);
checkpoint({
status: "running",
currentTime,
recoveryLevel: 0,
lastHeartbeat: now
});
};
const bindMediaEvents = () => {
clearMediaEvents();
if (!media) return;
addMediaEvent(media, "timeupdate", () => {
recordMediaProgress(Number(media.currentTime) || 0);
});
addMediaEvent(media, "playing", () => {
recordMediaProgress(Number(media.currentTime) || 0, true);
});
addMediaEvent(media, "play", () => {
recordMediaProgress(Number(media.currentTime) || 0, true);
});
addMediaEvent(media, "seeked", () => {
recordMediaProgress(Number(media.currentTime) || 0, true);
});
addMediaEvent(media, "ratechange", () => {
recordMediaProgress(Number(media.currentTime) || 0, true);
});
};
bindMediaEvents();
const cleanup = () => {
if (mediaTimer) clearInterval(mediaTimer);
if (taskTimer) clearInterval(taskTimer);
if (timeoutId) clearTimeout(timeoutId);
clearMediaEvents();
listeners.forEach((off) => off());
__mediaProcessedMap.delete(media);
};
const finish = ({ message = "", type = "info", done = false, status = "stopped" } = {}) => {
if (closed) return;
closed = true;
cleanup();
if (done) {
touchTaskState(taskMeta, { finished: true, type: taskMeta?.taskType || mediaType });
KeepAliveEngine.clear(taskMeta);
} else {
checkpoint({
status,
currentTime: Number(media.currentTime) || 0,
recoveryLevel
});
}
if (message) log(message, type);
resolve();
};
const recover = async (reason) => {
const now = Date.now();
if (now - lastRecoveryAt < 1200) return;
lastRecoveryAt = now;
if (await bindLatestMedia({ reason })) {
if (isMediaFinished(media)) return;
}
recoveryLevel += 1;
checkpoint({
status: "recovering",
recoveryLevel,
currentTime: Number(media.currentTime) || 0,
lastReason: reason
});
if (recoveryLevel === 1) {
log(`${typeName}保活恢复 L1:重试播放(${reason})`, "warning");
await bindLatestMedia({ reason, forcePlay: false });
await ensurePlaying(media, false);
return;
}
if (recoveryLevel === 2) {
log(`${typeName}保活恢复 L2:强制重拉起(${reason})`, "warning");
await bindLatestMedia({ reason, forcePlay: false });
await ensurePlaying(media, true);
return;
}
if (recoveryLevel === 3) {
log(`${typeName}保活恢复 L3:重建任务 iframe`, "warning");
touchTaskState(taskMeta, {
reloadPending: true,
reloadRequestedAt: Date.now(),
type: taskMeta?.taskType || mediaType
});
checkpoint({
status: "iframe_reload",
currentTime: Number(media.currentTime) || 0,
recoveryLevel
});
try {
if (iframe?.src) iframe.src = iframe.src;
} catch {}
finish({
message: `${typeName}任务点已重建,下一轮继续处理`,
type: "warning",
status: "iframe_reload"
});
return;
}
finish({
message: `${typeName}保活恢复失败,交由下一轮重试`,
type: "warning",
status: "recover_failed"
});
};
const onForegroundBack = async () => {
if (closed || globalPauseState.isPaused || !isTaskAlive()) return;
checkpoint({
status: "foreground_recover",
recoveryLevel,
currentTime: Number(media.currentTime) || 0
});
await ensurePlaying(media, true);
};
addEvent(window, "focus", onForegroundBack);
addEvent(window, "pageshow", onForegroundBack);
addEvent(document, "visibilitychange", onForegroundBack);
mediaTimer = setInterval(async () => {
if (!isTaskAlive()) {
finish({ status: "task_switched" });
return;
}
if (globalPauseState.isPaused) return;
await bindLatestMedia({ reason: "tick_sync" });
if (closed) return;
const now = Date.now();
if (now - lastMediaBeatAt > 2800) {
await recover("后台冻结恢复");
if (closed) return;
}
lastMediaBeatAt = now;
if (isMediaFinished(media)) {
finish({
message: `${typeName}播放结束`,
type: "success",
done: true,
status: "finished"
});
return;
}
if (media.paused) {
await recover("媒体暂停");
if (closed) return;
}
const currentTime = Number(media.currentTime) || 0;
if (currentTime > lastTime + 0.1) {
recordMediaProgress(currentTime);
return;
}
if (isPlaybackHealthy() && now - lastProgressAt > 4000) {
recordMediaProgress(currentTime, true);
return;
}
if (now - lastProgressAt > 8000) {
await recover("播放无进展");
}
}, 1000);
taskTimer = setInterval(async () => {
if (closed || globalPauseState.isPaused) return;
const now = Date.now();
if (now - lastTaskBeatAt > 11000) {
await recover("任务心跳漂移");
if (closed) return;
}
lastTaskBeatAt = now;
if (!iframe?.isConnected) {
await recover("任务容器失联");
return;
}
const currentDoc = safeDoc(iframe);
if (!currentDoc) {
await recover("任务文档不可访问");
return;
}
await bindLatestMedia({ reason: "task_doc_sync" });
}, 5000);
timeoutId = setTimeout(() => {
if (closed) return;
if (!isMediaFinished(media)) {
finish({
message: `${typeName}处理超时,交由下一轮继续`,
type: "warning",
status: "timeout"
});
return;
}
finish({ done: true, status: "finished" });
}, 25 * 60 * 1000);
};
run();
});
};
const parseActionResult = (result) => {
if (result === true) return true;
if (result === false) return false;
if (result && typeof result === "object") {
if (typeof result.success === "boolean") return result.success;
if (typeof result.status === "boolean") return result.status;
if (typeof result.result === "boolean") return result.result;
if (typeof result.code === "number") return result.code === 200 || result.code === 0;
}
return null;
};
const callWorkAction = async (win, actionName, actionLabel, log) => {
const fn = win?.[actionName];
if (typeof fn !== "function") {
log(`${actionLabel}失败:页面未提供 ${actionName} 接口`, "warning");
return false;
}
try {
const result = await fn();
const parsed = parseActionResult(result);
if (parsed === false) {
log(`${actionLabel}失败:接口返回失败状态`, "warning");
return false;
}
return true;
} catch (e) {
log(`${actionLabel}失败:${e?.message || "调用异常"}`, "warning");
return false;
}
};
const trySaveWork = async (win, doc, iframe, log) => {
const saveTriggered = await callWorkAction(win, "noSubmit", "暂存", log);
if (!saveTriggered) {
return { saved: false, state: "trigger_failed", message: "" };
}
const saveState = await waitWorkActionResult(doc, "save", 12000, { iframe });
if (saveState.state === WORK_FLOW_STATES.SAVED || saveState.state === WORK_FLOW_STATES.FINISHED) {
return { saved: true, state: saveState.state, message: saveState.message || "" };
}
if (saveState.state === WORK_FLOW_STATES.CAPTCHA) {
return { saved: false, state: WORK_FLOW_STATES.CAPTCHA, message: saveState.message || "" };
}
if (saveState.state === WORK_FLOW_STATES.ERROR) {
return { saved: false, state: WORK_FLOW_STATES.ERROR, message: saveState.message || "" };
}
// 兜底复检:部分页面提示出现较慢或被短暂遮挡,避免误判导致重复死循环。
await sleep(0.8);
const liveDoc = safeDoc(iframe) || doc;
if (isWorkFinishedByDoc(liveDoc)) {
return { saved: true, state: WORK_FLOW_STATES.FINISHED, message: "" };
}
const finalState = getWorkActionStatus(liveDoc);
if (finalState.state === WORK_FLOW_STATES.SAVED || finalState.state === WORK_FLOW_STATES.FINISHED) {
return { saved: true, state: finalState.state, message: finalState.message || "" };
}
if (finalState.state === WORK_FLOW_STATES.CAPTCHA) {
return { saved: false, state: WORK_FLOW_STATES.CAPTCHA, message: finalState.message || "" };
}
if (finalState.state === WORK_FLOW_STATES.ERROR) {
return { saved: false, state: WORK_FLOW_STATES.ERROR, message: finalState.message || "" };
}
return { saved: false, state: WORK_FLOW_STATES.TIMEOUT, message: saveState.message || "" };
};
const applySaveResultLog = (saveResult, log) => {
if (saveResult.saved) {
log("暂存完成", "success");
return true;
}
if (saveResult.state === WORK_FLOW_STATES.CAPTCHA) {
log("暂存触发验证码,请手动处理后重试", "warning");
return false;
}
if (saveResult.state === WORK_FLOW_STATES.ERROR) {
log(`暂存失败:${saveResult.message || "页面返回失败提示"}`, "warning");
return false;
}
log("暂存未确认成功,后续轮次将重试", "warning");
return false;
};
// 处理章节作业
const processWork = async (iframe, doc, win, addLog, taskMeta) => {
const log = addLog || ((message, type = "info") => logStore.addLog(message, type));
touchActivity();
log("处理章节测试...", "primary");
if (!doc) return;
try {
if (isWorkFinishedByDoc(doc)) {
log("测试已完成", "success");
touchTaskState(taskMeta, { finished: true, type: taskMeta?.taskType || "work" });
return;
}
await waitIfPaused(log);
FontDecoderModule.decryptDocument(doc);
const summary = await new CxQuestionHandler("zj", iframe, { addLog: log }).init();
if (win) win.alert = () => {};
if (!summary?.total) {
if (isWorkFinishedByDoc(doc)) {
log("测试已完成", "success");
touchTaskState(taskMeta, { finished: true, type: taskMeta?.taskType || "work" });
touchProgress();
return;
}
log("未解析到题目,跳过提交", "warning");
return;
}
let saved = false;
let done = false;
const shouldSaveOnly = !configStore.chapterSettings.autoSubmit;
if (shouldSaveOnly) {
log("尝试暂存答案", "primary");
const saveResult = await trySaveWork(win, doc, iframe, log);
saved = applySaveResultLog(saveResult, log);
done = saved || isWorkFinishedByDoc(doc);
} else {
log("尝试自动提交", "primary");
const submitOk = await callWorkAction(win, "btnBlueSubmit", "提交", log);
if (submitOk) {
await sleep(1);
await callWorkAction(win, "submitCheckTimes", "提交确认", log);
const submitState = await waitWorkActionResult(doc, "submit", 20000, { iframe });
if (submitState.state === WORK_FLOW_STATES.FINISHED) {
done = true;
log("提交成功", "success");
} else if (submitState.state === WORK_FLOW_STATES.CAPTCHA) {
log("提交触发验证码,请手动处理后重试", "warning");
} else if (submitState.state === WORK_FLOW_STATES.ERROR) {
log(`提交失败:${submitState.message || "页面返回失败提示"}`, "warning");
}
}
if (!done) {
log("提交结果未确认,回退暂存一次", "warning");
const saveResult = await trySaveWork(win, doc, iframe, log);
saved = applySaveResultLog(saveResult, log);
done = saved || isWorkFinishedByDoc(doc);
}
}
if (!done && !saved) log("提交/暂存未确认成功,后续轮次将重试", "warning");
touchTaskState(taskMeta, { finished: done, saved, type: taskMeta?.taskType || "work" });
if (done) {
touchProgress();
}
} catch (e) {
log("章节测试处理失败", "danger");
}
};
const processPpt = async (win, addLog, taskMeta) => {
const log = addLog || ((message, type = "info") => logStore.addLog(message, type));
if (!win?.document) return;
log("处理文档/PPT任务...", "primary");
try {
const panView = win.document.querySelector(CX_SELECTORS.documentViewer);
const viewWin = panView?.contentWindow || win;
const viewDoc = viewWin?.document;
if (viewDoc?.body) {
viewWin.scrollTo({ top: viewDoc.body.scrollHeight, behavior: "smooth" });
}
log("阅读完成", "success");
touchTaskState(taskMeta, { finished: true, type: taskMeta?.taskType || "doc" });
touchProgress();
} catch (e) {
log("文档处理失败", "danger");
}
};
const processBook = async (win, addLog, taskMeta) => {
const log = addLog || ((message, type = "info") => logStore.addLog(message, type));
log("处理电子书任务...", "primary");
try {
if (_unsafeWindow.top?.onchangepage && win?.getFrameAttr) {
_unsafeWindow.top.onchangepage(win.getFrameAttr("end"));
log("阅读完成", "success");
touchTaskState(taskMeta, { finished: true, type: taskMeta?.taskType || "book" });
touchProgress();
} else {
log("未找到电子书翻页接口", "warning");
}
} catch (e) {
log("电子书处理失败", "danger");
}
};
// 处理单个 iframe
const taskExecutors = Object.freeze({
work: async ({ iframe, doc, win, taskLog, taskMeta }) =>
processWork(iframe, doc, win, taskLog, taskMeta),
video: async ({ iframe, doc, win, taskId, taskLog, taskMeta }) =>
processMedia("video", doc, win, iframe, taskId, taskLog, taskMeta),
audio: async ({ iframe, doc, win, taskId, taskLog, taskMeta }) =>
processMedia("audio", doc, win, iframe, taskId, taskLog, taskMeta),
doc: async ({ win, taskLog, taskMeta }) =>
processPpt(win, taskLog, taskMeta),
book: async ({ win, taskLog, taskMeta }) =>
processBook(win, taskLog, taskMeta)
});
const shouldRunTaskExecutor = (detail, hasJobIcon) => {
if (detail?.type === "work") return true;
if (configStore.chapterSettings.onlyQuiz) return false;
if (!hasJobIcon) return false;
return typeof taskExecutors[detail?.type] === "function";
};
const processIframe = async (iframe, taskId) => {
let taskLog = (message, type = "info") => logStore.addLog(message, type);
let taskMeta = null;
try {
await waitIfPaused();
if (taskId !== currentTaskId) return;
await waitIframeLoad(iframe);
if (taskId !== currentTaskId) return;
const src = iframe.src || "";
if (!src || src.includes("javascript:")) return;
const loggerContext = createTaskLogger(iframe);
taskLog = loggerContext.log;
taskMeta = loggerContext.meta;
const taskTypeHint = getTaskTypeFromSrc(src);
const existingState = taskRegistry.get(taskMeta.taskKey) || {};
const isMediaTypeHint = taskTypeHint === "video" || taskTypeHint === "audio";
const doc = safeDoc(iframe);
const win = iframe.contentWindow;
if (!doc || !win) {
if (isMediaTypeHint && existingState.reloadPending) {
const reloadAge = Date.now() - Number(existingState.reloadRequestedAt || 0);
if (reloadAge < 120000) {
if (!existingState.waitVisibleLogged || reloadAge > 30000) {
taskLog(`${taskTypeHint === "video" ? "视频" : "音频"}任务点后台重建中,继续等待媒体重新加载`, "warning");
touchTaskState(taskMeta, { waitVisibleLogged: true, type: taskTypeHint });
}
clearActiveTask(taskMeta);
return;
}
touchTaskState(taskMeta, { reloadPending: false, reloadRequestedAt: 0, type: taskTypeHint });
}
touchTaskState(taskMeta, { type: taskTypeHint });
markTaskAttemptResult(taskMeta, taskLog, "任务点 iframe 不可访问", taskTypeHint);
clearActiveTask(taskMeta);
return;
}
const taskContainer = getTaskContainerFromIframe(iframe);
const hasJobIcon = !!taskContainer;
const isTask = src.includes("api/work") || src.includes("ananas/modules/work/index.html") || hasJobIcon;
if (!isTask) return;
applyDocumentHooks(doc, win);
const detail = getTaskDetail(iframe);
touchTaskState(detail.meta, { finished: detail.finished, saved: detail.saved, type: detail.type });
const isMediaTask = detail.type === "video" || detail.type === "audio";
if (existingState.ignored) {
if (!existingState.giveUpLogged) {
taskLog(`任务点连续 ${TASK_MAX_UNRESOLVED_ATTEMPTS} 次未完成,已放弃`, "warning");
touchTaskState(taskMeta, { ignored: true, giveUpLogged: true, type: detail.type });
}
clearActiveTask(taskMeta);
return;
}
if (isMediaTask && (existingState.awaitingForeground || existingState.reloadPending)) {
touchTaskState(taskMeta, {
awaitingForeground: false,
waitVisibleLogged: false,
reloadPending: false,
reloadRequestedAt: 0,
type: detail.type
});
}
setActiveTask(taskMeta, iframe);
if (!iframe.__cxTaskStarted) {
taskLog(`正在处理【${taskMeta.taskLabel || "任务点"}】`, "primary");
iframe.__cxTaskStarted = true;
}
if (detail.saved && detail.type === "work") {
taskLog("已暂存,跳过重复答题", "primary");
touchTaskState(taskMeta, { saved: true, finished: true, type: taskMeta.taskType });
clearActiveTask(taskMeta);
return;
}
// 跳过已完成
if (detail.finished && configStore.chapterSettings.skipCompleted) {
taskLog("跳过已完成任务点", "primary");
touchTaskState(taskMeta, { finished: true, type: taskMeta.taskType });
clearActiveTask(taskMeta);
return;
}
const executor = taskExecutors[detail.type];
if (shouldRunTaskExecutor(detail, hasJobIcon) && executor) {
await executor({
iframe,
doc,
win,
taskId,
taskLog,
taskMeta
});
}
const latestDetail = getTaskDetail(iframe);
touchTaskState(latestDetail.meta, {
finished: latestDetail.finished,
saved: latestDetail.saved,
type: latestDetail.type
});
const latestState = taskRegistry.get(taskMeta.taskKey) || {};
if ((latestDetail.type === "video" || latestDetail.type === "audio") && latestState.reloadPending) {
clearActiveTask(taskMeta);
return;
}
if (!latestDetail.finished) {
const reason = isSupportedTaskType(latestDetail.type)
? "任务处理后仍未完成"
: `未支持任务类型(src=${latestDetail.src || src})`;
markTaskAttemptResult(taskMeta, taskLog, reason, latestDetail.type || taskTypeHint);
}
clearActiveTask(taskMeta);
} catch (e) {
console.warn("处理iframe出错:", e);
if (taskMeta) {
markTaskAttemptResult(taskMeta, taskLog, "处理任务点出错", taskMeta.taskType);
}
clearActiveTask(taskMeta);
}
};
// 初始化
init();
chapterPhase = CHAPTER_PHASES.DISCOVER;
startMainLoop();
startWatchdog();
};
// ==================== 17. 作业和考试逻辑 ====================
const useCxWorkLogic = async () => {
const logStore = useLogStore();
if (!LicenseService.isValid()) {
logStore.addLog("许可验证未通过,功能已锁定", "danger");
return;
}
logStore.addLog("作业页面", "primary");
await sleep(2);
await new CxQuestionHandler("zy").init();
};
const useCxExamLogic = async () => {
const logStore = useLogStore();
const configStore = useConfigStore();
if (!LicenseService.isValid()) {
logStore.addLog("许可验证未通过,功能已锁定", "danger");
return;
}
logStore.addLog("考试页面", "primary");
await sleep(2);
await new CxQuestionHandler("ks").init();
if (configStore.examSettings.autoSwitchQuestion) {
await sleep(configStore.otherSettings.operationIntervalSec);
const currentQuestionNum = parseInt(_unsafeWindow.document.querySelector(CX_SELECTORS.examCurrentQuestion)?.innerText || "0", 10);
const totalQuestions = _unsafeWindow.document.querySelectorAll(CX_SELECTORS.examQuestionItems).length;
if (totalQuestions && currentQuestionNum >= totalQuestions) {
logStore.addLog("当前已是最后一题,不再自动切换", "warning");
logStore.addLog("请检查答案后手动提交试卷", "primary");
} else {
_unsafeWindow.getTheNextQuestion?.(1);
}
}
};
// ==================== 18. Vue 组件 ====================
const ScriptHome = {
props: { logList: Array },
setup(props) {
const scrollRef = vue.ref(null);
const openGroups = vue.ref([]);
vue.watch(() => props.logList.length, () => {
vue.nextTick(() => scrollRef.value?.setScrollTop?.(99999));
});
const isPaused = vue.computed(() => globalPauseState.isPaused);
const entries = vue.computed(() => {
const groups = new Map();
const list = [];
props.logList.forEach((item, index) => {
if (item.taskKey) {
const key = item.taskKey;
let group = groups.get(key);
if (!group) {
group = { key, label: item.taskLabel || "任务点", items: [], lastTime: item.time, lastType: item.type };
groups.set(key, group);
list.push({ type: "group", group, key: `group-${key}` });
}
if (item.taskLabel) {
group.label = item.taskLabel;
}
group.items.push(item);
group.lastTime = item.time;
group.lastType = item.type;
} else {
list.push({ type: "log", item, key: `log-${index}` });
}
});
return list;
});
const isOpen = (key) => openGroups.value.includes(key);
const toggleGroup = (key) => {
const set = new Set(openGroups.value);
if (set.has(key)) set.delete(key);
else set.add(key);
openGroups.value = Array.from(set);
};
const renderLogItem = (item, key) => {
return vue.h('div', { key, class: ['log-item', `type-${item.type}`] }, [
vue.h('span', { class: 'log-time' }, item.time),
vue.h('span', { class: 'log-message' }, item.message)
]);
};
const renderGroupHeader = (group, opened, key) => {
const type = group.lastType || "info";
return vue.h('div', {
key,
class: ['log-item', 'log-group-header', `type-${type}`],
onClick: () => toggleGroup(group.key)
}, [
vue.h('span', { class: 'log-time' }, group.lastTime || ""),
vue.h('span', { class: 'log-message' }, group.label || "任务点"),
vue.h('span', { class: 'log-group-count' }, `(${group.items.length})`),
vue.h('span', { class: 'log-group-toggle' }, opened ? '收起' : '展开')
]);
};
return () => vue.h('div', { class: 'script-log-container', style: { height: '100%' } }, [
vue.h('div', {
class: ['status-indicator', isPaused.value ? 'paused' : 'running']
}, [
vue.h('span', { class: 'status-dot' }),
vue.h('span', isPaused.value ? '已暂停' : '运行中')
]),
vue.h(vue.resolveComponent('el-scrollbar'), { ref: scrollRef, height: "calc(100% - 40px)" },
() => props.logList.length === 0
? vue.h('div', { class: 'empty-log' }, '暂无日志')
: entries.value.map((entry) => {
if (entry.type === "log") {
return renderLogItem(entry.item, entry.key);
}
const group = entry.group;
const opened = isOpen(group.key);
return vue.h('div', { key: entry.key, class: 'log-group' }, [
renderGroupHeader(group, opened, `${entry.key}-header`),
opened
? group.items.map((item, index) => renderLogItem(item, `${entry.key}-${index}`))
: null
]);
})
)
]);
}
};
const ApiManager = {
setup() {
const configStore = useConfigStore();
const logStore = useLogStore();
const newApiUrl = vue.ref("");
const editingIndex = vue.ref(-1);
const editingUrl = vue.ref("");
const addApi = () => {
if (newApiUrl.value.trim()) {
if (configStore.addApi(newApiUrl.value)) {
logStore.addLog("API添加成功", "success");
newApiUrl.value = "";
} else {
logStore.addLog("API已存在或格式无效", "warning");
}
}
};
const startEdit = (index, url) => {
editingIndex.value = index;
editingUrl.value = url;
};
const cancelEdit = () => {
editingIndex.value = -1;
editingUrl.value = "";
};
const confirmEdit = () => {
if (editingUrl.value.trim()) {
if (configStore.updateApi(editingIndex.value, editingUrl.value)) {
logStore.addLog("API修改成功", "success");
cancelEdit();
} else {
logStore.addLog("API已存在或格式无效", "warning");
}
}
};
const deleteApi = (index) => {
if (configStore.removeApi(index)) {
logStore.addLog("API删除成功", "success");
if (editingIndex.value === index) {
cancelEdit();
} else if (editingIndex.value > index) {
editingIndex.value -= 1;
}
} else {
logStore.addLog("至少保留一个API", "warning");
}
};
const renderApiItem = (url, index) => {
if (editingIndex.value === index) {
return vue.h('div', { key: 'edit-' + index, class: 'edit-row' }, [
vue.h(vue.resolveComponent('el-input'), {
modelValue: editingUrl.value,
'onUpdate:modelValue': (v) => { editingUrl.value = v; },
size: "small",
placeholder: "输入API地址",
autofocus: true,
onKeyup: (e) => {
if (e.key === 'Enter') confirmEdit();
if (e.key === 'Escape') cancelEdit();
}
}),
vue.h('div', { class: 'edit-actions' }, [
vue.h('div', {
class: 'edit-action-btn confirm',
onClick: confirmEdit,
title: '确认'
}, [
vue.h(vue.resolveComponent('el-icon'), { size: 14 }, () => vue.h(CheckIcon))
]),
vue.h('div', {
class: 'edit-action-btn cancel',
onClick: cancelEdit,
title: '取消'
}, [
vue.h(vue.resolveComponent('el-icon'), { size: 14 }, () => vue.h(CloseIcon))
])
])
]);
}
return vue.h('div', {
key: index,
class: ['api-item', { active: index === configStore.currentApiIndex }],
onClick: () => configStore.selectApi(index),
onDblclick: () => startEdit(index, url)
}, [
vue.h(vue.resolveComponent('el-icon'), {
size: 14,
color: index === configStore.currentApiIndex ? '#764ba2' : '#c0c4cc'
}, () => vue.h(CheckIcon)),
vue.h('span', { class: 'api-url', title: url }, url),
vue.h('div', { class: 'action-btns' }, [
vue.h(vue.resolveComponent('el-icon'), {
class: 'action-btn',
size: 14,
color: '#409eff',
onClick: (e) => { e.stopPropagation(); startEdit(index, url); },
title: '编辑'
}, () => vue.h(EditIcon)),
configStore.apiList.length > 1 ? vue.h(vue.resolveComponent('el-icon'), {
class: 'action-btn',
size: 14,
color: '#f56c6c',
onClick: (e) => { e.stopPropagation(); deleteApi(index); },
title: '删除'
}, () => vue.h(DeleteIcon)) : null
])
]);
};
return () => vue.h('div', { class: 'api-manager', style: { height: '100%' } }, [
vue.h(vue.resolveComponent('el-scrollbar'), { height: "calc(100% - 50px)" }, () =>
configStore.apiList.length > 0
? configStore.apiList.map((url, index) => renderApiItem(url, index))
: vue.h('div', { class: 'empty-tip' }, '暂无API,请添加')
),
vue.h('div', { class: 'add-api' }, [
vue.h(vue.resolveComponent('el-input'), {
modelValue: newApiUrl.value,
'onUpdate:modelValue': (v) => { newApiUrl.value = v; },
placeholder: "输入API地址 (如: http://localhost:3000)",
size: "small",
onKeyup: (e) => e.key === 'Enter' && addApi()
}),
vue.h(vue.resolveComponent('el-button'), {
type: "primary",
size: "small",
onClick: addApi
}, () => "添加")
])
]);
}
};
const ScriptSetting = {
setup() {
const configStore = useConfigStore();
const icons = {
"章节设置": "📚",
"考试设置": "📝",
"视频设置": "🎬",
"其他参数": "⚙️"
};
const platformParams = vue.computed(() => configStore.platformParams);
const otherParams = vue.computed(() => configStore.otherParams);
const updateParamValue = (partKey, paramKey, value) => {
configStore.setPlatformParam(partKey, paramKey, value);
};
const updateOtherParamValue = (paramKey, value) => {
configStore.setOtherParam(paramKey, value);
};
const revalidateLicense = async () => {
await validateLicenseState({ force: true });
};
return () => vue.h('div', { class: "script-setting", style: { height: '100%' } }, [
vue.h(vue.resolveComponent('el-scrollbar'), { height: "100%" }, () => [
...platformParams.value.cx.parts.map((part, partIndex) =>
vue.h('div', { key: 'part-' + partIndex, class: 'setting-section' }, [
vue.h('div', { class: 'section-title' }, [
vue.h('span', icons[part.name] || '📋'),
vue.h('span', part.name)
]),
...part.params.map((param, paramIndex) =>
vue.h('div', { key: 'param-' + paramIndex, class: 'setting-item' }, [
vue.h('div', [
vue.h('span', { class: 'setting-label' }, param.name),
param.tip ? vue.h('div', { class: 'setting-tip' }, param.tip) : null
]),
param.type === "boolean"
? vue.h(vue.resolveComponent('el-switch'), {
modelValue: param.value,
'onUpdate:modelValue': v => updateParamValue(part.key, param.key, v),
size: "small"
})
: vue.h(vue.resolveComponent('el-input-number'), {
modelValue: param.value,
'onUpdate:modelValue': v => updateParamValue(part.key, param.key, v),
min: param.min || 1,
max: param.max || 100,
size: "small",
controlsPosition: "right"
})
])
)
])
),
vue.h('div', { class: 'setting-section' }, [
vue.h('div', { class: 'section-title' }, [
vue.h('span', icons[otherParams.value.name] || '⚙️'),
vue.h('span', otherParams.value.name)
]),
...otherParams.value.params.map((param, index) =>
vue.h('div', { key: 'other-' + index, class: 'setting-item' }, [
vue.h('div', [
vue.h('span', { class: 'setting-label' }, param.name),
param.tip ? vue.h('div', { class: 'setting-tip' }, param.tip) : null
]),
param.type === "number"
? vue.h(vue.resolveComponent('el-input-number'), {
modelValue: param.value,
'onUpdate:modelValue': v => updateOtherParamValue(param.key, v),
min: param.min || 0,
max: param.max || 100,
size: "small",
controlsPosition: "right"
})
: (param.type === "boolean"
? vue.h(vue.resolveComponent('el-switch'), {
modelValue: !!param.value,
'onUpdate:modelValue': v => updateOtherParamValue(param.key, !!v),
size: "small"
})
: vue.h(vue.resolveComponent('el-input'), {
modelValue: param.value,
'onUpdate:modelValue': v => updateOtherParamValue(param.key, v),
size: "small",
placeholder: param.tip || "请输入"
})
)
])
),
vue.h('div', { class: 'setting-item' }, [
vue.h('div', [
vue.h('span', { class: 'setting-label' }, '许可状态校验'),
vue.h('div', { class: 'setting-tip' }, '修改地址或密钥后建议手动校验一次')
]),
vue.h(vue.resolveComponent('el-button'), {
type: "primary",
size: "small",
onClick: revalidateLicense
}, () => "立即校验")
])
])
])
]);
}
};
const QuestionTable = {
props: { questionList: Array },
setup(props) {
return () => vue.h('div', { class: 'script-question-table', style: { height: '100%' } }, [
props.questionList.length > 0
? vue.h(vue.resolveComponent('el-scrollbar'), { height: "100%" }, () =>
vue.h(vue.resolveComponent('el-table'), {
data: props.questionList,
stripe: true,
size: "small"
}, () => [
vue.h(vue.resolveComponent('el-table-column'), { type: "index", width: "40", label: "#" }),
vue.h(vue.resolveComponent('el-table-column'), {
prop: "title", label: "题目", minWidth: "140", showOverflowTooltip: true
}),
vue.h(vue.resolveComponent('el-table-column'), {
prop: "answer", label: "答案", minWidth: "100"
}, {
default: scope => vue.h('div', {
style: { whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontSize: '11px' },
innerHTML: Array.isArray(scope.row.answer) ? scope.row.answer.join('
') : scope.row.answer
})
})
])
)
: vue.h(vue.resolveComponent('el-empty'), { description: "暂无题目", imageSize: 50 })
]);
}
};
const IndexComponent = {
setup() {
const configStore = useConfigStore();
const logStore = useLogStore();
const questionStore = useQuestionStore();
const tabs = [
{ label: "日志", name: "0", component: ScriptHome, props: { logList: logStore.logList } },
{ label: "题目", name: "1", component: QuestionTable, props: { questionList: questionStore.questionList } },
{ label: "API", name: "2", component: ApiManager },
{ label: "设置", name: "3", component: ScriptSetting }
];
return () => vue.h(vue.resolveComponent('el-tabs'), {
modelValue: configStore.menuIndex,
'onUpdate:modelValue': v => configStore.menuIndex = v,
style: { height: '100%' }
}, () => tabs.map(tab =>
vue.h(vue.resolveComponent('el-tab-pane'), {
key: tab.name,
label: tab.label,
name: tab.name,
style: { height: '100%' }
}, () => vue.h(tab.component, tab.props))
));
}
};
const LayoutComponent = {
setup() {
const configStore = useConfigStore();
const logStore = useLogStore();
logStore.addLog("脚本启动 v" + getScriptInfo().version, "success");
BackgroundStability.init((message, type = "info") => {
logStore.addLog(message, type, { force: true });
});
let logicStarted = false;
const startLogic = () => {
const url = window.location.href;
const logicByMode = {
chapter: useCxChapterLogic,
work: useCxWorkLogic,
exam: useCxExamLogic,
course: () => logStore.addLog("请进入章节或答题页", "warning")
};
for (const { keyword, mode } of CX_PAGE_ROUTE_RULES) {
if (url.includes(keyword)) {
logicByMode[mode]?.();
break;
}
}
};
const startLogicOnce = () => {
if (logicStarted) return;
logicStarted = true;
startLogic();
};
LicenseService.onValid(startLogicOnce);
const runStartup = async () => {
const ok = await validateLicenseState({ force: true, addLog: logStore.addLog.bind(logStore) });
if (ok) startLogicOnce();
};
runStartup();
let applyingExternalConfig = false;
let configSaveTimer = null;
const buildConfigSnapshot = () => ({
version: configStore.version,
isMinus: configStore.isMinus,
licenseKey: configStore.licenseKey,
position: { ...configStore.position },
menuIndex: configStore.menuIndex,
platformName: configStore.platformName,
platformParams: JSON.parse(JSON.stringify(configStore.platformParams)),
otherParams: JSON.parse(JSON.stringify(configStore.otherParams)),
apiList: [...configStore.apiList],
currentApiIndex: configStore.currentApiIndex
});
const saveConfigDebounced = () => {
if (applyingExternalConfig) return;
if (configSaveTimer) clearTimeout(configSaveTimer);
configSaveTimer = setTimeout(() => {
_GM_setValue("config", JSON.stringify(buildConfigSnapshot()));
configSaveTimer = null;
}, 250);
};
const applyExternalConfig = (parsed) => {
if (!parsed || typeof parsed !== 'object') return;
applyingExternalConfig = true;
try {
if (parsed.position?.x && parsed.position?.y) {
configStore.position = { x: parsed.position.x, y: parsed.position.y };
position.value = { left: parsed.position.x, top: parsed.position.y };
}
if (Array.isArray(parsed.apiList) && parsed.apiList.length > 0) {
const incomingList = parsed.apiList
.map((item) => normalizeApiUrl(item))
.filter(Boolean);
if (incomingList.length > 0) {
configStore.apiList = Array.from(new Set(incomingList));
}
if (typeof parsed.currentApiIndex === 'number') {
configStore.currentApiIndex = Math.max(
0,
Math.min(parsed.currentApiIndex, configStore.apiList.length - 1)
);
}
LicenseService.markDirty();
} else if (typeof parsed.apiBaseUrl === "string" && parsed.apiBaseUrl.trim()) {
const migratedUrl = normalizeApiUrl(parsed.apiBaseUrl);
if (migratedUrl) {
if (!configStore.apiList.includes(migratedUrl)) {
configStore.apiList.push(migratedUrl);
}
configStore.currentApiIndex = configStore.apiList.indexOf(migratedUrl);
LicenseService.markDirty();
}
}
if (typeof parsed.menuIndex === 'string') configStore.menuIndex = parsed.menuIndex;
if (typeof parsed.isMinus === 'boolean') configStore.isMinus = parsed.isMinus;
if (typeof parsed.licenseKey === 'string') {
configStore.licenseKey = parsed.licenseKey;
configStore.setOtherParam(OTHER_PARAM_KEYS.licenseKey, parsed.licenseKey);
}
if (parsed.platformParams?.cx?.parts) {
mergeCxPartValues(configStore.platformParams?.cx?.parts, parsed.platformParams.cx.parts);
}
if (parsed.otherParams?.params) {
parsed.otherParams.params.forEach((param, i) => {
const key = typeof param?.key === "string" && param.key
? param.key
: configStore.otherParams?.params?.[i]?.key;
if (!key) return;
configStore.setOtherParam(key, param.value);
});
}
} finally {
setTimeout(() => { applyingExternalConfig = false; }, 0);
}
};
vue.watch(() => buildConfigSnapshot(), saveConfigDebounced, { deep: true });
if (_GM_addValueChangeListener) {
try {
_GM_addValueChangeListener("config", (key, oldValue, newValue, remote) => {
if (!remote) return;
try {
const parsed = typeof newValue === 'string' ? JSON.parse(newValue) : newValue;
applyExternalConfig(parsed);
} catch {}
});
} catch {}
}
const isDragging = vue.ref(false);
const offsetX = vue.ref(0);
const offsetY = vue.ref(0);
const position = vue.ref({
left: configStore.position.x,
top: configStore.position.y
});
const isPaused = vue.computed(() => globalPauseState.isPaused);
const commitPosition = () => {
configStore.position.x = position.value.left;
configStore.position.y = position.value.top;
};
const startPointerDrag = (event, options = {}) => {
if (event.button !== 0) return;
event.preventDefault();
const dragEl = options.resolveElement
? options.resolveElement(event)
: event.currentTarget;
if (!dragEl) return;
const rect = dragEl.getBoundingClientRect();
const dragW = Math.max(rect.width || 20, 20);
const dragH = Math.max(rect.height || 20, 20);
let moved = false;
isDragging.value = true;
offsetX.value = event.clientX - rect.left;
offsetY.value = event.clientY - rect.top;
if (event.pointerId != null && dragEl.setPointerCapture) {
try { dragEl.setPointerCapture(event.pointerId); } catch {}
}
const cleanup = () => {
isDragging.value = false;
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.removeEventListener("pointercancel", onPointerUp);
window.removeEventListener("blur", onPointerUp);
};
const onPointerUp = () => {
cleanup();
commitPosition();
if (!moved && typeof options.onClick === "function") {
options.onClick();
}
};
const onPointerMove = (e) => {
if (!isDragging.value || e.buttons !== 1) {
onPointerUp();
return;
}
moved = true;
const x = Math.max(0, Math.min(e.clientX - offsetX.value, window.innerWidth - dragW));
const y = Math.max(0, Math.min(e.clientY - offsetY.value, window.innerHeight - dragH));
position.value = { left: `${x}px`, top: `${y}px` };
};
window.addEventListener("blur", onPointerUp);
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
document.addEventListener("pointercancel", onPointerUp);
};
const startPanelDrag = (event) => {
const target = event.target;
if (target?.closest?.('.header-btns')) return;
startPointerDrag(event, {
resolveElement: (e) => e.currentTarget?.closest?.('.main-panel') || e.currentTarget
});
};
const startCircleDrag = (event) => {
startPointerDrag(event, {
onClick: () => { configStore.isMinus = false; }
});
};
const handleMinimize = () => {
configStore.isMinus = true;
};
const handleTogglePause = () => {
if (!LicenseService.isValid()) {
logStore.addLog("许可验证未通过,无法解除锁定", "danger");
configStore.setPaused(true);
return;
}
const paused = configStore.togglePause();
logStore.addLog(paused ? '脚本已暂停' : '脚本已继续', paused ? 'warning' : 'success');
};
return () => vue.h('div', { style: position.value, class: "main-page script-container" }, [
configStore.isMinus
? vue.h('div', {
class: ['mini-circle', { paused: isPaused.value }],
onPointerdown: startCircleDrag,
title: '单击展开 / 按住拖动'
})
: vue.h('div', { class: 'main-panel' }, [
vue.h(vue.resolveComponent('el-card'), { shadow: "always" }, {
header: () => vue.h('div', {
class: ["card-header"],
onPointerdown: startPanelDrag
}, [
vue.h('div', { class: "title" }, [
vue.h('span', "🚀"),
vue.h('span', configStore.platformParams.cx.name)
]),
vue.h('div', { class: 'header-btns' }, [
vue.h('div', {
class: ['pause-btn', { playing: isPaused.value }],
onPointerdown: (e) => { e.stopPropagation(); },
onClick: (e) => { e.stopPropagation(); handleTogglePause(); },
title: isPaused.value ? '继续' : '暂停'
}, [
vue.h('div', { class: 'pause-icon' })
]),
vue.h('div', {
class: "minimize-btn",
onPointerdown: (e) => { e.stopPropagation(); },
onClick: (e) => { e.stopPropagation(); handleMinimize(); },
title: "最小化"
})
])
]),
default: () => vue.h(IndexComponent)
})
])
]);
}
};
const App = {
setup() {
const configStore = useConfigStore();
configStore.platformName = "cx";
return () => vue.h(LayoutComponent);
}
};
// ==================== 19. 初始化 ====================
const initApp = () => {
if (!isTopWindow) {
console.log('[超星自行火炮] 在iframe中运行,跳过UI初始化');
return;
}
const containerId = 'chaoxing-helper-root';
if (document.getElementById(containerId)) {
console.log('[超星自行火炮] 容器已存在,跳过初始化');
return;
}
const app = vue.createApp(App);
app.use(pinia.createPinia());
app.use(ElementPlus);
const container = document.createElement("div");
container.id = containerId;
document.body.appendChild(container);
const shadow = container.attachShadow({ mode: "closed" });
const appDiv = document.createElement("div");
shadow.appendChild(appDiv);
const eleStyle = _GM_getResourceText("ElementPlusStyle");
if (eleStyle) {
const styleSheet = new CSSStyleSheet();
styleSheet.replaceSync(eleStyle);
shadow.adoptedStyleSheets = [styleSheet];
}
const customSheet = new CSSStyleSheet();
customSheet.replaceSync(customStyles);
shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, customSheet];
app.mount(appDiv);
console.log('[超星自行火炮] UI初始化完成');
};
if (document.readyState === "complete") {
initApp();
} else {
window.addEventListener('load', initApp, { once: true });
}
})(Vue, Pinia, md5, ElementPlus);