// ==UserScript== // @name 超星自行火炮 // @namespace chaoxing-helper // @version 4.6.2 // @author isMobile // @description 超星自行火炮——自动化完成超星视频/音频/文档/答题(选择/简答)任务,不连接题库,使用大模型完成答题,仅供内部使用,用以测试课程流程,不外传 // @license MIT // @icon https://vitejs.dev/logo.svg // @match *://*.chaoxing.com/* // @match *://*.edu.cn/* // @match *://*.nbdlib.cn/* // @match *://*.hnsyu.net/* // @match *://*.gdhkmooc.com/* // @match *://www.bing.com/* // @match *://cn.bing.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_openInTab // @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.4' } }; const _GM_setValue = typeof GM_setValue !== 'undefined' ? GM_setValue : () => {}; const _GM_addValueChangeListener = typeof GM_addValueChangeListener !== 'undefined' ? GM_addValueChangeListener : null; const _GM_openInTab = typeof GM_openInTab !== 'undefined' ? GM_openInTab : 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.4' }); 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 BROWSER_SEARCH_MAX_RESULTS = 2; const BROWSER_SEARCH_CONTEXT_MAX_CHARS = 12000; const BROWSER_SEARCH_RESULT_MAX_CONTENT_CHARS = 700; const BROWSER_BING_SEARCH_ENDPOINT = "https://www.bing.com/search"; const DEFAULT_BROWSER_SEARCH_SITE = "www.jhq8.cn"; const BROWSER_SEARCH_BRIDGE_TASK_KEY = "__cx_browser_search_task__"; const BROWSER_SEARCH_BRIDGE_RESULT_PREFIX = "__cx_browser_search_result__:"; const BROWSER_SEARCH_BRIDGE_PARAM = "cx_search_req"; const BROWSER_SEARCH_BRIDGE_TIMEOUT_MS = 20000; const BROWSER_SEARCH_BRIDGE_POLL_MS = 250; const browserRequest = ({ url, method = "GET", headers = {}, data, timeout = 20000, responseType = "text", overrideMimeType = "" }) => new Promise((resolve, reject) => { _GM_xmlhttpRequest({ url, method, headers, data, timeout, responseType, overrideMimeType, onload: response => resolve(response), onerror: err => reject(err || new Error("request failed")), ontimeout: () => reject(new Error("request timeout")) }); }); const createBrowserSearchRequestId = () => `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; const getBrowserSearchResultKey = (requestId) => `${BROWSER_SEARCH_BRIDGE_RESULT_PREFIX}${requestId}`; const readBrowserBridgeValue = (key, fallback = null) => { try { const raw = _GM_getValue(key, ""); if (!raw) return fallback; return JSON.parse(String(raw)); } catch { return fallback; } }; const writeBrowserBridgeValue = (key, value) => { try { _GM_setValue(key, JSON.stringify(value)); } catch {} }; const clearBrowserBridgeValue = (key) => { try { _GM_setValue(key, ""); } catch {} }; const parseHeaderValue = (headersText, key) => { const match = String(headersText || "").match(new RegExp(`^${key}:\\s*([^\\r\\n]+)`, "im")); return match?.[1]?.trim() || ""; }; const detectBrowserCharset = (contentType = "", htmlText = "") => { const headerMatch = String(contentType || "").match(/charset=([^;]+)/i); if (headerMatch?.[1]) return headerMatch[1].trim().toLowerCase(); const metaMatch = String(htmlText || "").match(/]+charset=["']?\\s*([^"'>/\\s]+)/i); if (metaMatch?.[1]) return metaMatch[1].trim().toLowerCase(); return ""; }; const decodeBrowserArrayBuffer = (buffer, contentType = "") => { const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer || []); const utf8Text = new TextDecoder("utf-8").decode(bytes); const charset = detectBrowserCharset(contentType, utf8Text.slice(0, 2048)); if (!charset || /^utf-?8$/i.test(charset)) return utf8Text; if (/^(gb2312|gbk|gb18030)$/i.test(charset)) { try { return new TextDecoder("gb18030").decode(bytes); } catch {} } return utf8Text; }; const normalizeBrowserHtmlEntities = (text) => String(text || "") .replace(/ /gi, " ") .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))) .replace(/&#([0-9]+);/g, (_, num) => String.fromCharCode(parseInt(num, 10))); const resolveBrowserUrl = (rawUrl, baseUrl = "") => { const normalized = normalizeBrowserHtmlEntities(String(rawUrl || "").trim()); if (!normalized) return ""; try { return new URL(normalized, baseUrl || window.location.href).href; } catch { return normalized; } }; const stripBrowserHtmlTags = (htmlText) => normalizeBrowserHtmlEntities( String(htmlText || "") .replace(/)<[^<]*)*<\/script>/gi, " ") .replace(/)<[^<]*)*<\/style>/gi, " ") .replace(/<[^>]+>/g, " ") ).replace(/\s+/g, " ").trim(); const cleanBrowserSearchText = (text) => stripBrowserHtmlTags(String(text || "")) .replace(/\s+/g, " ") .trim(); const isLikelyBrowserGarbledText = (text) => { const src = String(text || "").trim(); if (!src) return false; const replacementCount = (src.match(/�/g) || []).length; const questionMarkCount = (src.match(/\?/g) || []).length; const visibleLength = Array.from(src).filter(ch => !/\s/.test(ch)).length || 1; return replacementCount >= 3 || questionMarkCount / visibleLength > 0.2; }; const normalizeBrowserNetField = (value) => String(value || "") .replace(/\s+/g, " ") .trim() .replace(//g, ")"); const normalizeBrowserMultilineField = (value) => String(value || "") .replace(/\r/g, "") .replace(/\u00A0/g, " ") .replace(/[ \t]+\n/g, "\n") .replace(/\n[ \t]+/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); const normalizeBrowserCompareText = (value) => String(value || "") .toUpperCase() .replace(/\s+/g, "") .replace(/[,,。;;::“”"'‘’()()【】\[\]<>《》·\-—_]/g, ""); const decodeBrowserArticleText = (html) => normalizeBrowserHtmlEntities(String(html || "")) .replace(//gi, "\n") .replace(/<\/p\s*>/gi, "\n") .replace(/]*>/gi, "\n") .replace(/<\/div\s*>/gi, "\n") .replace(/]*>/gi, "\n") .replace(/<\/li\s*>/gi, "\n") .replace(/]*>/gi, "\n") .replace(/)<[^<]*)*<\/script>/gi, " ") .replace(/)<[^<]*)*<\/style>/gi, " ") .replace(/<[^>]+>/g, " ") .replace(/[ \t]+\n/g, "\n") .replace(/\n[ \t]+/g, "\n") .replace(/\n{2,}/g, "\n") .trim(); const splitBrowserAnswerParts = (value) => String(value || "") .split(/(?:;|;|、|,|,|\s+|(?:和|及|与))/) .map(item => item.trim()) .filter(Boolean); const parseBrowserOptionEntries = (bodyText) => { const text = String(bodyText || ""); const regex = /(^|[\s\n((【\[])([A-H])\s*[、..::]?\s*/g; const matches = []; let match = null; while ((match = regex.exec(text)) !== null) { const prefix = match[1] || ""; const key = String(match[2] || "").toUpperCase(); const start = match.index + prefix.length; const contentStart = regex.lastIndex; if (matches.length > 0 && start <= matches[matches.length - 1].start) continue; matches.push({ key, start, contentStart }); } if (matches.length < 2) return null; const stem = text.slice(0, matches[0].start).trim(); if (!stem) return null; const options = matches.map((item, index) => { const next = matches[index + 1]; const textValue = text.slice(item.contentStart, next ? next.start : text.length).trim(); return { key: item.key, text: textValue }; }).filter(item => item.key && item.text); return options.length >= 2 ? { stem, options } : null; }; const extractBrowserAnswerKeys = (answerRaw, options) => { const normalized = String(answerRaw || "").toUpperCase().trim(); if (!normalized) return []; const optionKeys = new Set((options || []).map(item => item.key)); const prefixMatch = normalized.match(/^[A-H](?:[\s,,、;;\/|和及与]*[A-H])*/); if (prefixMatch?.[0]) { const prefixKeys = [...new Set((prefixMatch[0].match(/[A-H]/g) || []).filter(key => optionKeys.has(key)))]; if (prefixKeys.length > 0) return prefixKeys; } const pureAnswer = normalized.replace(/[A-H\s,,、;;\/|和及与]/g, ""); if (!pureAnswer) { const directKeys = [...new Set((normalized.match(/[A-H]/g) || []).filter(key => optionKeys.has(key)))]; if (directKeys.length > 0) return directKeys; } return []; }; const resolveBrowserAnswerTexts = (answerRaw, options) => { const entries = Array.isArray(options) ? options : []; if (entries.length === 0) return []; const keys = extractBrowserAnswerKeys(answerRaw, entries); if (keys.length > 0) { return keys .map(key => entries.find(item => item.key === key)?.text || "") .filter(Boolean); } const normalizedAnswer = normalizeBrowserCompareText(answerRaw); if (!normalizedAnswer) return []; const direct = entries.filter(item => { const normalizedOption = normalizeBrowserCompareText(item.text); return normalizedOption && ( normalizedAnswer === normalizedOption || normalizedAnswer.includes(normalizedOption) || normalizedOption.includes(normalizedAnswer) ); }).map(item => item.text); if (direct.length > 0) return [...new Set(direct)]; const parts = splitBrowserAnswerParts(answerRaw); const matched = []; for (const part of parts) { const normalizedPart = normalizeBrowserCompareText(part); if (!normalizedPart) continue; for (const item of entries) { const normalizedOption = normalizeBrowserCompareText(item.text); if (!normalizedOption) continue; if ( normalizedPart === normalizedOption || normalizedPart.includes(normalizedOption) || normalizedOption.includes(normalizedPart) ) { matched.push(item.text); } } } return [...new Set(matched)]; }; const parseBrowserFocusedArticle = (heading, articleText) => { const rawText = normalizeBrowserMultilineField(articleText); if (!rawText) return ""; const answerMatch = rawText.match(/(?:正确答案|参考答案|答案)\s*[::]?\s*([\s\S]+)$/); if (!answerMatch) { const stemOnly = normalizeBrowserMultilineField(rawText || heading || ""); return stemOnly ? `题干:${stemOnly}` : ""; } const answerRaw = normalizeBrowserMultilineField(answerMatch[1]); const bodyText = normalizeBrowserMultilineField(rawText.slice(0, answerMatch.index)); const parsedOptions = parseBrowserOptionEntries(bodyText); const stem = normalizeBrowserMultilineField( parsedOptions?.stem || bodyText || heading ); if (!stem) return ""; const answerTexts = resolveBrowserAnswerTexts(answerRaw, parsedOptions?.options || []); const answerLabel = answerTexts.length > 0 ? "正确选项" : "正确答案"; const answerValue = answerTexts.length > 0 ? answerTexts.join(";") : answerRaw; if (!answerValue) return `题干:${stem}`; return `题干:${stem}\n${answerLabel}:${normalizeBrowserMultilineField(answerValue)}`; }; const limitBrowserVisibleChars = (text, maxChars) => { const src = String(text || ""); const n = Number(maxChars) || 0; if (n <= 0 || !src) return ""; let count = 0; let end = 0; for (const ch of src) { end += ch.length; if (!/[\p{P}\p{S}\s]/u.test(ch)) count += 1; if (count >= n) { const sliced = src.slice(0, end).trim(); return end < src.length ? `${sliced}...` : sliced; } } return src; }; const extractBrowserHostname = (url) => { try { return new URL(String(url || "")).hostname.toLowerCase(); } catch { return ""; } }; const normalizeBrowserSiteDomain = (siteDomain = DEFAULT_BROWSER_SEARCH_SITE) => String(siteDomain || DEFAULT_BROWSER_SEARCH_SITE).trim().replace(/^https?:\/\//i, "").replace(/\/+$/, ""); const isBrowserSearchBridgePage = () => { try { const host = String(window.location.hostname || "").toLowerCase(); if (!/(^|\.)bing\.com$/.test(host)) return false; return !!new URL(window.location.href).searchParams.get(BROWSER_SEARCH_BRIDGE_PARAM); } catch { return false; } }; const isBrowserHostMatched = (url, siteDomain) => { const site = normalizeBrowserSiteDomain(siteDomain); const host = extractBrowserHostname(url); return !!host && (host === site || host.endsWith(`.${site}`)); }; const isBrowserSingleQuestionUrl = (url, siteDomain) => { if (!isBrowserHostMatched(url, siteDomain)) return false; return /\/daan\/\d{4}\/\d{2}\/\d+\.html(?:[?#].*)?$/i.test(String(url || "")); }; const buildBrowserSearchQueryFromQuestion = (question) => String(question || "") .replace(/^\s*\d+\s*[.、\]]\s*/, "") .replace(/_{2,}/g, " ") .replace(/[()()]/g, " ") .replace(/\s+/g, " ") .replace(/[[\]"'“”‘’【】]/g, " ") .replace(/[!?。!?]+$/g, "") .trim() .slice(0, 160); const buildBrowserBingQuery = (query, siteDomain = DEFAULT_BROWSER_SEARCH_SITE) => { const q = String(query || "").trim(); const site = normalizeBrowserSiteDomain(siteDomain); if (!q || !site) return ""; return `${q} site:${site}`; }; const collectBrowserBingAnchors = (item, baseUrl = "") => { const anchors = Array.from(item?.querySelectorAll?.("a[href]") || []); const seen = new Set(); const ordered = []; for (const anchor of anchors) { const rawHref = String(anchor?.getAttribute?.("href") || "").trim(); const url = resolveBrowserUrl(rawHref, baseUrl); if (!url || seen.has(url)) continue; seen.add(url); ordered.push({ anchor, url }); } return ordered; }; const findBrowserNearestTitle = (anchor) => { if (!anchor) return ""; const heading = anchor.closest("h1, h2, h3, h4"); if (heading?.textContent) return cleanBrowserSearchText(heading.textContent); const block = anchor.closest("article, li, div"); const blockHeading = block?.querySelector?.("h1, h2, h3, h4"); if (blockHeading?.textContent) return cleanBrowserSearchText(blockHeading.textContent); return cleanBrowserSearchText(anchor.textContent || ""); }; const findBrowserNearestSnippet = (anchor) => { const block = anchor?.closest?.("article, li, div"); if (!block) return ""; const snippetNode = block.querySelector?.("p, .b_caption, .b_lineclamp2, .tob_Paragraph"); return cleanBrowserSearchText(snippetNode?.textContent || ""); }; const isBrowserBingRedirectUrl = (url) => { try { const parsed = new URL(String(url || "")); return /(^|\.)bing\.com$/i.test(parsed.hostname) && /^\/ck\//i.test(parsed.pathname); } catch { return false; } }; const resolveBrowserCandidateUrl = async (url) => { if (!url) return ""; if (!isBrowserBingRedirectUrl(url)) return url; try { const response = await browserRequest({ url, method: "GET", timeout: 15000, responseType: "text", headers: { Accept: "text/html,application/xhtml+xml" } }); const finalUrl = String(response?.finalUrl || response?.responseURL || url || ""); return resolveBrowserUrl(finalUrl, url); } catch { return url; } }; const blockLooksLikeTargetSite = (anchor, siteDomain) => { const site = normalizeBrowserSiteDomain(siteDomain); const block = anchor?.closest?.("article, li, div"); const text = cleanBrowserSearchText(block?.textContent || ""); return !!text && text.toLowerCase().includes(site.toLowerCase()) && /daan/i.test(text); }; const extractBrowserRowsFromDocument = (doc, siteDomain) => { const rows = []; const seenUrls = new Set(); const anchors = Array.from(doc?.querySelectorAll?.("a[href]") || []); for (const anchor of anchors) { const rawHref = String(anchor?.getAttribute?.("href") || "").trim(); const url = resolveBrowserUrl(rawHref, doc?.baseURI || window.location.href); if (!url || seenUrls.has(url) || !isBrowserSingleQuestionUrl(url, siteDomain)) continue; const title = findBrowserNearestTitle(anchor); if (!title) continue; const snippet = findBrowserNearestSnippet(anchor); seenUrls.add(url); rows.push({ title, url, snippet, rawContent: snippet }); if (rows.length >= BROWSER_SEARCH_MAX_RESULTS) break; } return rows; }; const extractBrowserCandidateRowsFromDocument = (doc, siteDomain) => { const rows = []; const seenUrls = new Set(); const anchors = Array.from(doc?.querySelectorAll?.("a[href]") || []); for (const anchor of anchors) { const rawHref = String(anchor?.getAttribute?.("href") || "").trim(); const url = resolveBrowserUrl(rawHref, doc?.baseURI || window.location.href); if (!url || seenUrls.has(url)) continue; if (!isBrowserSingleQuestionUrl(url, siteDomain) && !blockLooksLikeTargetSite(anchor, siteDomain)) continue; const title = findBrowserNearestTitle(anchor); if (!title) continue; const snippet = findBrowserNearestSnippet(anchor); seenUrls.add(url); rows.push({ title, url, snippet, rawContent: snippet }); if (rows.length >= 12) break; } return rows; }; const parseBrowserBingDocumentRows = (doc, siteDomain) => { const extracted = extractBrowserRowsFromDocument(doc, siteDomain); if (extracted.length > 0) return extracted; const rows = []; const seenUrls = new Set(); const items = Array.from(doc?.querySelectorAll?.("li.b_algo") || []); for (const item of items) { const candidates = collectBrowserBingAnchors(item, doc?.baseURI || window.location.href); const matched = candidates.find(({ url }) => isBrowserSingleQuestionUrl(url, siteDomain)); if (!matched || seenUrls.has(matched.url)) continue; const title = cleanBrowserSearchText( matched.anchor?.textContent || item.querySelector("h2")?.textContent || "" ); if (!title) continue; const snippet = cleanBrowserSearchText( item.querySelector(".b_caption p")?.textContent || item.querySelector("p")?.textContent || "" ); seenUrls.add(matched.url); rows.push({ title, url: matched.url, snippet, rawContent: snippet }); if (rows.length >= BROWSER_SEARCH_MAX_RESULTS) break; } return rows; }; const parseBrowserBingDocumentRowsResolved = async (doc, siteDomain) => { const directRows = parseBrowserBingDocumentRows(doc, siteDomain); if (directRows.length > 0) return directRows; const candidates = extractBrowserCandidateRowsFromDocument(doc, siteDomain); const rows = []; const seen = new Set(); for (const item of candidates) { const resolvedUrl = await resolveBrowserCandidateUrl(item.url); if (!resolvedUrl || seen.has(resolvedUrl) || !isBrowserSingleQuestionUrl(resolvedUrl, siteDomain)) continue; seen.add(resolvedUrl); rows.push({ ...item, url: resolvedUrl }); if (rows.length >= BROWSER_SEARCH_MAX_RESULTS) break; } return rows; }; const buildBrowserFocusedContent = (html) => { try { const doc = new DOMParser().parseFromString(String(html || ""), "text/html"); const heading = cleanBrowserSearchText(doc.querySelector("h1")?.textContent || ""); const articleHtml = String(doc.querySelector(".is-article")?.innerHTML || ""); const articleText = decodeBrowserArticleText(articleHtml); if (!articleText) return ""; return parseBrowserFocusedArticle(heading, articleText); } catch { return ""; } }; const fetchBrowserFocusedPageContent = async (url, siteDomain) => { if (!url || !isBrowserSingleQuestionUrl(url, siteDomain)) return ""; try { const textResponse = await browserRequest({ url, method: "GET", headers: { Accept: "text/html,application/xhtml+xml" }, timeout: 15000, responseType: "text", overrideMimeType: "text/html; charset=gb18030" }); const textHtml = String(textResponse?.responseText || textResponse?.response || ""); let content = buildBrowserFocusedContent(textHtml); if (content && !isLikelyBrowserGarbledText(content)) return content; const response = await browserRequest({ url, method: "GET", headers: { Accept: "text/html,application/xhtml+xml" }, timeout: 15000, responseType: "arraybuffer" }); const contentType = parseHeaderValue(response.responseHeaders, "content-type"); const html = decodeBrowserArrayBuffer(response.response, contentType); content = buildBrowserFocusedContent(html); return isLikelyBrowserGarbledText(content) ? "" : content; } catch { return ""; } }; const formatBrowserSearchContext = (results) => { const rows = []; for (let index = 0; index < results.length; index += 1) { const item = results[index] || {}; const text = normalizeBrowserMultilineField(item.content || ""); const mergedContent = normalizeBrowserNetField(limitBrowserVisibleChars(text, BROWSER_SEARCH_RESULT_MAX_CONTENT_CHARS)) .replace(/\s*正确选项:/g, "\n正确选项:") .replace(/\s*正确答案:/g, "\n正确答案:") .replace(/^题干:/g, "题干:"); rows.push(`例题${index + 1}\n${mergedContent || "题干:无内容"}`); } const joined = rows.join("\n\n").trim(); if (joined.length <= BROWSER_SEARCH_CONTEXT_MAX_CHARS) return joined || "<无搜索结果>"; return `${joined.slice(0, BROWSER_SEARCH_CONTEXT_MAX_CHARS)}...(truncated)`; }; const waitForBrowserSearchBridgeResult = async (requestId, timeoutMs = BROWSER_SEARCH_BRIDGE_TIMEOUT_MS) => { const resultKey = getBrowserSearchResultKey(requestId); const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { const result = readBrowserBridgeValue(resultKey, null); if (result && result.requestId === requestId) return result; await sleep(BROWSER_SEARCH_BRIDGE_POLL_MS / 1000); } return null; }; const runBrowserSearchBridgeTask = async () => { if (!isBrowserSearchBridgePage()) return false; const requestId = new URL(window.location.href).searchParams.get(BROWSER_SEARCH_BRIDGE_PARAM); const task = readBrowserBridgeValue(BROWSER_SEARCH_BRIDGE_TASK_KEY, null); if (!task || task.requestId !== requestId) return true; const siteDomain = normalizeBrowserSiteDomain(task.siteDomain || DEFAULT_BROWSER_SEARCH_SITE); const searchQuery = String(task.searchQuery || "").trim(); const startedAt = Date.now(); let rows = []; while (Date.now() - startedAt < 18000) { rows = await parseBrowserBingDocumentRowsResolved(document, siteDomain); if (rows.length > 0) break; await sleep(0.3); } const enrichedRows = await Promise.all(rows.map(async (item) => ({ ...item, content: normalizeBrowserMultilineField(await fetchBrowserFocusedPageContent(item.url, siteDomain) || "") }))); const validRows = enrichedRows.filter(item => item.title && item.url && item.content && !isLikelyBrowserGarbledText(item.content) ); const payload = { requestId, searchQuery, searchContext: validRows.length > 0 ? formatBrowserSearchContext(validRows) : "<无搜索结果>", searchHits: validRows.length, searchProvider: "browser_bing_dom" }; writeBrowserBridgeValue(getBrowserSearchResultKey(requestId), payload); return true; }; const searchInBrowserViaBridge = async (searchQuery, siteDomain) => { const site = normalizeBrowserSiteDomain(siteDomain); const bingQuery = buildBrowserBingQuery(searchQuery, site); if (!bingQuery || !_GM_openInTab) { return { searchQuery, searchContext: "<无搜索结果>", searchHits: 0, searchProvider: "browser_bing_dom" }; } const requestId = createBrowserSearchRequestId(); const resultKey = getBrowserSearchResultKey(requestId); const url = `${BROWSER_BING_SEARCH_ENDPOINT}?q=${encodeURIComponent(bingQuery)}&setlang=zh-CN&${BROWSER_SEARCH_BRIDGE_PARAM}=${encodeURIComponent(requestId)}`; writeBrowserBridgeValue(BROWSER_SEARCH_BRIDGE_TASK_KEY, { requestId, searchQuery, siteDomain: site, bingQuery, createdAt: Date.now() }); clearBrowserBridgeValue(resultKey); let tabHandle = null; try { tabHandle = _GM_openInTab(url, { active: false, insert: true, setParent: true }); } catch (error) { console.warn("[cx-search] bridge open tab failed", error); return { searchQuery, searchContext: "<无搜索结果>", searchHits: 0, searchProvider: "browser_bing_dom" }; } const result = await waitForBrowserSearchBridgeResult(requestId); try { tabHandle?.close?.(); } catch {} clearBrowserBridgeValue(BROWSER_SEARCH_BRIDGE_TASK_KEY); clearBrowserBridgeValue(resultKey); return result || { searchQuery, searchContext: "<无搜索结果>", searchHits: 0, searchProvider: "browser_bing_dom" }; }; const searchInBrowser = async (query, siteDomain) => { const searchQuery = buildBrowserSearchQueryFromQuestion(query); if (!searchQuery) { return { searchQuery: "", searchContext: "<无搜索结果>", searchHits: 0, searchProvider: "browser_bing_dom" }; } return searchInBrowserViaBridge(searchQuery, siteDomain); }; 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: [] } }; } let browserSearchPayload = null; if (configStore.otherSettings.enableWebSearch) { log("浏览器侧联网搜索中...", "info"); browserSearchPayload = await searchInBrowser(question.title, DEFAULT_BROWSER_SEARCH_SITE); log(`浏览器侧联网搜索完成,命中 ${browserSearchPayload.searchHits} 条`, browserSearchPayload.searchHits > 0 ? "success" : "warning"); } 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, searchContext: browserSearchPayload?.searchContext || "", searchQuery: browserSearchPayload?.searchQuery || "", searchProvider: browserSearchPayload?.searchProvider || "", searchHits: browserSearchPayload?.searchHits || 0 }); 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" }); const CX_WORK_TASK_PHASES = Object.freeze({ ANSWERING: "answering", SUBMITTING: "submitting", SAVING: "saving", SUBMIT_PENDING: "submit_pending", FINISHED: "finished" }); 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); this.onProgress = typeof options.onProgress === "function" ? options.onProgress : null; this.shouldAbort = typeof options.shouldAbort === "function" ? options.shouldAbort : () => false; } 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(); } isAborted() { try { return this.shouldAbort() === true; } catch { return false; } } reportProgress(patch = {}) { try { this.onProgress?.(patch); } catch {} } } 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; let processedCount = 0; this.reportProgress({ phase: CX_WORK_TASK_PHASES.ANSWERING, totalQuestions: total, processedQuestionCount: 0, currentQuestionIndex: 0, progressLabel: "parsed" }); for (const [index, question] of this.questions.entries()) { if (this.isAborted()) { return { total, hit: hitCount, hitRate: 0, aborted: true, processed: processedCount }; } await waitIfPaused(this.addLog); if (this.isAborted()) { return { total, hit: hitCount, hitRate: 0, aborted: true, processed: processedCount }; } this.reportProgress({ phase: CX_WORK_TASK_PHASES.ANSWERING, totalQuestions: total, processedQuestionCount: processedCount, currentQuestionIndex: index + 1, progressLabel: "requesting" }); const answerData = await getAnswer(question, this.addLog); if (this.isAborted()) { return { total, hit: hitCount, hitRate: 0, aborted: true, processed: processedCount }; } if (answerData.code === 200 && answerData.data.answer?.length) { hitCount += 1; question.answer = answerData.data.answer; this.reportProgress({ phase: CX_WORK_TASK_PHASES.ANSWERING, totalQuestions: total, processedQuestionCount: processedCount, currentQuestionIndex: index + 1, progressLabel: "received" }); this.fillQuestion(question); if (this.isAborted()) { return { total, hit: hitCount, hitRate: 0, aborted: true, processed: processedCount }; } this.addLog(`第 ${index + 1} 题已作答`, "success"); } else { this.addLog(`第 ${index + 1} 题未找到答案`, "warning"); question.answer = [answerData.msg || "未找到答案"]; } this.addQuestion(question); processedCount += 1; this.reportProgress({ phase: CX_WORK_TASK_PHASES.ANSWERING, totalQuestions: total, processedQuestionCount: processedCount, currentQuestionIndex: index + 1, progressLabel: "processed" }); } const hitRate = total ? Math.round((hitCount / total) * 100) : 0; this.addLog(`答题完成,命中率 ${hitCount}/${total} (${hitRate}%)`, hitCount ? "success" : "warning"); return { total, hit: hitCount, hitRate, processed: processedCount }; } 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 }; const WORK_TASK_PHASES = CX_WORK_TASK_PHASES; const WORK_WATCHDOG_LIMITS = Object.freeze({ [WORK_TASK_PHASES.ANSWERING]: 120000, [WORK_TASK_PHASES.SUBMITTING]: 45000, [WORK_TASK_PHASES.SAVING]: 45000, [WORK_TASK_PHASES.SUBMIT_PENDING]: 30000 }); 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_PENDING_REFRESH_MAX = 3; const PAGE_PENDING_REFRESH_STORAGE_KEY = "cx_pending_page_refresh_state_v1"; 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 reloadCurrentPage = (message = "", addLog = null) => { const log = typeof addLog === "function" ? addLog : (msg, type = "warning") => logStore.addLog(msg, type); if (message) { log(message, "warning"); } touchActivity(); try { window.location.reload(); return true; } catch { return false; } }; 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 getPendingPageRetrySignature = () => { const mainIframe = getMainIframe(); return JSON.stringify({ chapterId: getChapterId() || "", pageUrl: window.location.href || "", iframeSrc: mainIframe?.src || "" }); }; const readPendingPageRetryState = () => { try { const raw = window.sessionStorage.getItem(PAGE_PENDING_REFRESH_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") return null; return { signature: typeof parsed.signature === "string" ? parsed.signature : "", count: Number(parsed.count || 0) }; } catch { return null; } }; const writePendingPageRetryState = (signature, count) => { try { window.sessionStorage.setItem(PAGE_PENDING_REFRESH_STORAGE_KEY, JSON.stringify({ signature, count: Number(count || 0) })); } catch {} }; const clearPendingPageRetryState = () => { try { window.sessionStorage.removeItem(PAGE_PENDING_REFRESH_STORAGE_KEY); } catch {} }; const handlePendingPageRecovery = (stats) => { const signature = getPendingPageRetrySignature(); const existing = readPendingPageRetryState(); const nextCount = existing?.signature === signature ? Number(existing.count || 0) + 1 : 1; if (nextCount >= PAGE_PENDING_REFRESH_MAX) { clearPendingPageRetryState(); currentTaskId += 1; clearActiveTaskState(); queuedTaskFrames = []; pendingJumpStats = stats; chapterPhase = CHAPTER_PHASES.NEXT; touchActivity(); logStore.addLog( `当前页面连续 ${PAGE_PENDING_REFRESH_MAX} 次检测到未完成任务点(${stats.pendingCount}/${stats.total}),直接进入下一页面`, "warning" ); return true; } writePendingPageRetryState(signature, nextCount); currentTaskId += 1; clearActiveTaskState(); queuedTaskFrames = []; pendingJumpStats = null; chapterPhase = CHAPTER_PHASES.DISCOVER; return reloadCurrentPage( `检测到仍有未完成任务点(${stats.pendingCount}/${stats.total}),刷新当前页面重试(${nextCount}/${PAGE_PENDING_REFRESH_MAX})` ); }; 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 (typeof patch.phase === "string") current.phase = patch.phase; if (typeof patch.runToken === "number") current.runToken = patch.runToken; if (typeof patch.totalQuestions === "number") current.totalQuestions = patch.totalQuestions; if (typeof patch.currentQuestionIndex === "number") current.currentQuestionIndex = patch.currentQuestionIndex; if (typeof patch.processedQuestionCount === "number") current.processedQuestionCount = patch.processedQuestionCount; if (typeof patch.progressLabel === "string") current.progressLabel = patch.progressLabel; 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 markTaskProgressWithPatch = (meta, patch = {}) => { touchProgress(); resetWatchdogRecovery(); touchTaskState(meta, { ...patch, progress: true, type: patch.type || meta?.taskType }); }; const getWorkTaskStaleMs = (state) => { if (state?.type !== "work") return WATCHDOG_CONFIG.staleTaskMs; return WORK_WATCHDOG_LIMITS[state.phase] || Math.max(WATCHDOG_CONFIG.staleTaskMs, 60000); }; const isProtectedWorkPhase = (state) => state?.type === "work" && [ WORK_TASK_PHASES.ANSWERING, WORK_TASK_PHASES.SUBMITTING, WORK_TASK_PHASES.SAVING, WORK_TASK_PHASES.SUBMIT_PENDING ].includes(state.phase); 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") { if (handlePendingPageRecovery(stats)) return; logPendingTasks(stats); } chapterPhase = CHAPTER_PHASES.DISCOVER; return; } clearPendingPageRetryState(); // 分页视为同一章节内的连续页面,优先进入下一分页。 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; clearActiveTaskState(); queuedTaskFrames = []; pendingJumpStats = null; chapterPhase = CHAPTER_PHASES.DISCOVER; return reloadCurrentPage(`恢复 L3:刷新当前页面(${reason})`); } currentTaskId += 1; clearActiveTaskState(); queuedTaskFrames = []; pendingJumpStats = null; chapterPhase = CHAPTER_PHASES.DISCOVER; return reloadCurrentPage(`恢复 L4:刷新当前页面(${reason})`); }; 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); const staleTaskMs = getWorkTaskStaleMs(activeState); if (now - lastProgressAt > staleTaskMs) { recoverWithLevel(Math.min(watchdogRecoveryLevel + 1, 4), "当前任务长时间无进展"); return; } if (isProtectedWorkPhase(activeState)) { 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", ABORTED: "aborted", 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 shouldAbort = typeof context?.shouldAbort === "function" ? context.shouldAbort : null; const onProgress = typeof context?.onProgress === "function" ? context.onProgress : 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) { if (shouldAbort?.()) { return { state: WORK_FLOW_STATES.ABORTED, message: "" }; } onProgress?.({ phase: mode === "submit" ? WORK_TASK_PHASES.SUBMITTING : WORK_TASK_PHASES.SAVING, progressLabel: `waiting_${mode}_result` }); 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 (shouldAbort?.()) { return { state: WORK_FLOW_STATES.ABORTED, message: "" }; } 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) { touchTaskState(taskMeta, { reloadPending: false, reloadRequestedAt: 0, type: taskMeta?.taskType || mediaType }); checkpoint({ status: "page_reload", currentTime: Number(media.currentTime) || 0, recoveryLevel }); if (reloadCurrentPage(`${typeName}保活恢复 L3:刷新当前页面(${reason})`, log)) { return; } finish({ message: `${typeName}刷新页面失败,交由下一轮重试`, type: "warning", status: "page_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, context = {}) => { if (context?.shouldAbort?.()) { return { saved: false, state: WORK_FLOW_STATES.ABORTED, message: "" }; } const saveTriggered = await callWorkAction(win, "noSubmit", "暂存", log); if (!saveTriggered) { return { saved: false, state: "trigger_failed", message: "" }; } const saveState = await waitWorkActionResult(doc, "save", 12000, { iframe, shouldAbort: context?.shouldAbort, onProgress: context?.onProgress }); 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.ABORTED) { return { saved: false, state: WORK_FLOW_STATES.ABORTED, 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); if (context?.shouldAbort?.()) { return { saved: false, state: WORK_FLOW_STATES.ABORTED, message: "" }; } 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, taskId) => { const log = addLog || ((message, type = "info") => logStore.addLog(message, type)); const workType = taskMeta?.taskType || "work"; const abortResult = { aborted: true }; const pendingResult = { pending: true }; const isTaskAlive = () => taskId === currentTaskId; const syncWorkState = (patch = {}, progress = false) => { if (!isTaskAlive()) return false; if (progress) { markTaskProgressWithPatch(taskMeta, { ...patch, runToken: taskId, type: workType }); } else { touchTaskState(taskMeta, { ...patch, runToken: taskId, type: workType }); touchActivity(); } return true; }; const progressWork = (patch = {}) => syncWorkState(patch, true); const abortIfStale = () => !isTaskAlive(); touchActivity(); log("处理章节测试...", "primary"); if (!doc) return; try { if (!syncWorkState({ phase: WORK_TASK_PHASES.ANSWERING, currentQuestionIndex: 0, processedQuestionCount: 0, progressLabel: "starting" }, true)) { return abortResult; } if (isWorkFinishedByDoc(doc)) { log("测试已完成", "success"); touchTaskState(taskMeta, { finished: true, phase: WORK_TASK_PHASES.FINISHED, type: workType }); return; } await waitIfPaused(log); if (abortIfStale()) return abortResult; FontDecoderModule.decryptDocument(doc); const summary = await new CxQuestionHandler("zj", iframe, { addLog: log, shouldAbort: abortIfStale, onProgress: (patch = {}) => { progressWork({ phase: patch.phase || WORK_TASK_PHASES.ANSWERING, totalQuestions: patch.totalQuestions, currentQuestionIndex: patch.currentQuestionIndex, processedQuestionCount: patch.processedQuestionCount, progressLabel: patch.progressLabel || "answering" }); } }).init(); if (summary?.aborted || abortIfStale()) return abortResult; if (win) win.alert = () => {}; if (!summary?.total) { if (isWorkFinishedByDoc(doc)) { log("测试已完成", "success"); touchTaskState(taskMeta, { finished: true, phase: WORK_TASK_PHASES.FINISHED, type: workType }); touchProgress(); return; } log("未解析到题目,跳过提交", "warning"); return; } let saved = false; let done = false; const shouldSaveOnly = !configStore.chapterSettings.autoSubmit; if (shouldSaveOnly) { if (!progressWork({ phase: WORK_TASK_PHASES.SAVING, totalQuestions: summary.total, currentQuestionIndex: summary.total, processedQuestionCount: summary.processed || summary.total, progressLabel: "before_save" })) { return abortResult; } log("尝试暂存答案", "primary"); const saveResult = await trySaveWork(win, doc, iframe, log, { shouldAbort: abortIfStale, onProgress: (patch = {}) => progressWork({ phase: patch.phase || WORK_TASK_PHASES.SAVING, progressLabel: patch.progressLabel || "saving" }) }); if (saveResult.state === WORK_FLOW_STATES.ABORTED || abortIfStale()) return abortResult; saved = applySaveResultLog(saveResult, log); done = saved || isWorkFinishedByDoc(doc); } else { if (!progressWork({ phase: WORK_TASK_PHASES.SUBMITTING, totalQuestions: summary.total, currentQuestionIndex: summary.total, processedQuestionCount: summary.processed || summary.total, progressLabel: "before_submit" })) { return abortResult; } log("尝试自动提交", "primary"); const submitOk = await callWorkAction(win, "btnBlueSubmit", "提交", log); if (abortIfStale()) return abortResult; if (submitOk) { progressWork({ phase: WORK_TASK_PHASES.SUBMITTING, progressLabel: "submit_clicked" }); await sleep(1); if (abortIfStale()) return abortResult; await callWorkAction(win, "submitCheckTimes", "提交确认", log); if (abortIfStale()) return abortResult; const submitState = await waitWorkActionResult(doc, "submit", 20000, { iframe, shouldAbort: abortIfStale, onProgress: (patch = {}) => progressWork({ phase: patch.phase || WORK_TASK_PHASES.SUBMITTING, progressLabel: patch.progressLabel || "waiting_submit_result" }) }); if (submitState.state === WORK_FLOW_STATES.ABORTED || abortIfStale()) return abortResult; 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"); } else { progressWork({ phase: WORK_TASK_PHASES.SUBMIT_PENDING, progressLabel: "submit_pending" }); log("提交结果未确认,等待下一轮复检", "warning"); return pendingResult; } } } if (!done && !saved) log("提交/暂存未确认成功,后续轮次将重试", "warning"); touchTaskState(taskMeta, { finished: done, saved, phase: done ? WORK_TASK_PHASES.FINISHED : (saved ? WORK_TASK_PHASES.SAVING : WORK_TASK_PHASES.ANSWERING), runToken: taskId, type: workType }); 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, taskId }) => processWork(iframe, doc, win, taskLog, taskMeta, taskId), 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]; let executorResult = null; if (shouldRunTaskExecutor(detail, hasJobIcon) && executor) { executorResult = await executor({ iframe, doc, win, taskId, taskLog, taskMeta }); } if (executorResult?.aborted) { clearActiveTask(taskMeta); return; } if (executorResult?.pending) { clearActiveTask(taskMeta); return; } 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 (isBrowserSearchBridgePage()) { runBrowserSearchBridgeTask(); console.log('[超星自行火炮] Bing搜索桥页面,跳过UI初始化'); return; } if (/(^|\.)bing\.com$/i.test(window.location.hostname || "")) { console.log('[超星自行火炮] Bing页面,跳过UI初始化'); return; } 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);