// ==UserScript==
// @name 学习通刷课助手
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description 学习通座位预约+视频自动播放+自动答题一站式助手
// @author 叶屿
// @license GPL3
// @antifeature payment 题库答题功能需要验证码(免费)或激活码(付费),视频播放等基础功能完全免费
// @match *://*.chaoxing.com/*
// @match *://office.chaoxing.com/*
// @run-at document-end
// @icon https://www.chaoxing.com/favicon.ico
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant unsafeWindow
// @connect office.chaoxing.com
// @connect qsy.iano.cn
// @connect lyck6.cn
// @connect open.bigmodel.cn
// @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js
// @require https://unpkg.com/tesseract.js@v2.1.0/dist/tesseract.min.js
// ==/UserScript==
/*
* ==========================================
* 学习通刷课助手 v1.0.0
* ==========================================
*
* 【功能说明】
* 1. 视频自动播放(支持倍速、自动推进章节、自动跳过测验)
* 2. 作业自动答题(题库查询 + AI 答题)
* 3. 考试自动答题(支持停止/继续)
* 4. 座位预约(定时预约、房间查询)
* 5. 双模式题库(免费验证码 / 付费激活码)
*
* 【积分购买】
* 联系微信:C919irt
* 价格表:50积分=2元,100积分=4元,150积分=6元,200积分=8元,500积分=18元
* 说明:每次答题消耗1积分,积分永久有效
*
* 【付费声明】
* 本脚本基础功能(视频播放、座位预约)完全免费
* 题库答题功能需要验证码(免费24小时)或激活码(付费永久)
* 付费仅用于题库API调用成本,不强制购买
*
* 【免责声明】
* 本脚本仅供学习交流使用,请勿用于违反学校规定或作弊行为
* 使用本脚本造成的任何后果由使用者自行承担
*
* 【版权信息】
* 作者:叶屿 | 版本:v1.0.0
*
* ==========================================
*/
(() => {
'use strict';
// ==========================================
// ConfigStore - 配置存储模块
// ==========================================
const ConfigStore = {
// AES 加密内置密钥(补齐到 16 字节)
_key: CryptoJS.enc.Utf8.parse('chaoxing_cfg_key'),
_iv: CryptoJS.enc.Utf8.parse('chaoxing_cfg_key'),
// 通用存取(GM_setValue / GM_getValue)
get(key) {
const raw = GM_getValue(key, undefined);
if (raw === undefined) return undefined;
try { return JSON.parse(raw); } catch (_) { return raw; }
},
set(key, value) {
GM_setValue(key, JSON.stringify(value));
},
remove(key) {
GM_deleteValue(key);
},
clearAll() {
this.remove('cx_seat_config');
this.remove('cx_video_config');
this.remove('cx_answer_config');
localStorage.removeItem('cx_device_id');
localStorage.removeItem('cx_run_state');
},
// 密码加密 / 解密(AES/CBC/PKCS7)
encryptPassword(plaintext) {
return CryptoJS.AES.encrypt(plaintext, this._key, {
iv: this._iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}).toString();
},
decryptPassword(ciphertext) {
const bytes = CryptoJS.AES.decrypt(ciphertext, this._key, {
iv: this._iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return bytes.toString(CryptoJS.enc.Utf8);
},
// 设备 ID(首次生成 UUID,持久化到 localStorage)
getDeviceId() {
let id = localStorage.getItem('cx_device_id');
if (!id) {
id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
localStorage.setItem('cx_device_id', id);
}
return id;
},
// 座位预约配置
getSeatConfig() {
return this.get('cx_seat_config') || {};
},
setSeatConfig(config) {
this.set('cx_seat_config', config);
},
// 视频播放配置
getVideoConfig() {
return this.get('cx_video_config') || { playbackRate: 2, autoAdvance: true, autoSkipQuiz: true };
},
setVideoConfig(config) {
this.set('cx_video_config', config);
},
// 答题模块配置
getAnswerConfig() {
return this.get('cx_answer_config') || { mode: 'free', verifyValidUntil: 0, activationCode: '', aiApiKey: '' };
},
setAnswerConfig(config) {
this.set('cx_answer_config', config);
},
// 运行状态(localStorage)
getRunState() {
const raw = localStorage.getItem('cx_run_state');
if (!raw) return null;
try { return JSON.parse(raw); } catch (_) { return null; }
},
setRunState(state) {
localStorage.setItem('cx_run_state', JSON.stringify(state));
},
clearRunState() {
localStorage.removeItem('cx_run_state');
}
};
// ==========================================
// Logger - 日志模块
// ==========================================
const Logger = {
_entries: [],
_onLog: null,
log(message, level = 'info') {
const entry = {
timestamp: new Date().toISOString(),
level,
message
};
this._entries.push(entry);
if (this._entries.length > 50) {
this._entries.shift();
}
if (typeof this._onLog === 'function') {
this._onLog(entry);
}
},
clearLogs() {
this._entries = [];
},
getEntries() {
return this._entries;
}
};
// ==========================================
// UIPanel - UI 面板模块
// ==========================================
const UIPanel = {
_iframe: null,
_doc: null,
_isMinimized: false,
_activeTab: 'video',
_settingsVisible: false,
_running: false,
_startTime: null,
_timerInterval: null,
// 2.1 - init(): 创建 iframe 并注入面板 HTML/CSS
init() {
if (this._iframe) return;
const iframe = document.createElement('iframe');
iframe.id = 'cx-helper-iframe';
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('allowtransparency', 'true');
Object.assign(iframe.style, {
position: 'fixed',
top: '20px',
right: '20px',
left: 'auto',
width: '480px',
height: '520px',
zIndex: '999999',
border: 'none',
borderRadius: '14px',
background: '#fff',
overflow: 'hidden',
boxShadow: '0 20px 60px rgba(0,0,0,0.12), 0 8px 20px rgba(0,0,0,0.06)'
});
document.body.appendChild(iframe);
this._iframe = iframe;
const doc = iframe.contentDocument || iframe.contentWindow.document;
this._doc = doc;
doc.open();
doc.write(this._buildHTML());
doc.close();
// 绑定事件
this._bindEvents();
// 启用标题栏拖拽
this.enableDrag(doc.getElementById('cx-header'));
// 连接 Logger
Logger._onLog = (entry) => this.log(entry.message, entry.level);
// 渲染已有日志
Logger.getEntries().forEach(e => this.log(e.message, e.level));
},
// 2.1 - destroy(): 移除 iframe
destroy() {
if (this._iframe) {
this._iframe.remove();
this._iframe = null;
this._doc = null;
}
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
Logger._onLog = null;
},
// 2.2 - enableDrag(): 绑定标题栏拖拽
enableDrag(handleEl) {
if (!handleEl || !this._iframe) return;
const iframe = this._iframe;
const doc = this._doc;
let isDragging = false;
let startX = 0, startY = 0, startLeft = 0, startTop = 0;
const onMove = (e) => {
if (!isDragging) return;
const dx = e.screenX - startX;
const dy = e.screenY - startY;
const maxLeft = Math.max(0, window.innerWidth - iframe.offsetWidth);
const maxTop = Math.max(0, window.innerHeight - iframe.offsetHeight);
iframe.style.left = Math.min(Math.max(0, startLeft + dx), maxLeft) + 'px';
iframe.style.top = Math.min(Math.max(0, startTop + dy), maxTop) + 'px';
iframe.style.right = 'auto';
};
const stopDrag = () => {
if (!isDragging) return;
isDragging = false;
iframe.style.transition = '';
doc.body.style.userSelect = '';
};
handleEl.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.screenX;
startY = e.screenY;
// Convert right-positioned to left-positioned for dragging
const rect = iframe.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
iframe.style.left = startLeft + 'px';
iframe.style.right = 'auto';
iframe.style.transition = 'none';
doc.body.style.userSelect = 'none';
e.preventDefault();
});
doc.addEventListener('mousemove', onMove);
window.addEventListener('mousemove', onMove);
doc.addEventListener('mouseup', stopDrag);
window.addEventListener('mouseup', stopDrag);
window.addEventListener('blur', stopDrag);
},
// 2.3 - minimize(): 收缩为 50×50 悬浮图标
minimize() {
if (this._isMinimized || !this._iframe) return;
this._isMinimized = true;
const doc = this._doc;
doc.getElementById('cx-panel').style.display = 'none';
doc.getElementById('cx-mini').classList.add('show');
this._iframe.style.width = '50px';
this._iframe.style.height = '50px';
this._iframe.style.borderRadius = '50%';
},
// 2.3 - restore(): 恢复完整面板
restore() {
if (!this._isMinimized || !this._iframe) return;
this._isMinimized = false;
const doc = this._doc;
doc.getElementById('cx-panel').style.display = '';
doc.getElementById('cx-mini').classList.remove('show');
this._iframe.style.width = '480px';
this._iframe.style.height = '520px';
this._iframe.style.borderRadius = '14px';
},
// 2.4 - switchTab(): 切换标签页
switchTab(tab) {
if (!this._doc) return;
this._activeTab = tab;
const doc = this._doc;
// 更新标签按钮样式
doc.querySelectorAll('.cx-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
// 更新内容区
doc.querySelectorAll('.cx-tab-content').forEach(el => {
el.style.display = el.dataset.tab === tab ? 'block' : 'none';
});
},
// 2.5 - showSettings(): 显示设置面板
showSettings() {
if (!this._doc) return;
this._settingsVisible = !this._settingsVisible;
this._doc.getElementById('cx-settings').style.display = this._settingsVisible ? 'block' : 'none';
},
// 2.6 - log(): 在日志区域追加日志
log(message, level = 'info') {
if (!this._doc) return;
const logArea = this._doc.getElementById('cx-log-area');
if (!logArea) return;
const div = this._doc.createElement('div');
div.className = 'cx-log-entry cx-log-' + level;
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false });
const icon = level === 'success' ? '✓' : level === 'error' ? '✗' : '·';
div.textContent = `${icon} ${time} ${message}`;
logArea.appendChild(div);
// 限制最多 50 条
while (logArea.children.length > 50) {
logArea.removeChild(logArea.firstChild);
}
logArea.scrollTop = logArea.scrollHeight;
},
// 2.6 - clearLogs(): 清空日志区域
clearLogs() {
if (!this._doc) return;
const logArea = this._doc.getElementById('cx-log-area');
if (logArea) logArea.innerHTML = '';
},
// 2.6 - setStatus(): 设置状态栏文字
setStatus(text, type = 'info') {
if (!this._doc) return;
const el = this._doc.getElementById('cx-status-text');
if (!el) return;
el.textContent = text;
el.className = 'cx-status-text cx-status-' + type;
},
// 2.6 - setRunning(): 设置运行状态
setRunning(isRunning) {
this._running = isRunning;
if (!this._doc) return;
const indicator = this._doc.getElementById('cx-status-indicator');
const timerEl = this._doc.getElementById('cx-status-timer');
// 先清理旧的定时器,防止内存泄漏
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
if (isRunning) {
this._startTime = Date.now();
if (indicator) indicator.className = 'cx-status-indicator running';
this._timerInterval = setInterval(() => {
if (timerEl && this._startTime) {
const elapsed = Math.floor((Date.now() - this._startTime) / 1000);
const h = String(Math.floor(elapsed / 3600)).padStart(2, '0');
const m = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0');
const s = String(elapsed % 60).padStart(2, '0');
timerEl.textContent = `${h}:${m}:${s}`;
}
}, 1000);
} else {
this._startTime = null;
if (indicator) indicator.className = 'cx-status-indicator';
if (timerEl) timerEl.textContent = '00:00:00';
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
}
},
// 2.6 - setProgress(): 设置进度
setProgress(current, total) {
if (!this._doc) return;
const el = this._doc.getElementById('cx-status-progress');
if (el) el.textContent = total > 0 ? `${current}/${total}` : '';
},
// 2.6 - setCountdown(): 设置倒计时
setCountdown(seconds) {
if (!this._doc) return;
const el = this._doc.getElementById('cx-status-countdown');
if (!el) return;
if (seconds > 0) {
const h = String(Math.floor(seconds / 3600)).padStart(2, '0');
const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
const s = String(seconds % 60).padStart(2, '0');
el.textContent = `倒计时 ${h}:${m}:${s}`;
el.style.display = 'inline';
} else {
el.textContent = '';
el.style.display = 'none';
}
},
// 2.6 - setButtonEnabled(): 设置按钮启用/禁用
setButtonEnabled(buttonId, enabled) {
if (!this._doc) return;
const btn = this._doc.getElementById(buttonId);
if (btn) btn.disabled = !enabled;
},
// 2.6 - setButtonLoading(): 设置按钮加载状态
setButtonLoading(buttonId, loading) {
if (!this._doc) return;
const btn = this._doc.getElementById(buttonId);
if (!btn) return;
if (loading) {
btn._origText = btn._origText || btn.textContent;
btn.textContent = '处理中...';
btn.disabled = true;
} else {
btn.textContent = btn._origText || btn.textContent;
btn.disabled = false;
}
},
// 内部:构建面板 HTML
_buildHTML() {
return `
⚠️ 有BUG及时反馈需提供账号,联系微信:C919irt 📋
免费验证码模式
📱
扫码观看广告获取验证码
验证后免费使用24小时
付费积分模式
当前积分余额:
--
💰 积分购买
联系微信:C919irt 📋
价格表:
• 50积分 = 2元
• 100积分 = 4元
• 150积分 = 6元
• 200积分 = 8元
• 500积分 = 18元
每次答题消耗1积分 | 积分永久有效
AI 答题模式(智谱 GLM-4-Flash)
💡 GLM-4-Flash为免费模型,无需付费
数据管理
清除所有存储数据,包括账号、密码、配置和日志。此操作不可撤销。
`;
},
// 内部:构建 CSS
_buildCSS() {
return `
html, body { margin:0; padding:0; overflow:hidden; height:100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
color: #2c3e50; background: transparent; font-size: 13px; }
* { box-sizing:border-box; }
/* 最小化图标 */
.cx-mini { position:absolute; inset:0; background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff; border-radius:50%;
display:none; align-items:center; justify-content:center; cursor:pointer;
font-size:22px; box-shadow:0 4px 16px rgba(99,102,241,0.45); transition:all .3s; }
.cx-mini:hover { transform:scale(1.1); box-shadow:0 6px 22px rgba(99,102,241,0.55); }
.cx-mini.show { display:flex; }
/* 主面板 */
.cx-panel { width:100%; height:100%; background:#f8fafc; border-radius:16px;
position:relative; overflow:hidden; display:flex; flex-direction:column; max-height:100vh; }
/* 标题栏 */
.cx-header { height:48px; background:linear-gradient(135deg,#6366f1 0%,#8b5cf6 50%,#a78bfa 100%); color:#fff;
display:flex; align-items:center; justify-content:space-between; padding:0 18px;
cursor:move; flex-shrink:0; user-select:none; }
.cx-header-title { font-size:14px; font-weight:700; letter-spacing:0.3px; text-shadow:0 1px 3px rgba(0,0,0,0.12); }
.cx-header-tools { display:flex; gap:12px; }
.cx-tool-btn { cursor:pointer; font-size:15px; opacity:.85; transition:all .2s; padding:4px 6px;
border-radius:6px; }
.cx-tool-btn:hover { opacity:1; transform:scale(1.12); background:rgba(255,255,255,0.18); }
/* 标签栏 */
.cx-tab-bar { display:flex; background:#fff; border-bottom:1px solid #e2e8f0; flex-shrink:0; padding:0 10px; }
.cx-tab-btn { flex:1; text-align:center; padding:10px 0; font-size:12.5px; cursor:pointer;
color:#94a3b8; transition:all .25s; border-bottom:2px solid transparent; font-weight:500;
margin:0 2px; border-radius:6px 6px 0 0; }
.cx-tab-btn:hover { color:#6366f1; background:rgba(99,102,241,0.04); }
.cx-tab-btn.active { color:#6366f1; border-bottom-color:#6366f1; font-weight:700; background:#fff; }
/* 反馈提示条 */
.cx-feedback-tip { background:#fffbeb; border-bottom:1px solid #fde68a; padding:6px 14px;
font-size:11px; color:#92400e; line-height:1.5; flex-shrink:0; }
.cx-feedback-wechat { display:inline-block; background:#fff; padding:1px 8px; border-radius:4px;
border:1px solid #e2e8f0; cursor:pointer; transition:all .2s; font-weight:600; font-size:11px; margin-left:2px; }
.cx-feedback-wechat:hover { border-color:#6366f1; color:#6366f1; background:#eef2ff; }
/* 内容区 — 关键:允许滚动 */
.cx-body { flex:1 1 0; overflow-y:auto; overflow-x:hidden; padding:14px; min-height:0; background:#f8fafc; }
.cx-body::-webkit-scrollbar { width:5px; }
.cx-body::-webkit-scrollbar-track { background:transparent; }
.cx-body::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:10px; }
.cx-body::-webkit-scrollbar-thumb:hover { background:#94a3b8; }
/* 设置面板 */
.cx-settings { display:none; position:absolute; top:48px; left:0; width:100%;
height:calc(100% - 48px); background:#f8fafc; z-index:99; overflow-y:auto;
box-sizing:border-box; }
.cx-settings::-webkit-scrollbar { width:5px; }
.cx-settings::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:3px; }
.cx-settings-header { display:flex; justify-content:space-between; align-items:center;
padding:14px 18px; background:#fff; border-bottom:1px solid #e2e8f0; font-weight:700; font-size:14px; color:#1e293b; }
.cx-settings-close { cursor:pointer; font-size:16px; color:#94a3b8; transition:all .2s; width:28px; height:28px;
display:flex; align-items:center; justify-content:center; border-radius:8px; }
.cx-settings-close:hover { color:#ef4444; background:#fef2f2; }
.cx-settings-body { padding:14px; }
.cx-settings-section { background:#fff; border-radius:12px; padding:16px; margin-bottom:12px;
box-shadow:0 1px 3px rgba(0,0,0,0.04); border:1px solid #e2e8f0; transition:box-shadow .2s; }
.cx-settings-section:hover { box-shadow:0 2px 8px rgba(0,0,0,0.06); }
.cx-settings-title { font-weight:700; font-size:13px; margin-bottom:10px; color:#1e293b; }
.cx-settings-danger-zone { border:1px solid #fecaca; background:#fef2f2; }
.cx-form-item { margin-bottom:10px; }
.cx-form-item label { font-size:13px; color:#64748b; }
.cx-form-item select { padding:7px 12px; border:1.5px solid #e2e8f0; border-radius:8px;
font-size:13px; background:#fff; margin-left:8px; outline:none; transition:border-color .2s; }
.cx-form-item select:focus { border-color:#6366f1; }
.cx-checkbox-label { display:flex; align-items:center; gap:8px; cursor:pointer; padding:6px 8px;
border-radius:8px; transition:background .2s; }
.cx-checkbox-label:hover { background:#eef2ff; }
.cx-checkbox-label input { margin:0; width:16px; height:16px; cursor:pointer; accent-color:#6366f1; }
.cx-radio-group { display:flex; flex-direction:column; gap:4px; }
.cx-radio-option { display:flex; align-items:center; gap:8px; padding:8px 10px; border-radius:8px;
cursor:pointer; transition:background .2s; font-size:13px; }
.cx-radio-option:hover { background:#eef2ff; }
.cx-radio-option input { margin:0; cursor:pointer; accent-color:#6366f1; }
.cx-settings-footer { display:flex; gap:10px; justify-content:center; margin-top:14px; }
/* 按钮 */
.cx-btn { padding:9px 20px; border:none; border-radius:10px; font-size:12.5px; font-weight:600;
cursor:pointer; transition:all .2s; letter-spacing:0.2px; }
.cx-btn:hover { transform:translateY(-1px); box-shadow:0 4px 12px rgba(0,0,0,0.08); }
.cx-btn:active { transform:translateY(0); }
.cx-btn-primary { background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff; }
.cx-btn-primary:hover { box-shadow:0 4px 16px rgba(99,102,241,0.35); }
.cx-btn-secondary { background:#f1f5f9; color:#64748b; }
.cx-btn-secondary:hover { background:#e2e8f0; box-shadow:0 2px 8px rgba(0,0,0,0.04); }
.cx-btn-success { background:linear-gradient(135deg,#10b981,#059669); color:#fff; }
.cx-btn-success:hover { box-shadow:0 4px 16px rgba(16,185,129,0.35); }
.cx-btn-warning { background:linear-gradient(135deg,#f59e0b,#d97706); color:#fff; }
.cx-btn-warning:hover { box-shadow:0 4px 16px rgba(245,158,11,0.35); }
.cx-btn-danger { background:linear-gradient(135deg,#ef4444,#dc2626); color:#fff; width:100%; }
.cx-btn-danger:hover { box-shadow:0 4px 16px rgba(239,68,68,0.35); }
.cx-btn-info { background:#f1f5f9; color:#64748b; }
.cx-btn-info:hover { background:#e2e8f0; }
.cx-btn:disabled { opacity:.45; cursor:not-allowed; transform:none; box-shadow:none; }
/* 日志区域 */
.cx-log-wrap { flex-shrink:0; position:relative; border-top:1px solid #e2e8f0; background:#f8fafc; }
.cx-log-area { height:100px; overflow-y:auto; padding:8px 14px; box-sizing:border-box;
font-size:11px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;
mask-image:linear-gradient(to bottom, transparent 0%, black 20%, black 100%);
-webkit-mask-image:linear-gradient(to bottom, transparent 0%, black 20%, black 100%); }
.cx-log-area::-webkit-scrollbar { width:3px; }
.cx-log-area::-webkit-scrollbar-track { background:transparent; }
.cx-log-area::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:3px; }
.cx-log-entry { padding:3px 8px; margin:2px 0; line-height:1.5; border-radius:6px; font-size:11px;
animation:cx-log-in .3s ease; }
@keyframes cx-log-in { from{opacity:0;transform:translateY(4px);} to{opacity:1;transform:translateY(0);} }
.cx-log-info { color:#64748b; background:#f1f5f9; }
.cx-log-success { color:#059669; background:#ecfdf5; }
.cx-log-error { color:#dc2626; background:#fef2f2; }
/* 状态栏 */
.cx-status-bar { height:32px; background:#fff; border-top:1px solid #e2e8f0;
display:flex; align-items:center; justify-content:space-between; padding:0 14px;
font-size:11px; color:#94a3b8; flex-shrink:0; }
.cx-status-left { display:flex; align-items:center; gap:8px; }
.cx-status-right { display:flex; align-items:center; }
.cx-status-indicator { width:7px; height:7px; border-radius:50%; background:#cbd5e1; display:inline-block; }
.cx-status-indicator.running { background:#10b981; animation:cx-pulse 1.5s infinite; }
@keyframes cx-pulse { 0%,100%{opacity:1;} 50%{opacity:.4;} }
.cx-status-text { color:#64748b; }
.cx-status-text.cx-status-info { color:#64748b; }
.cx-status-text.cx-status-success { color:#10b981; }
.cx-status-text.cx-status-error { color:#ef4444; }
.cx-status-progress { color:#6366f1; font-weight:700; }
.cx-status-countdown { color:#f59e0b; font-weight:700; }
.cx-status-timer { font-family:'JetBrains Mono',Consolas,"Courier New",monospace; color:#94a3b8; font-size:11px; }
/* 表单通用 */
.cx-form-row { display:flex; flex-direction:column; gap:4px; }
.cx-form-row-inline { flex-direction:row; gap:10px; }
.cx-form-col { flex:1; display:flex; flex-direction:column; gap:4px; }
.cx-form-label { font-size:12px; color:#64748b; font-weight:600; }
.cx-required { color:#ef4444; margin-left:2px; }
.cx-input { padding:9px 12px; border:1.5px solid #e2e8f0; border-radius:10px; font-size:13px;
outline:none; transition:all .2s; box-sizing:border-box; width:100%; background:#fff; }
.cx-input:focus { border-color:#6366f1; box-shadow:0 0 0 3px rgba(99,102,241,0.1); }
.cx-input.cx-input-error { border-color:#ef4444; box-shadow:0 0 0 3px rgba(239,68,68,0.1); }
.cx-form-error { font-size:11px; color:#ef4444; min-height:14px; }
/* 座位预约 */
.cx-seat-form { display:flex; flex-direction:column; gap:10px; }
.cx-seat-session-hint { display:flex; align-items:center; gap:8px; padding:10px 14px;
background:linear-gradient(135deg,#eef2ff,#e0e7ff); border-radius:10px; font-size:12px;
color:#6366f1; border:1px solid #c7d2fe; }
.cx-hint-icon { font-size:16px; }
.cx-seat-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:6px; }
.cx-seat-actions .cx-btn { font-size:12px; padding:7px 14px; }
.cx-seat-msg { font-size:12px; padding:6px 0; min-height:18px; }
.cx-seat-msg.cx-msg-success { color:#10b981; }
.cx-seat-msg.cx-msg-error { color:#ef4444; }
.cx-seat-msg.cx-msg-info { color:#94a3b8; }
/* 房间/座位列表 */
.cx-room-list, .cx-seat-list { margin-top:8px; }
.cx-room-list-title, .cx-seat-list-title { font-size:12px; font-weight:700; color:#1e293b; margin-bottom:6px; }
.cx-room-item { padding:10px 12px; background:#fff; border-radius:10px; margin-bottom:4px;
cursor:pointer; font-size:12px; transition:all .2s; display:flex; justify-content:space-between;
border:1px solid #e2e8f0; }
.cx-room-item:hover { background:#eef2ff; border-color:#c7d2fe; transform:translateX(2px); }
.cx-seat-grid { display:flex; flex-wrap:wrap; gap:6px; }
.cx-seat-item { width:48px; height:36px; display:flex; align-items:center; justify-content:center;
border-radius:8px; font-size:11px; cursor:pointer; transition:all .2s; border:1.5px solid #e2e8f0;
font-weight:600; }
.cx-seat-item.available { background:#ecfdf5; color:#10b981; border-color:#a7f3d0; }
.cx-seat-item.available:hover { background:#10b981; color:#fff; transform:scale(1.05); }
.cx-seat-item.occupied { background:#fef2f2; color:#ef4444; border-color:#fecaca; cursor:not-allowed; }
.cx-seat-item.reserved { background:#fffbeb; color:#f59e0b; border-color:#fde68a; cursor:not-allowed; }
/* 加载动画 */
.cx-loading::after { content:''; display:inline-block; width:12px; height:12px;
border:2px solid rgba(255,255,255,0.4); border-top-color:#fff; border-radius:50%;
animation:cx-spin .6s linear infinite; margin-left:6px; vertical-align:middle; }
@keyframes cx-spin { to{transform:rotate(360deg);} }
/* 视频面板 */
.cx-video-panel { display:flex; flex-direction:column; gap:12px; }
.cx-video-status-card { background:#fff; border-radius:14px; padding:16px;
box-shadow:0 1px 3px rgba(0,0,0,0.04); border:1px solid #e2e8f0; transition:box-shadow .2s; }
.cx-video-status-card:hover { box-shadow:0 2px 8px rgba(0,0,0,0.06); }
.cx-video-info-row { display:flex; align-items:center; margin-bottom:8px; font-size:13px; }
.cx-video-label { color:#94a3b8; min-width:80px; flex-shrink:0; font-weight:500; }
.cx-video-value { color:#1e293b; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cx-video-progress-wrap { margin-top:6px; }
.cx-video-progress-track { height:6px; background:#e2e8f0; border-radius:3px; overflow:hidden; }
.cx-video-progress-bar { height:100%; background:linear-gradient(90deg,#6366f1,#10b981);
border-radius:3px; transition:width .5s ease; min-width:0; }
.cx-video-actions { display:flex; gap:10px; }
.cx-video-actions .cx-btn { flex:1; padding:11px 0; font-size:13px; }
/* 答题模块 */
.cx-answer-panel { display:flex; flex-direction:column; gap:10px; }
.cx-answer-mode-bar { display:flex; gap:4px; background:#fff; border-radius:12px; padding:4px;
border:1px solid #e2e8f0; }
.cx-answer-mode-option { flex:1; display:flex; align-items:center; justify-content:center; gap:4px;
padding:8px 4px; border-radius:10px; cursor:pointer; font-size:12px; font-weight:600;
transition:all .25s; text-align:center; color:#94a3b8; }
.cx-answer-mode-option:hover { background:#eef2ff; color:#6366f1; }
.cx-answer-mode-option input { display:none; }
.cx-answer-mode-option:has(input:checked) { background:linear-gradient(135deg,#6366f1,#8b5cf6); color:#fff;
box-shadow:0 2px 8px rgba(99,102,241,0.3); }
.cx-answer-area { }
.cx-answer-card { background:#fff; border-radius:14px; padding:16px;
box-shadow:0 1px 3px rgba(0,0,0,0.04); border:1px solid #e2e8f0; transition:box-shadow .2s; }
.cx-answer-card:hover { box-shadow:0 2px 8px rgba(0,0,0,0.06); }
.cx-answer-card-title { font-weight:700; font-size:13px; color:#1e293b; margin-bottom:12px; }
.cx-free-qr-placeholder { display:flex; flex-direction:column; align-items:center; gap:8px;
padding:20px; background:#f8fafc; border-radius:12px; border:1.5px dashed #c7d2fe; }
.cx-free-qr-icon { font-size:36px; }
.cx-free-qr-text { font-size:12px; color:#94a3b8; }
.cx-input-group { display:flex; gap:8px; align-items:center; }
.cx-btn-sm { padding:8px 16px; font-size:12px; }
.cx-btn-xs { padding:5px 10px; font-size:11px; border-radius:6px; }
.cx-answer-status { font-size:12px; margin-top:10px; min-height:16px; }
.cx-paid-credits-row { display:flex; align-items:center; gap:10px; padding:12px 14px;
background:#f8fafc; border-radius:10px; border:1px solid #e2e8f0; }
.cx-paid-credits-label { font-size:13px; color:#64748b; }
.cx-paid-credits-value { font-size:20px; font-weight:800; color:#6366f1; }
.cx-answer-actions { display:flex; gap:8px; margin-top:4px; }
.cx-answer-actions .cx-btn { flex:1; }
/* 购买信息 */
.cx-buy-info { background:#fffbeb; padding:12px; border-radius:10px; border:1px solid #fde68a;
margin-top:12px; font-size:12px; line-height:1.6; }
.cx-buy-info-title { font-weight:700; color:#d97706; margin-bottom:8px; font-size:13px; }
.cx-buy-info-wechat { margin-bottom:4px; color:#5a6577; }
.cx-wechat-copy { display:inline-flex; align-items:center; gap:4px; background:#fff; padding:3px 10px;
border-radius:6px; border:1px solid #dce1e8; cursor:pointer; transition:all .2s; font-weight:600;
font-size:12px; }
.cx-wechat-copy:hover { border-color:#667eea; background:#f0f2ff; color:#667eea; }
.cx-price-table { margin-top:6px; font-size:11px; color:#6b7280; line-height:1.8; }
/* AI 答题提示 */
.cx-ai-hint { font-size:12px; color:#64748b; margin-bottom:12px; line-height:1.6; }
.cx-ai-link { color:#6366f1; text-decoration:none; font-weight:500; }
.cx-ai-link:hover { text-decoration:underline; }
.cx-ai-free-tip { margin-top:8px; font-size:11px; color:#94a3b8; }
/* 视频内联设置卡片 */
.cx-video-settings-card { background:#fff; border-radius:14px; padding:14px 16px; margin-top:12px;
box-shadow:0 1px 3px rgba(0,0,0,0.04); border:1px solid #e2e8f0; transition:box-shadow .2s; }
.cx-video-settings-card:hover { box-shadow:0 2px 8px rgba(0,0,0,0.06); }
.cx-card-title { font-weight:700; font-size:13px; color:#1e293b; margin-bottom:10px; }
.cx-inline-setting { display:flex; align-items:center; padding:6px 0; }
.cx-setting-label { font-size:13px; color:#64748b; margin-right:10px; font-weight:500; }
.cx-select-inline { padding:6px 10px; border:1.5px solid #e2e8f0; border-radius:10px;
font-size:13px; background:#fff; outline:none; transition:border-color .2s; cursor:pointer; }
.cx-select-inline:focus { border-color:#6366f1; }
.cx-toggle-label { display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px;
color:#64748b; padding:2px 0; }
.cx-toggle-label input { margin:0; width:16px; height:16px; cursor:pointer; accent-color:#6366f1; }
/* 底部留白防止滚动不到底 */
.cx-tab-content { padding-bottom:8px; }
`;
},
// 内部:绑定事件
_bindEvents() {
const doc = this._doc;
// 最小化按钮
doc.getElementById('cx-btn-minimize').addEventListener('click', () => this.minimize());
// 最小化图标点击恢复
doc.getElementById('cx-mini').addEventListener('click', () => this.restore());
// 反馈微信号复制
doc.getElementById('cx-feedback-wechat')?.addEventListener('click', () => {
const el = doc.getElementById('cx-feedback-wechat');
const text = 'C919irt';
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
el.textContent = '已复制 ✅';
setTimeout(() => { el.textContent = 'C919irt 📋'; }, 1500);
}).catch(() => {});
}
});
// 设置按钮
doc.getElementById('cx-btn-settings').addEventListener('click', () => this.showSettings());
// 设置面板关闭
doc.getElementById('cx-settings-close').addEventListener('click', () => {
this._settingsVisible = false;
doc.getElementById('cx-settings').style.display = 'none';
});
// 设置面板保存
doc.getElementById('cx-settings-save').addEventListener('click', () => {
// 保存视频配置
const playbackRate = parseFloat(doc.getElementById('cx-cfg-playback-rate').value) || 2;
const autoAdvance = doc.getElementById('cx-cfg-auto-advance').checked;
const autoSkipQuiz = doc.getElementById('cx-cfg-auto-skip-quiz').checked;
ConfigStore.setVideoConfig({ playbackRate, autoAdvance, autoSkipQuiz });
// 立即更新当前视频速度
if (typeof VideoModule !== 'undefined' && VideoModule.isPlaying()) {
VideoModule.setPlaybackRate(playbackRate);
}
// 同步到内联设置控件
const rateInline = doc.getElementById('cx-video-rate-inline');
if (rateInline) rateInline.value = String(playbackRate);
const advInline = doc.getElementById('cx-video-advance-inline');
if (advInline) advInline.checked = autoAdvance;
const skipInline = doc.getElementById('cx-video-skip-quiz-inline');
if (skipInline) skipInline.checked = autoSkipQuiz;
// 保存答题模式配置
const answerMode = doc.querySelector('input[name="cx-answer-mode"]:checked')?.value || 'free';
const answerCfg = ConfigStore.getAnswerConfig();
answerCfg.mode = answerMode;
ConfigStore.setAnswerConfig(answerCfg);
Logger.log('设置已保存', 'success');
this._settingsVisible = false;
doc.getElementById('cx-settings').style.display = 'none';
});
// 设置面板取消
doc.getElementById('cx-settings-cancel').addEventListener('click', () => {
this._settingsVisible = false;
doc.getElementById('cx-settings').style.display = 'none';
});
// 标签页切换
doc.querySelectorAll('.cx-tab-btn').forEach(btn => {
btn.addEventListener('click', () => this.switchTab(btn.dataset.tab));
});
// 播放倍速即时更新
doc.getElementById('cx-cfg-playback-rate').addEventListener('change', (e) => {
const rate = parseFloat(e.target.value) || 2;
if (typeof VideoModule !== 'undefined' && VideoModule.isPlaying()) {
VideoModule.setPlaybackRate(rate);
}
});
// 清除数据按钮
doc.getElementById('cx-btn-clear-data')?.addEventListener('click', () => {
if (confirm('确定要清除所有数据吗?此操作不可撤销,将删除所有账号、密码、配置和日志。')) {
ConfigStore.clearAll();
Logger.clearLogs();
this.clearLogs();
this.setRunning(false);
this.setStatus('数据已清除', 'info');
Logger.log('所有数据已清除', 'success');
}
});
// 视频内联设置 — 倍速
doc.getElementById('cx-video-rate-inline')?.addEventListener('change', (e) => {
const rate = parseFloat(e.target.value) || 2;
const cfg = ConfigStore.getVideoConfig();
cfg.playbackRate = rate;
ConfigStore.setVideoConfig(cfg);
// 同步设置面板
const settingsRate = doc.getElementById('cx-cfg-playback-rate');
if (settingsRate) settingsRate.value = String(rate);
if (typeof VideoModule !== 'undefined' && VideoModule.isPlaying()) {
VideoModule.setPlaybackRate(rate);
}
Logger.log(`播放倍速已设为 ${rate}x`, 'info');
});
// 视频内联设置 — 自动推进
doc.getElementById('cx-video-advance-inline')?.addEventListener('change', (e) => {
const cfg = ConfigStore.getVideoConfig();
cfg.autoAdvance = e.target.checked;
ConfigStore.setVideoConfig(cfg);
const settingsCb = doc.getElementById('cx-cfg-auto-advance');
if (settingsCb) settingsCb.checked = e.target.checked;
Logger.log(`自动推进章节: ${e.target.checked ? '开启' : '关闭'}`, 'info');
});
// 视频内联设置 — 跳过测验
doc.getElementById('cx-video-skip-quiz-inline')?.addEventListener('change', (e) => {
const cfg = ConfigStore.getVideoConfig();
cfg.autoSkipQuiz = e.target.checked;
ConfigStore.setVideoConfig(cfg);
const settingsCb = doc.getElementById('cx-cfg-auto-skip-quiz');
if (settingsCb) settingsCb.checked = e.target.checked;
Logger.log(`自动跳过测验: ${e.target.checked ? '开启' : '关闭'}`, 'info');
});
// 二维码图片加载失败时显示文字占位
const qrImg = doc.getElementById('cx-free-qr-img');
const qrFallback = doc.getElementById('cx-free-qr-fallback');
if (qrImg) {
qrImg.onload = () => { qrImg.style.display = 'block'; if (qrFallback) qrFallback.style.display = 'none'; };
qrImg.onerror = () => { qrImg.style.display = 'none'; if (qrFallback) qrFallback.style.display = 'block'; };
}
// 微信号复制
const wechatCopy = doc.getElementById('cx-wechat-copy');
if (wechatCopy) {
wechatCopy.addEventListener('click', () => {
const text = 'C919irt';
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
wechatCopy.textContent = '已复制 ✅';
setTimeout(() => { wechatCopy.textContent = 'C919irt 📋'; }, 1500);
}).catch(() => {
wechatCopy.textContent = 'C919irt 📋';
});
}
});
}
}
};
// LoginService 已移除 — 座位预约直接使用浏览器已登录的会话 Cookie
// ==========================================
// SeatModule - 座位预约模块
// ==========================================
const SeatModule = {
_scheduleTimer: null,
_countdownInterval: null,
_scheduledTime: null,
_retryCount: 0,
// 4.2 - 加载配置到表单
loadConfig() {
const doc = UIPanel._doc;
if (!doc) return;
const cfg = ConfigStore.getSeatConfig();
if (cfg.roomId) doc.getElementById('cx-seat-roomid').value = cfg.roomId;
if (cfg.seatId) doc.getElementById('cx-seat-seatid').value = cfg.seatId;
if (cfg.date) doc.getElementById('cx-seat-date').value = cfg.date;
if (cfg.startTime) doc.getElementById('cx-seat-start').value = cfg.startTime;
if (cfg.endTime) doc.getElementById('cx-seat-end').value = cfg.endTime;
if (cfg.scheduledTime) doc.getElementById('cx-seat-schedule').value = cfg.scheduledTime;
},
// 4.2 - 保存配置
saveConfig() {
const doc = UIPanel._doc;
if (!doc) return false;
const roomId = doc.getElementById('cx-seat-roomid').value.trim();
const seatId = doc.getElementById('cx-seat-seatid').value.trim();
const date = doc.getElementById('cx-seat-date').value;
const startTime = doc.getElementById('cx-seat-start').value;
const endTime = doc.getElementById('cx-seat-end').value;
const scheduledTime = doc.getElementById('cx-seat-schedule').value;
const config = { roomId, seatId, date, startTime, endTime, scheduledTime };
ConfigStore.setSeatConfig(config);
Logger.log('座位预约配置已保存', 'success');
this._showMsg('配置已保存', 'success');
return true;
},
// 4.2 - 验证配置
validateConfig() {
const doc = UIPanel._doc;
if (!doc) return { valid: false, errors: ['面板未初始化'] };
const errors = [];
const fields = [
{ id: 'cx-seat-roomid', errId: 'cx-seat-roomid-err', label: '房间ID' },
{ id: 'cx-seat-seatid', errId: 'cx-seat-seatid-err', label: '座位ID' }
];
let valid = true;
fields.forEach(f => {
const input = doc.getElementById(f.id);
const errEl = doc.getElementById(f.errId);
if (!input.value.trim()) {
valid = false;
errors.push(`${f.label}不能为空`);
if (errEl) errEl.textContent = `请填写${f.label}`;
input.classList.add('cx-input-error');
} else {
if (errEl) errEl.textContent = '';
input.classList.remove('cx-input-error');
}
});
return { valid, errors };
},
// 4.3 - 获取 enc_token(使用浏览器已登录的会话)
getEncToken() {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://office.chaoxing.com/data/apps/seat/reserve',
onload: (resp) => {
try {
const match = resp.responseText.match(/enc\s*[=:]\s*["']([^"']+)["']/);
if (match && match[1]) {
resolve(match[1]);
} else {
try {
const data = JSON.parse(resp.responseText);
resolve(data.enc || data.data?.enc || '');
} catch (_) {
resolve('');
}
}
} catch (e) {
resolve('');
}
},
onerror: () => resolve('')
});
});
},
// 4.3 - 立即预约(使用浏览器会话,无需登录)
async reserve() {
const validation = this.validateConfig();
if (!validation.valid) {
this._showMsg('请填写所有必填字段', 'error');
return { success: false, message: validation.errors.join(', ') };
}
const doc = UIPanel._doc;
const reserveBtn = doc?.getElementById('cx-btn-reserve');
if (reserveBtn) {
reserveBtn.disabled = true;
reserveBtn._origText = reserveBtn.textContent;
reserveBtn.textContent = '预约中...';
reserveBtn.classList.add('cx-loading');
}
try {
// Step 1: 获取 enc_token
this._showMsg('正在获取令牌...', 'info');
const enc = await this.getEncToken();
// Step 2: 提交预约
this._showMsg('正在提交预约...', 'info');
const roomId = doc.getElementById('cx-seat-roomid').value.trim();
const seatId = doc.getElementById('cx-seat-seatid').value.trim();
const date = doc.getElementById('cx-seat-date').value;
const startTime = doc.getElementById('cx-seat-start').value;
const endTime = doc.getElementById('cx-seat-end').value;
const result = await this._submitReserve(roomId, seatId, date, startTime, endTime, enc);
return result;
} catch (e) {
const msg = `预约异常: ${e.message || e}`;
this._showMsg(msg, 'error');
Logger.log(msg, 'error');
return { success: false, message: msg };
} finally {
if (reserveBtn) {
reserveBtn.disabled = false;
reserveBtn.textContent = reserveBtn._origText || '🎯 立即预约';
reserveBtn.classList.remove('cx-loading');
}
}
},
// 内部:提交预约请求(使用浏览器会话)
_submitReserve(roomId, seatId, date, startTime, endTime, enc) {
return new Promise((resolve) => {
const params = [
`roomid=${encodeURIComponent(roomId)}`,
`seatid=${encodeURIComponent(seatId)}`,
`date=${encodeURIComponent(date)}`,
`startTime=${encodeURIComponent(startTime)}`,
`endTime=${encodeURIComponent(endTime)}`,
`enc=${encodeURIComponent(enc)}`
].join('&');
GM_xmlhttpRequest({
method: 'POST',
url: 'https://office.chaoxing.com/data/apps/seat/submit',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
data: params,
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText);
if (data.success) {
const msg = `预约成功!座位 ${seatId},时间段 ${startTime}-${endTime}`;
this._showMsg(msg, 'success');
Logger.log(msg, 'success');
resolve({ success: true, message: msg });
} else {
const msg = data.msg || '预约失败';
this._showMsg(`预约失败: ${msg}`, 'error');
Logger.log(`预约失败: ${msg}`, 'error');
resolve({ success: false, message: msg });
}
} catch (e) {
this._showMsg('预约响应解析失败', 'error');
Logger.log('预约响应解析失败', 'error');
resolve({ success: false, message: '响应解析失败' });
}
},
onerror: () => {
this._showMsg('预约请求失败', 'error');
Logger.log('预约请求失败', 'error');
resolve({ success: false, message: '网络请求失败' });
}
});
});
},
// 4.4 - 定时预约
scheduleReserve(triggerTime) {
if (this._scheduleTimer) {
this._showMsg('已有定时任务运行中', 'error');
return;
}
const now = new Date();
const target = new Date(now);
const parts = triggerTime.split(':');
target.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2] || 0, 10), 0);
// 如果目标时间已过,设为明天
if (target <= now) {
target.setDate(target.getDate() + 1);
}
this._scheduledTime = target;
this._retryCount = 0;
const delay = target.getTime() - Date.now();
Logger.log(`定时预约已设置,将在 ${target.toLocaleTimeString('zh-CN')} 触发`, 'info');
this._showMsg(`定时预约已设置,等待触发...`, 'info');
const doc = UIPanel._doc;
if (doc) {
doc.getElementById('cx-btn-cancel-schedule').style.display = '';
doc.getElementById('cx-btn-schedule').disabled = true;
}
// 倒计时显示
this._countdownInterval = setInterval(() => {
const remaining = Math.max(0, Math.floor((this._scheduledTime.getTime() - Date.now()) / 1000));
UIPanel.setCountdown(remaining);
if (remaining <= 0) {
clearInterval(this._countdownInterval);
this._countdownInterval = null;
}
}, 1000);
// 定时触发
this._scheduleTimer = setTimeout(() => this._executeScheduledReserve(), delay);
},
// 内部:执行定时预约(含重试)
async _executeScheduledReserve() {
this._scheduleTimer = null;
Logger.log('定时预约触发,开始执行...', 'info');
const result = await this.reserve();
if (!result.success && this._retryCount < 3) {
this._retryCount++;
Logger.log(`预约失败,${5}秒后第${this._retryCount}次重试...`, 'error');
this._showMsg(`预约失败,5秒后重试 (${this._retryCount}/3)`, 'error');
this._scheduleTimer = setTimeout(() => this._executeScheduledReserve(), 5000);
} else {
// 完成(成功或重试耗尽)
this._cleanupSchedule();
if (!result.success) {
Logger.log('定时预约失败,已达最大重试次数', 'error');
this._showMsg('定时预约失败,已达最大重试次数', 'error');
}
}
},
// 4.4 - 取消定时
cancelSchedule() {
if (this._scheduleTimer) {
clearTimeout(this._scheduleTimer);
}
this._cleanupSchedule();
Logger.log('定时预约已取消', 'info');
this._showMsg('定时预约已取消', 'info');
},
// 内部:清理定时任务状态
_cleanupSchedule() {
if (this._scheduleTimer) {
clearTimeout(this._scheduleTimer);
this._scheduleTimer = null;
}
if (this._countdownInterval) {
clearInterval(this._countdownInterval);
this._countdownInterval = null;
}
this._scheduledTime = null;
this._retryCount = 0;
UIPanel.setCountdown(0);
const doc = UIPanel._doc;
if (doc) {
doc.getElementById('cx-btn-cancel-schedule').style.display = 'none';
doc.getElementById('cx-btn-schedule').disabled = false;
}
},
// 4.4 - 获取倒计时
getCountdown() {
if (!this._scheduledTime) return 0;
return Math.max(0, Math.floor((this._scheduledTime.getTime() - Date.now()) / 1000));
},
// 4.5 - 查询房间列表
queryRooms() {
return new Promise((resolve) => {
this._showMsg('正在查询房间...', 'info');
GM_xmlhttpRequest({
method: 'GET',
url: 'https://office.chaoxing.com/data/apps/seat/room/list',
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText);
if (data.success && data.data) {
const rooms = data.data;
this._renderRoomList(rooms);
Logger.log(`查询到 ${rooms.length} 个房间`, 'success');
this._showMsg(`查询到 ${rooms.length} 个房间`, 'success');
resolve(rooms);
} else {
this._showMsg('查询失败,请稍后重试', 'error');
Logger.log('房间查询失败', 'error');
resolve([]);
}
} catch (e) {
this._showMsg('查询失败,请稍后重试', 'error');
Logger.log('房间查询响应解析失败', 'error');
resolve([]);
}
},
onerror: () => {
this._showMsg('查询失败,请稍后重试', 'error');
Logger.log('房间查询请求失败', 'error');
resolve([]);
}
});
});
},
// 4.5 - 查询座位列表
querySeats(roomId) {
return new Promise((resolve) => {
this._showMsg('正在加载座位...', 'info');
GM_xmlhttpRequest({
method: 'GET',
url: `https://office.chaoxing.com/data/apps/seat/room/seats?roomId=${encodeURIComponent(roomId)}`,
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText);
if (data.success && data.data) {
const seats = data.data;
this._renderSeatList(seats, roomId);
Logger.log(`房间 ${roomId} 有 ${seats.length} 个座位`, 'success');
resolve(seats);
} else {
this._showMsg('查询失败,请稍后重试', 'error');
Logger.log('座位查询失败', 'error');
resolve([]);
}
} catch (e) {
this._showMsg('查询失败,请稍后重试', 'error');
Logger.log('座位查询响应解析失败', 'error');
resolve([]);
}
},
onerror: () => {
this._showMsg('查询失败,请稍后重试', 'error');
Logger.log('座位查询请求失败', 'error');
resolve([]);
}
});
});
},
// 内部:渲染房间列表
_renderRoomList(rooms) {
const doc = UIPanel._doc;
if (!doc) return;
const container = doc.getElementById('cx-room-list');
container.style.display = 'block';
container.innerHTML = '📋 房间列表
';
if (rooms.length === 0) {
container.innerHTML += '暂无可用房间
';
return;
}
rooms.forEach(room => {
const div = doc.createElement('div');
div.className = 'cx-room-item';
div.innerHTML = `${room.name || room.id}可用: ${room.available ?? '?'}/${room.capacity ?? '?'}`;
div.addEventListener('click', () => {
doc.getElementById('cx-seat-roomid').value = room.id;
this.querySeats(room.id);
});
container.appendChild(div);
});
},
// 内部:渲染座位列表
_renderSeatList(seats, roomId) {
const doc = UIPanel._doc;
if (!doc) return;
const container = doc.getElementById('cx-seat-list');
container.style.display = 'block';
container.innerHTML = '💺 座位列表
';
const grid = doc.getElementById('cx-seat-grid');
seats.forEach(seat => {
const div = doc.createElement('div');
const statusClass = seat.status || 'available';
div.className = `cx-seat-item ${statusClass}`;
div.textContent = seat.name || seat.id;
div.title = `座位 ${seat.name || seat.id} - ${statusClass === 'available' ? '可用' : statusClass === 'occupied' ? '已占用' : '已预约'}`;
if (statusClass === 'available') {
div.addEventListener('click', () => {
doc.getElementById('cx-seat-roomid').value = roomId;
doc.getElementById('cx-seat-seatid').value = seat.id;
this._showMsg(`已选择座位 ${seat.name || seat.id}`, 'success');
Logger.log(`已选择房间 ${roomId} 座位 ${seat.id}`, 'info');
});
}
grid.appendChild(div);
});
},
// 内部:显示消息
_showMsg(text, type = 'info') {
const doc = UIPanel._doc;
if (!doc) return;
const el = doc.getElementById('cx-seat-msg');
if (!el) return;
el.textContent = text;
el.className = `cx-seat-msg cx-msg-${type}`;
},
// 绑定座位模块事件
bindEvents() {
const doc = UIPanel._doc;
if (!doc) return;
// 保存配置
doc.getElementById('cx-btn-save-config').addEventListener('click', () => this.saveConfig());
// 立即预约
doc.getElementById('cx-btn-reserve').addEventListener('click', () => this.reserve());
// 定时预约
doc.getElementById('cx-btn-schedule').addEventListener('click', () => {
const scheduleTime = doc.getElementById('cx-seat-schedule').value;
if (!scheduleTime) {
this._showMsg('请先设置定时触发时间', 'error');
return;
}
this.scheduleReserve(scheduleTime);
});
// 取消定时
doc.getElementById('cx-btn-cancel-schedule').addEventListener('click', () => this.cancelSchedule());
// 查询房间
doc.getElementById('cx-btn-query-rooms').addEventListener('click', () => this.queryRooms());
}
};
// ==========================================
// VideoModule - 视频自动播放模块
// ==========================================
const VideoModule = {
_playing: false,
_currentChapter: '',
_progress: 0,
_videoEl: null,
_antiPauseHandlers: [],
_pauseCheckInterval: null,
_quizCheckInterval: null,
_progressInterval: null,
_loadRetryCount: 0,
_maxLoadRetries: 3,
_userPaused: false,
_lastPauseTime: 0,
// 6.1 - start(): 开始自动播放
start() {
if (this._playing) return;
this._playing = true;
const cfg = ConfigStore.getVideoConfig();
Logger.log('视频自动播放已启动', 'success');
UIPanel.setRunning(true);
UIPanel.setStatus('运行中', 'success');
this._loadRetryCount = 0;
this._userPaused = false;
this.installAntiPause();
this.playCurrentVideo();
// 启动测验弹窗检测
if (cfg.autoSkipQuiz) {
this._quizCheckInterval = setInterval(() => this.detectAndCloseQuiz(), 2000);
}
// 启动进度更新
this._progressInterval = setInterval(() => this._updateProgress(), 1000);
this._updateVideoUI();
},
// 6.1 - stop(): 停止自动播放
stop() {
if (!this._playing) return;
this._playing = false;
this.removeAntiPause();
if (this._quizCheckInterval) {
clearInterval(this._quizCheckInterval);
this._quizCheckInterval = null;
}
if (this._progressInterval) {
clearInterval(this._progressInterval);
this._progressInterval = null;
}
if (this._pauseCheckInterval) {
clearInterval(this._pauseCheckInterval);
this._pauseCheckInterval = null;
}
this._videoEl = null;
Logger.log('视频自动播放已停止', 'info');
UIPanel.setRunning(false);
UIPanel.setStatus('已停止', 'info');
this._updateVideoUI();
},
// 6.1 - playCurrentVideo(): 检测页面视频元素并播放
async playCurrentVideo() {
if (!this._playing) return;
this._loadRetryCount = 0;
await this._tryPlayVideo();
},
// 内部:尝试查找并播放视频,15秒超时重试
async _tryPlayVideo() {
if (!this._playing) return;
const video = this._findVideoElement();
if (video) {
this._videoEl = video;
const cfg = ConfigStore.getVideoConfig();
video.playbackRate = cfg.playbackRate || 2;
video.muted = false;
try {
await video.play();
Logger.log(`视频开始播放,倍速 ${video.playbackRate}x`, 'success');
} catch (e) {
// 自动播放可能被浏览器阻止,尝试静音播放
video.muted = true;
try {
await video.play();
Logger.log('视频已静音播放(浏览器限制)', 'info');
} catch (e2) {
Logger.log('视频播放失败: ' + e2.message, 'error');
}
}
this._updateChapterName();
this._bindVideoEvents(video);
this._updateVideoUI();
return;
}
// 视频未找到,等待加载
if (this._loadRetryCount < this._maxLoadRetries) {
Logger.log(`未检测到视频元素,15秒后重试 (${this._loadRetryCount + 1}/${this._maxLoadRetries})`, 'info');
await new Promise(resolve => setTimeout(resolve, 15000));
if (!this._playing) return;
this._loadRetryCount++;
// 刷新当前章节iframe
this._refreshCurrentFrame();
await new Promise(resolve => setTimeout(resolve, 3000));
if (!this._playing) return;
await this._tryPlayVideo();
} else {
Logger.log('视频加载失败,已达最大重试次数', 'error');
UIPanel.setStatus('视频加载失败', 'error');
}
},
// 内部:查找视频元素(包括iframe内部)
_findVideoElement() {
// 先在主页面查找
let video = document.querySelector('video');
if (video) return video;
// 在iframe中查找
const iframes = document.querySelectorAll('iframe');
for (const iframe of iframes) {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (iframeDoc) {
video = iframeDoc.querySelector('video');
if (video) return video;
// 嵌套iframe
const innerIframes = iframeDoc.querySelectorAll('iframe');
for (const inner of innerIframes) {
try {
const innerDoc = inner.contentDocument || inner.contentWindow?.document;
if (innerDoc) {
video = innerDoc.querySelector('video');
if (video) return video;
}
} catch (_) { /* 跨域iframe忽略 */ }
}
}
} catch (_) { /* 跨域iframe忽略 */ }
}
return null;
},
// 内部:刷新当前章节iframe
_refreshCurrentFrame() {
const iframes = document.querySelectorAll('iframe');
for (const iframe of iframes) {
try {
if (iframe.contentDocument?.querySelector('video') !== null || iframe.src) {
iframe.src = iframe.src;
break;
}
} catch (_) { /* 跨域忽略 */ }
}
},
// 内部:绑定视频事件
_bindVideoEvents(video) {
// 监听视频结束事件
const onEnded = () => {
if (!this._playing) return;
Logger.log('当前视频播放完毕', 'success');
const cfg = ConfigStore.getVideoConfig();
if (cfg.autoAdvance) {
setTimeout(() => this.advanceToNextChapter(), 1500);
} else {
Logger.log('自动推进已关闭,等待手动操作', 'info');
UIPanel.setStatus('视频播放完毕', 'info');
}
};
video.addEventListener('ended', onEnded);
// 保存引用以便清理
this._antiPauseHandlers.push({ target: video, event: 'ended', handler: onEnded, capture: false });
},
// 6.1 - setPlaybackRate(): 设置倍速
setPlaybackRate(rate) {
const video = this._videoEl || this._findVideoElement();
if (video) {
video.playbackRate = rate;
Logger.log(`播放倍速已设置为 ${rate}x`, 'info');
}
// 同步到配置
const cfg = ConfigStore.getVideoConfig();
cfg.playbackRate = rate;
ConfigStore.setVideoConfig(cfg);
},
// 6.2 - installAntiPause(): 安装防暂停钩子
installAntiPause() {
this.removeAntiPause();
// 拦截 visibilitychange(capture阶段阻止传播)
const onVisibilityChange = (e) => {
e.stopImmediatePropagation();
e.preventDefault();
};
document.addEventListener('visibilitychange', onVisibilityChange, true);
this._antiPauseHandlers.push({ target: document, event: 'visibilitychange', handler: onVisibilityChange, capture: true });
// 拦截 mouseout
const onMouseOut = (e) => {
if (this._playing) {
e.stopImmediatePropagation();
}
};
document.addEventListener('mouseout', onMouseOut, true);
this._antiPauseHandlers.push({ target: document, event: 'mouseout', handler: onMouseOut, capture: true });
// 拦截 blur
const onBlur = (e) => {
if (this._playing) {
e.stopImmediatePropagation();
}
};
window.addEventListener('blur', onBlur, true);
this._antiPauseHandlers.push({ target: window, event: 'blur', handler: onBlur, capture: true });
// 监听视频 pause 事件 — 非用户主动暂停时自动恢复
this._installVideoPauseHook();
// 3秒超时自动恢复:定时检查视频状态
this._pauseCheckInterval = setInterval(() => {
if (!this._playing) return;
const video = this._videoEl || this._findVideoElement();
if (video && video.paused && !video.ended && !this._userPaused) {
const pauseDuration = Date.now() - this._lastPauseTime;
if (this._lastPauseTime > 0 && pauseDuration >= 3000) {
Logger.log('视频暂停超过3秒,自动恢复播放', 'info');
video.play().catch(() => {});
this._lastPauseTime = 0;
}
}
}, 1000);
Logger.log('防中断机制已安装', 'info');
},
// 内部:安装视频pause事件钩子
_installVideoPauseHook() {
const hookPause = (video) => {
const onPause = () => {
if (!this._playing || video.ended) return;
if (this._userPaused) return;
this._lastPauseTime = Date.now();
// 短暂延迟后恢复,避免与正常seek冲突
setTimeout(() => {
if (this._playing && video.paused && !video.ended && !this._userPaused) {
video.play().catch(() => {});
}
}, 500);
};
video.addEventListener('pause', onPause);
this._antiPauseHandlers.push({ target: video, event: 'pause', handler: onPause, capture: false });
};
// 对当前视频安装
const video = this._videoEl || this._findVideoElement();
if (video) hookPause(video);
},
// 6.2 - removeAntiPause(): 移除所有钩子
removeAntiPause() {
this._antiPauseHandlers.forEach(({ target, event, handler, capture }) => {
try {
target.removeEventListener(event, handler, capture);
} catch (_) {}
});
this._antiPauseHandlers = [];
if (this._pauseCheckInterval) {
clearInterval(this._pauseCheckInterval);
this._pauseCheckInterval = null;
}
this._lastPauseTime = 0;
},
// 6.3 - advanceToNextChapter(): 推进到下一章节
advanceToNextChapter() {
if (!this._playing) return;
const cfg = ConfigStore.getVideoConfig();
if (!cfg.autoAdvance) {
Logger.log('自动推进已关闭', 'info');
return;
}
const courseTree = document.getElementById('coursetree');
if (!courseTree) {
Logger.log('未找到章节树 (#coursetree)', 'error');
return;
}
// 查找下一个未完成章节
const chapters = courseTree.querySelectorAll('.posCatalog_select, .posCatalog_active, [onclick]');
let foundCurrent = false;
let nextChapter = null;
for (const node of chapters) {
// 检测当前活跃章节
if (node.classList.contains('posCatalog_active') || node.classList.contains('currents')) {
foundCurrent = true;
continue;
}
// 找到当前章节后,查找下一个未完成的
if (foundCurrent) {
// 检查是否已完成(通常有完成标记class)
const isCompleted = node.classList.contains('finished') ||
node.querySelector('.icon-finish, .roundpoint.done, .catalog_finished');
if (!isCompleted) {
nextChapter = node;
break;
}
}
}
// 备选:如果上面没找到,尝试更通用的选择器
if (!nextChapter) {
const allItems = courseTree.querySelectorAll('.chapter_item, .catalog_sbar, li[id]');
foundCurrent = false;
for (const item of allItems) {
if (item.classList.contains('posCatalog_active') || item.classList.contains('currents') ||
item.querySelector('.posCatalog_active, .currents')) {
foundCurrent = true;
continue;
}
if (foundCurrent) {
const isCompleted = item.classList.contains('finished') ||
item.querySelector('.icon-finish, .roundpoint.done, .catalog_finished');
if (!isCompleted) {
nextChapter = item;
break;
}
}
}
}
if (nextChapter) {
const chapterName = nextChapter.textContent?.trim().substring(0, 30) || '下一章节';
Logger.log(`推进到: ${chapterName}`, 'info');
this._currentChapter = chapterName;
// 模拟点击
const clickTarget = nextChapter.querySelector('a, span[onclick], div[onclick]') || nextChapter;
clickTarget.click();
// 等待页面加载后播放新视频
setTimeout(() => {
if (this._playing) {
this._loadRetryCount = 0;
this._tryPlayVideo();
}
}, 3000);
} else {
Logger.log('所有章节已完成或未找到下一章节', 'success');
UIPanel.setStatus('所有章节已完成', 'success');
}
this._updateVideoUI();
},
// 6.4 - detectAndCloseQuiz(): 检测并关闭测验弹窗
detectAndCloseQuiz() {
if (!this._playing) return false;
const cfg = ConfigStore.getVideoConfig();
if (!cfg.autoSkipQuiz) return false;
// 在主页面和iframe中查找测验弹窗
const selectors = [
'.ans-job-icon',
'.ans-attach-ct',
'.popboxes_close',
'.ans-videoquiz-close',
'#videoquiz-close',
'.close_btn',
'.ans-job-close'
];
const tryClose = (doc) => {
for (const sel of selectors) {
const el = doc.querySelector(sel);
if (el && el.offsetParent !== null) {
// 找到关闭按钮
const closeBtn = el.querySelector('.close_btn, .ans-videoquiz-close, .popboxes_close') || el;
closeBtn.click();
Logger.log('已自动关闭章节测验弹窗', 'info');
// 关闭后继续播放
setTimeout(() => {
if (this._playing) {
const video = this._videoEl || this._findVideoElement();
if (video && video.paused && !video.ended) {
video.play().catch(() => {});
} else if (!video || video.ended) {
this.advanceToNextChapter();
}
}
}, 500);
return true;
}
}
return false;
};
// 主页面
if (tryClose(document)) return true;
// iframe中查找
const iframes = document.querySelectorAll('iframe');
for (const iframe of iframes) {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (iframeDoc && tryClose(iframeDoc)) return true;
} catch (_) {}
}
return false;
},
// 6.6 - getCurrentChapter(): 返回当前章节名称
getCurrentChapter() {
return this._currentChapter || '未知章节';
},
// 6.6 - getProgress(): 返回 0-100 进度百分比
getProgress() {
return this._progress;
},
// isPlaying(): 返回是否正在播放
isPlaying() {
return this._playing;
},
// 内部:更新章节名称
_updateChapterName() {
// 从章节树获取当前活跃章节名称
const courseTree = document.getElementById('coursetree');
if (courseTree) {
const active = courseTree.querySelector('.posCatalog_active, .currents, .active');
if (active) {
this._currentChapter = active.textContent?.trim().substring(0, 30) || '当前章节';
return;
}
}
// 备选:从页面标题获取
const title = document.title || '';
if (title) {
this._currentChapter = title.substring(0, 30);
}
},
// 内部:更新播放进度
_updateProgress() {
const video = this._videoEl || this._findVideoElement();
if (video && video.duration > 0) {
this._progress = Math.round((video.currentTime / video.duration) * 100);
} else {
this._progress = 0;
}
this._updateVideoUI();
},
// 6.5 & 6.6 - 更新视频标签页UI
_updateVideoUI() {
const doc = UIPanel._doc;
if (!doc) return;
const chapterEl = doc.getElementById('cx-video-chapter');
const progressEl = doc.getElementById('cx-video-progress');
const progressBar = doc.getElementById('cx-video-progress-bar');
const startBtn = doc.getElementById('cx-btn-video-start');
const stopBtn = doc.getElementById('cx-btn-video-stop');
if (chapterEl) chapterEl.textContent = this._currentChapter || '未开始';
if (progressEl) progressEl.textContent = `${this._progress}%`;
if (progressBar) progressBar.style.width = `${this._progress}%`;
if (startBtn) startBtn.disabled = this._playing;
if (stopBtn) stopBtn.disabled = !this._playing;
},
// 绑定视频模块事件
bindEvents() {
const doc = UIPanel._doc;
if (!doc) return;
// 开始播放
doc.getElementById('cx-btn-video-start')?.addEventListener('click', () => this.start());
// 停止播放
doc.getElementById('cx-btn-video-stop')?.addEventListener('click', () => {
this._userPaused = true;
this.stop();
});
}
};
// ==========================================
// Solver - 答案求解器模块(题目识别与答案匹配)
// ==========================================
const Solver = {
// 题目类型常量
TYPE_SINGLE: 0, // 单选
TYPE_MULTI: 1, // 多选
TYPE_FILL: 2, // 填空
TYPE_JUDGE: 3, // 判断
TYPE_OTHER: 4, // 其他
// ---- 8.1 readFromDOM ----
readFromDOM(element) {
const result = { question: '', options: [], type: this.TYPE_OTHER };
if (!element) return result;
result.type = this._detectType(element);
result.question = this._extractQuestion(element);
result.options = this._extractOptions(element);
return result;
},
_detectType(el) {
const cls = el.className || '';
// 通过容器 class 判断
if (/singleQuesId|single/i.test(cls)) return this.TYPE_SINGLE;
if (/multipleQuesId|multiple|multi/i.test(cls)) return this.TYPE_MULTI;
if (/judgementQuesId|judge|panduan/i.test(cls)) return this.TYPE_JUDGE;
if (/completionQuesId|fill|tiankong/i.test(cls)) return this.TYPE_FILL;
// 通过题目类型指示文本判断
const typeIndicator = el.querySelector('.mark_name, .Zy_TItle .type, .questionType');
const typeText = typeIndicator ? typeIndicator.textContent : (el.textContent || '');
if (/单选|单项选择/.test(typeText)) return this.TYPE_SINGLE;
if (/多选|多项选择|不定项/.test(typeText)) return this.TYPE_MULTI;
if (/判断|是非/.test(typeText)) return this.TYPE_JUDGE;
if (/填空/.test(typeText)) return this.TYPE_FILL;
// 通过选项数量和输入框推断
const inputs = el.querySelectorAll('input[type="radio"]');
if (inputs.length > 0) {
// 判断题通常只有2个选项(对/错)
const opts = this._extractOptions(el);
if (inputs.length === 2 && opts.length === 2) {
const joined = opts.join('');
if (/[对错√×✓✗TF]/.test(joined) || /正确|错误|true|false/i.test(joined)) {
return this.TYPE_JUDGE;
}
}
return this.TYPE_SINGLE;
}
const checkboxes = el.querySelectorAll('input[type="checkbox"]');
if (checkboxes.length > 0) return this.TYPE_MULTI;
const textInputs = el.querySelectorAll('input[type="text"], textarea');
if (textInputs.length > 0) return this.TYPE_FILL;
return this.TYPE_OTHER;
},
_extractQuestion(el) {
// 优先从 .mark_name 或 .Zy_TItle 提取
const selectors = ['.mark_name', '.Zy_TItle', '.questionContent', '.question_title', '.stem'];
for (const sel of selectors) {
const node = el.querySelector(sel);
if (node) {
const text = node.textContent.trim();
if (text) return this._cleanQuestionText(text);
}
}
// 备选:取容器内第一段有意义的文本
const firstP = el.querySelector('p, h3, h4, .title');
if (firstP) {
const text = firstP.textContent.trim();
if (text) return this._cleanQuestionText(text);
}
return '';
},
_cleanQuestionText(text) {
// 移除题号前缀(如 "1." "【单选题】" 等)
return text.replace(/^[\s\d.、\[\]【】()()]+/, '')
.replace(/^(单选题|多选题|判断题|填空题|单项选择题|多项选择题)[】\]))]\s*/, '')
.trim();
},
_extractOptions(el) {
const options = [];
// 优先从 .Zy_ulTop li 提取
const liItems = el.querySelectorAll('.Zy_ulTop li, .answerBg li, .option_li, .answer_p');
if (liItems.length > 0) {
liItems.forEach(li => {
const text = this._cleanOptionText(li.textContent.trim());
if (text) options.push(text);
});
if (options.length > 0) return options;
}
// 备选:从 label 元素提取
const labels = el.querySelectorAll('label.fl, label.option, .answerBg label');
if (labels.length > 0) {
labels.forEach(label => {
const text = this._cleanOptionText(label.textContent.trim());
if (text) options.push(text);
});
if (options.length > 0) return options;
}
// 再备选:从包含 radio/checkbox 的容器提取
const inputContainers = el.querySelectorAll('.Zy_ulBottom li, .answerList li, div.option');
inputContainers.forEach(container => {
const text = this._cleanOptionText(container.textContent.trim());
if (text) options.push(text);
});
return options;
},
_cleanOptionText(text) {
// 移除选项前缀(如 "A." "A、" "A " 等)
return text.replace(/^[A-Za-z][.、::\s]\s*/, '').trim();
},
// ---- 8.2 recognizeByOCR ----
async recognizeByOCR(element) {
const result = { question: '', options: [], type: this.TYPE_OTHER };
if (!element) return result;
// 查找题目区域中的图片
const images = element.querySelectorAll('img');
if (images.length === 0) return result;
try {
// 识别第一张图片作为题目
const mainImg = images[0];
const ocrResult = await this._ocrImage(mainImg);
if (ocrResult) {
result.question = ocrResult.trim();
}
// 如果有多张图片,后续图片可能是选项
for (let i = 1; i < images.length; i++) {
const optText = await this._ocrImage(images[i]);
if (optText && optText.trim()) {
result.options.push(optText.trim());
}
}
// 尝试从 DOM 补充类型检测
result.type = this._detectType(element);
} catch (e) {
Logger.log('OCR 识别失败: ' + (e.message || e), 'error');
}
return result;
},
async _ocrImage(imgEl) {
if (!imgEl || !imgEl.src) return '';
try {
// Tesseract.js v2 API
if (typeof Tesseract !== 'undefined' && Tesseract.recognize) {
const { data } = await Tesseract.recognize(imgEl.src, 'chi_sim');
return data.text || '';
}
} catch (e) {
Logger.log('Tesseract OCR 错误: ' + (e.message || e), 'error');
}
return '';
},
// recognize(): 综合识别(先 DOM,失败则 OCR)
async recognize(element) {
const domResult = this.readFromDOM(element);
if (domResult.question) return domResult;
// DOM 提取失败,尝试 OCR
Logger.log('DOM 提取失败,尝试 OCR 识别...', 'info');
return await this.recognizeByOCR(element);
},
// ---- 8.3 matchAnswer ----
matchAnswer(answers, pageOptions) {
if (!answers || answers.length === 0 || !pageOptions || pageOptions.length === 0) return [];
const matched = [];
for (const answer of answers) {
const ans = answer.trim();
if (!ans) continue;
// 策略1:判断题特殊处理
const judgeIdx = this._matchJudge(ans);
if (judgeIdx >= 0 && judgeIdx < pageOptions.length) {
matched.push(judgeIdx);
continue;
}
// 策略2:纯字母答案映射(A/AC/BCD → 索引)
const letterIndices = this._matchLetters(ans);
if (letterIndices.length > 0) {
const valid = letterIndices.filter(i => i < pageOptions.length);
matched.push(...valid);
continue;
}
// 策略3:精确匹配
const exactIdx = this._matchExact(ans, pageOptions);
if (exactIdx >= 0) {
matched.push(exactIdx);
continue;
}
// 策略4:包含匹配
const containIdx = this._matchContain(ans, pageOptions);
if (containIdx >= 0) {
matched.push(containIdx);
continue;
}
// 策略5:相似度匹配(>60%)
const simIdx = this._matchSimilarity(ans, pageOptions);
if (simIdx >= 0) {
matched.push(simIdx);
continue;
}
}
// 去重
return [...new Set(matched)];
},
_matchJudge(ans) {
const truePatterns = /^(对|正确|√|✓|T|true|是|right|yes)$/i;
const falsePatterns = /^(错|错误|×|✗|F|false|否|wrong|no)$/i;
if (truePatterns.test(ans)) return 0;
if (falsePatterns.test(ans)) return 1;
return -1;
},
_matchLetters(ans) {
// 仅当答案是纯字母(A-Z)时才匹配
if (!/^[A-Za-z]+$/.test(ans)) return [];
// 排除可能是单词的情况(超过4个字母且不全是大写)
if (ans.length > 4 && ans !== ans.toUpperCase()) return [];
return ans.toUpperCase().split('').map(ch => ch.charCodeAt(0) - 65);
},
_matchExact(ans, pageOptions) {
const normalized = ans.toLowerCase().trim();
for (let i = 0; i < pageOptions.length; i++) {
if (pageOptions[i].toLowerCase().trim() === normalized) return i;
}
return -1;
},
_matchContain(ans, pageOptions) {
const normalized = ans.toLowerCase().trim();
// 答案是选项的子串
for (let i = 0; i < pageOptions.length; i++) {
const opt = pageOptions[i].toLowerCase().trim();
if (opt.includes(normalized) || normalized.includes(opt)) return i;
}
return -1;
},
_matchSimilarity(ans, pageOptions) {
let bestIdx = -1;
let bestScore = 0.6; // 阈值 60%
for (let i = 0; i < pageOptions.length; i++) {
const score = this._similarity(ans, pageOptions[i]);
if (score > bestScore) {
bestScore = score;
bestIdx = i;
}
}
return bestIdx;
},
// 字符串相似度(基于编辑距离)
_similarity(a, b) {
const s1 = a.toLowerCase().trim();
const s2 = b.toLowerCase().trim();
if (s1 === s2) return 1;
if (!s1 || !s2) return 0;
const maxLen = Math.max(s1.length, s2.length);
if (maxLen === 0) return 1;
const dist = this._editDistance(s1, s2);
return 1 - dist / maxLen;
},
_editDistance(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
}
return dp[m][n];
},
// ---- 8.4 autoSelectAndSubmit ----
async autoSelectAndSubmit(answers, container) {
if (!container) {
Logger.log('无法解析答案:容器元素为空', 'error');
return;
}
// 提取页面选项文本
const pageOptions = this._extractOptions(container);
if (pageOptions.length === 0 && this._detectType(container) !== this.TYPE_FILL) {
Logger.log('无法解析答案:未找到页面选项', 'error');
return;
}
const type = this._detectType(container);
// 填空题特殊处理
if (type === this.TYPE_FILL) {
this._fillBlanks(answers, container);
return;
}
// 匹配答案
const matchedIndices = this.matchAnswer(answers, pageOptions);
if (matchedIndices.length === 0) {
Logger.log('无法解析答案:匹配失败,跳过该题', 'error');
return;
}
// 选中对应选项
this._selectOptions(matchedIndices, container, type);
// 点击提交按钮
await this._clickSubmit(container);
},
_selectOptions(indices, container, type) {
// 获取所有可点击的选项元素
const optionEls = container.querySelectorAll(
'.Zy_ulTop li, .answerBg li, .option_li, label.fl, label.option, .answerList li, div.option'
);
// 获取所有 radio/checkbox
const radios = container.querySelectorAll('input[type="radio"]');
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
const inputs = radios.length > 0 ? radios : checkboxes;
indices.forEach(idx => {
// 优先通过 input 元素选中
if (idx < inputs.length) {
const input = inputs[idx];
if (!input.checked) {
input.click();
input.checked = true;
// 触发 change 事件
input.dispatchEvent(new Event('change', { bubbles: true }));
}
} else if (idx < optionEls.length) {
// 备选:点击选项容器
optionEls[idx].click();
}
});
Logger.log(`已选择选项: ${indices.map(i => String.fromCharCode(65 + i)).join('')}`, 'info');
},
_fillBlanks(answers, container) {
const inputs = container.querySelectorAll('input[type="text"], textarea');
answers.forEach((ans, i) => {
if (i < inputs.length) {
inputs[i].value = ans.trim();
inputs[i].dispatchEvent(new Event('input', { bubbles: true }));
inputs[i].dispatchEvent(new Event('change', { bubbles: true }));
}
});
Logger.log(`已填写 ${Math.min(answers.length, inputs.length)} 个填空`, 'info');
},
async _clickSubmit(container) {
// 查找提交按钮
const submitSelectors = [
'.jb_btn',
'#submitBtn',
'a.jb_btn',
'button[type="submit"]',
'.Btn_blue_01',
'.save_btn',
'.submit_btn'
];
let submitBtn = null;
for (const sel of submitSelectors) {
submitBtn = container.querySelector(sel) || document.querySelector(sel);
if (submitBtn) break;
}
if (submitBtn) {
// 短暂延迟后点击提交
await new Promise(r => setTimeout(r, 300));
submitBtn.click();
Logger.log('已点击提交按钮', 'info');
}
}
};
// ==========================================
// AnswerModule - 答题模块(三种答题模式)
// ==========================================
const AnswerModule = {
_mode: 'free', // 当前模式: 'free' | 'paid' | 'ai'
_answering: false,
_stopRequested: false,
_currentQuestion: 0,
_totalQuestions: 0,
// ---- 模式管理 ----
setMode(mode) {
this._mode = mode;
const cfg = ConfigStore.getAnswerConfig();
cfg.mode = mode;
ConfigStore.setAnswerConfig(cfg);
this._updateModeUI();
Logger.log(`答题模式已切换为: ${mode === 'free' ? '免费题库' : mode === 'paid' ? '付费题库' : 'AI 答题'}`, 'info');
},
getMode() {
return this._mode;
},
// ---- 9.1 免费验证码模式 ----
verifyCode(code) {
return new Promise((resolve) => {
const doc = UIPanel._doc;
const verifyBtn = doc?.getElementById('cx-btn-verify');
if (verifyBtn) {
verifyBtn.disabled = true;
verifyBtn.textContent = '验证中...';
}
GM_xmlhttpRequest({
method: 'POST',
url: 'https://qsy.iano.cn/index.php?s=/api/code/verify',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: `code=${encodeURIComponent(code)}`,
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText);
if (data.code === 1 && data.data && data.data.valid) {
const validUntil = data.data.valid_until;
const validUntilStr = data.data.valid_until_str || new Date(validUntil * 1000).toLocaleString('zh-CN');
// 保存有效期
const cfg = ConfigStore.getAnswerConfig();
cfg.verifyValidUntil = validUntil;
ConfigStore.setAnswerConfig(cfg);
Logger.log(`验证成功,有效期至 ${validUntilStr}`, 'success');
this._updateFreeStatus(true, validUntilStr);
resolve({ success: true, validUntil, validUntilStr });
} else {
const msg = data.msg || '验证码无效或已过期';
Logger.log(`验证失败: ${msg}`, 'error');
this._updateFreeStatus(false, msg);
resolve({ success: false, message: msg });
}
} catch (e) {
Logger.log('验证响应解析失败', 'error');
this._updateFreeStatus(false, '验证响应解析失败');
resolve({ success: false, message: '响应解析失败' });
} finally {
if (verifyBtn) {
verifyBtn.textContent = '验证';
// 如果验证成功且有效期内,保持禁用
if (!this.isVerifyValid()) {
verifyBtn.disabled = false;
}
}
}
},
onerror: () => {
Logger.log('验证请求失败', 'error');
this._updateFreeStatus(false, '网络请求失败');
if (verifyBtn) {
verifyBtn.disabled = false;
verifyBtn.textContent = '验证';
}
resolve({ success: false, message: '网络请求失败' });
}
});
});
},
isVerifyValid() {
const cfg = ConfigStore.getAnswerConfig();
const validUntil = cfg.verifyValidUntil || 0;
return validUntil > 0 && Date.now() / 1000 < validUntil;
},
_updateFreeStatus(success, message) {
const doc = UIPanel._doc;
if (!doc) return;
const statusEl = doc.getElementById('cx-free-status');
const codeInput = doc.getElementById('cx-free-code');
const verifyBtn = doc.getElementById('cx-btn-verify');
if (!statusEl) return;
if (success && this.isVerifyValid()) {
statusEl.textContent = `✅ 验证成功,有效期至 ${message}`;
statusEl.style.color = '#67c23a';
if (codeInput) codeInput.disabled = true;
if (verifyBtn) verifyBtn.disabled = true;
} else if (success === false) {
statusEl.textContent = `❌ ${message}`;
statusEl.style.color = '#f56c6c';
if (codeInput) codeInput.disabled = false;
if (verifyBtn) verifyBtn.disabled = false;
} else {
statusEl.textContent = '';
if (codeInput) codeInput.disabled = false;
if (verifyBtn) verifyBtn.disabled = false;
}
},
// 检查免费模式有效期,过期后重新启用输入
_checkFreeExpiry() {
if (this._mode !== 'free') return;
const cfg = ConfigStore.getAnswerConfig();
const validUntil = cfg.verifyValidUntil || 0;
if (validUntil > 0 && Date.now() / 1000 >= validUntil) {
// 已过期
cfg.verifyValidUntil = 0;
ConfigStore.setAnswerConfig(cfg);
this._updateFreeStatus(null);
const doc = UIPanel._doc;
if (doc) {
const statusEl = doc.getElementById('cx-free-status');
if (statusEl) {
statusEl.textContent = '⚠️ 验证码已过期,请重新验证';
statusEl.style.color = '#e6a23c';
}
}
Logger.log('免费模式验证码已过期', 'info');
}
},
// ---- 9.2 付费积分模式 ----
activateCode(code) {
return new Promise((resolve) => {
const doc = UIPanel._doc;
const activateBtn = doc?.getElementById('cx-btn-activate');
if (activateBtn) {
activateBtn.disabled = true;
activateBtn.textContent = '激活中...';
}
GM_xmlhttpRequest({
method: 'POST',
url: 'https://qsy.iano.cn/index.php?s=/api/question_bank/activate',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
code: code,
device_id: ConfigStore.getDeviceId()
}),
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText);
if (data.code === 1 && data.data) {
const credits = data.data.credits || 0;
const remaining = data.data.remaining_credits || 0;
const msg = data.data.message || `恭喜您获得 ${credits} 积分`;
Logger.log(`激活成功: ${msg}`, 'success');
this._updateCreditsDisplay(remaining);
this._showPaidStatus(true, msg);
resolve({ success: true, credits, remaining });
} else {
const msg = data.msg || '激活码无效或已使用';
Logger.log(`激活失败: ${msg}`, 'error');
this._showPaidStatus(false, msg);
resolve({ success: false, message: msg });
}
} catch (e) {
Logger.log('激活响应解析失败', 'error');
this._showPaidStatus(false, '激活响应解析失败');
resolve({ success: false, message: '响应解析失败' });
} finally {
if (activateBtn) {
activateBtn.disabled = false;
activateBtn.textContent = '激活';
}
}
},
onerror: () => {
Logger.log('激活请求失败', 'error');
this._showPaidStatus(false, '网络请求失败');
if (activateBtn) {
activateBtn.disabled = false;
activateBtn.textContent = '激活';
}
resolve({ success: false, message: '网络请求失败' });
}
});
});
},
getCredits() {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://qsy.iano.cn/index.php?s=/api/question_bank/credits&device_id=${encodeURIComponent(ConfigStore.getDeviceId())}`,
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText);
if (data.code === 1 && data.data) {
const remaining = data.data.remaining_credits || 0;
this._updateCreditsDisplay(remaining);
resolve(remaining);
} else {
resolve(0);
}
} catch (e) {
resolve(0);
}
},
onerror: () => resolve(0)
});
});
},
_updateCreditsDisplay(credits) {
const doc = UIPanel._doc;
if (!doc) return;
const el = doc.getElementById('cx-paid-credits');
if (el) el.textContent = credits;
},
_showPaidStatus(success, message) {
const doc = UIPanel._doc;
if (!doc) return;
const statusEl = doc.getElementById('cx-paid-status');
if (!statusEl) return;
statusEl.textContent = success ? `✅ ${message}` : `❌ ${message}`;
statusEl.style.color = success ? '#67c23a' : '#f56c6c';
},
// ---- 9.3 AI 答题模式 ----
setAIApiKey(key) {
const cfg = ConfigStore.getAnswerConfig();
cfg.aiApiKey = key;
ConfigStore.setAnswerConfig(cfg);
Logger.log('AI API Key 已保存', 'success');
},
askAI(question, options, type) {
return new Promise((resolve) => {
const cfg = ConfigStore.getAnswerConfig();
const apiKey = cfg.aiApiKey;
if (!apiKey) {
Logger.log('请先配置 AI API Key', 'error');
resolve({ answers: [], remaining: -1 });
return;
}
// 构造 prompt
const prompt = this._buildAIPrompt(question, options, type);
GM_xmlhttpRequest({
method: 'POST',
url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
data: JSON.stringify({
model: 'glm-4-flash',
messages: [{ role: 'user', content: prompt }]
}),
timeout: 30000,
onload: (resp) => {
try {
if (resp.status === 401) {
Logger.log('AI API Key 无效(401 Unauthorized)', 'error');
resolve({ answers: [], remaining: -1 });
return;
}
if (resp.status === 429) {
Logger.log('AI 请求频率超限(429 Too Many Requests)', 'error');
resolve({ answers: [], remaining: -1 });
return;
}
const data = JSON.parse(resp.responseText);
if (data.choices && data.choices.length > 0) {
const content = data.choices[0].message?.content || '';
const answers = this._parseAIAnswer(content, type);
Logger.log(`AI 返回答案: ${answers.join(', ')}`, 'success');
resolve({ answers, remaining: -1 });
} else {
Logger.log('AI 返回内容为空', 'error');
resolve({ answers: [], remaining: -1 });
}
} catch (e) {
Logger.log('AI 响应解析失败: ' + (e.message || e), 'error');
resolve({ answers: [], remaining: -1 });
}
},
onerror: () => {
Logger.log('AI 请求失败(网络错误)', 'error');
resolve({ answers: [], remaining: -1 });
},
ontimeout: () => {
Logger.log('AI 请求超时', 'error');
resolve({ answers: [], remaining: -1 });
}
});
});
},
_buildAIPrompt(question, options, type) {
const typeNames = { 0: '单选题', 1: '多选题', 2: '填空题', 3: '判断题', 4: '题目' };
const typeName = typeNames[type] || '题目';
let prompt = `请回答以下${typeName},`;
switch (type) {
case Solver.TYPE_SINGLE:
prompt += '请只回答选项字母(如 A),不要包含其他内容。\n';
break;
case Solver.TYPE_MULTI:
prompt += '请回答所有正确选项的字母,多个选项之间用 || 分隔(如 A||C||D),不要包含其他内容。\n';
break;
case Solver.TYPE_JUDGE:
prompt += '请只回答"对"或"错",不要包含其他内容。\n';
break;
case Solver.TYPE_FILL:
prompt += '请只回答填空内容,多个空之间用 || 分隔,不要包含其他内容。\n';
break;
default:
prompt += '请直接回答答案,不要包含解释。\n';
}
prompt += `\n题目:${question}\n`;
if (options && options.length > 0) {
prompt += '选项:\n';
options.forEach((opt, i) => {
prompt += `${String.fromCharCode(65 + i)}. ${opt}\n`;
});
}
return prompt;
},
_parseAIAnswer(content, type) {
if (!content) return [];
const text = content.trim();
// 多选题或填空题:用 || 分隔
if (type === Solver.TYPE_MULTI || type === Solver.TYPE_FILL) {
if (text.includes('||')) {
return text.split('||').map(s => s.trim()).filter(Boolean);
}
}
// 单选题:提取字母
if (type === Solver.TYPE_SINGLE) {
const match = text.match(/^[A-Za-z]/);
if (match) return [match[0].toUpperCase()];
}
// 多选题备选:提取连续字母
if (type === Solver.TYPE_MULTI) {
const match = text.match(/[A-Za-z]/g);
if (match) return [...new Set(match.map(c => c.toUpperCase()))];
}
// 判断题
if (type === Solver.TYPE_JUDGE) {
if (/对|正确|√|true|是|right|yes/i.test(text)) return ['对'];
if (/错|错误|×|false|否|wrong|no/i.test(text)) return ['错'];
}
// 默认返回整个文本
return [text];
},
// ---- 答题查询接口(供后续任务使用) ----
askFreeBank(question, options, type) {
return new Promise((resolve) => {
if (!this.isVerifyValid()) {
Logger.log('免费模式验证码已过期,请重新验证', 'error');
resolve({ answers: [], remaining: -1 });
return;
}
GM_xmlhttpRequest({
method: 'POST',
url: 'https://qsy.iano.cn/index.php?s=/api/question_bank/answer',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
device_id: ConfigStore.getDeviceId(),
question,
options,
type,
location: '学习通'
}),
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText);
if (data.code === 1 && data.data?.result) {
resolve({ answers: data.data.result.answers || [], remaining: -1 });
} else {
resolve({ answers: [], remaining: -1 });
}
} catch (e) {
resolve({ answers: [], remaining: -1 });
}
},
onerror: () => resolve({ answers: [], remaining: -1 })
});
});
},
askPaidBank(question, options, type) {
return new Promise(async (resolve) => {
// 检查积分余额
const credits = await this.getCredits();
if (credits <= 0) {
Logger.log('积分不足,请充值后再试', 'error');
resolve({ answers: [], remaining: 0 });
return;
}
GM_xmlhttpRequest({
method: 'POST',
url: 'https://qsy.iano.cn/index.php?s=/api/question_bank/answer',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
device_id: ConfigStore.getDeviceId(),
question,
options,
type,
location: '学习通'
}),
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText);
if (data.code === 1 && data.data?.result) {
const remaining = data.data.remaining_credits || 0;
this._updateCreditsDisplay(remaining);
resolve({ answers: data.data.result.answers || [], remaining });
} else {
resolve({ answers: [], remaining: credits });
}
} catch (e) {
resolve({ answers: [], remaining: credits });
}
},
onerror: () => resolve({ answers: [], remaining: credits })
});
});
},
// ---- 10.1 / 10.2 答题查询路由 ----
async _queryAnswer(question, options, type) {
switch (this._mode) {
case 'free': return this.askFreeBank(question, options, type);
case 'paid': return this.askPaidBank(question, options, type);
case 'ai': return this.askAI(question, options, type);
default: return { answers: [], remaining: -1 };
}
},
// ---- 10.1 作业自动答题 ----
async startHomework() {
if (this._answering) {
Logger.log('答题任务正在进行中', 'info');
return;
}
// 查找所有题目容器
const containers = document.querySelectorAll(
'.questionLi, .singleQuesId, .multipleQuesId, .judgementQuesId, .completionQuesId'
);
if (containers.length === 0) {
Logger.log('未检测到题目,请确认当前页面为作业页面', 'error');
return;
}
this._answering = true;
this._stopRequested = false;
this._totalQuestions = containers.length;
this._currentQuestion = 0;
UIPanel.setRunning(true);
UIPanel.setProgress(0, this._totalQuestions);
Logger.log(`检测到 ${this._totalQuestions} 道题目,开始答题...`, 'info');
// 更新按钮状态
this._setAnswerButtonsRunning(true);
for (let i = 0; i < containers.length; i++) {
if (this._stopRequested) {
Logger.log('答题已停止', 'info');
break;
}
this._currentQuestion = i + 1;
UIPanel.setProgress(this._currentQuestion, this._totalQuestions);
Logger.log(`正在处理第 ${this._currentQuestion}/${this._totalQuestions} 题`, 'info');
const container = containers[i];
// 跳过已完成题目
if (this._isQuestionCompleted(container)) {
Logger.log(`第 ${this._currentQuestion} 题已完成,跳过`, 'info');
continue;
}
try {
// 识别题目
const qData = await Solver.recognize(container);
if (!qData.question) {
Logger.log(`第 ${this._currentQuestion} 题识别失败,跳过`, 'error');
continue;
}
// 查询答案(失败后 2 秒重试一次)
let result = await this._queryAnswer(qData.question, qData.options, qData.type);
if (!result.answers || result.answers.length === 0) {
Logger.log(`第 ${this._currentQuestion} 题首次查询失败,2 秒后重试...`, 'info');
await this._delay(2000);
result = await this._queryAnswer(qData.question, qData.options, qData.type);
}
if (!result.answers || result.answers.length === 0) {
Logger.log(`第 ${this._currentQuestion} 题查询无结果,跳过`, 'error');
continue;
}
// 匹配并选择答案
await Solver.autoSelectAndSubmit(result.answers, container);
Logger.log(`第 ${this._currentQuestion} 题已作答`, 'success');
} catch (e) {
Logger.log(`第 ${this._currentQuestion} 题处理异常: ${e.message || e}`, 'error');
}
// 题间延迟 1-2 秒,避免检测
if (i < containers.length - 1 && !this._stopRequested) {
await this._delay(1000 + Math.random() * 1000);
}
}
this._answering = false;
this._stopRequested = false;
UIPanel.setRunning(false);
this._setAnswerButtonsRunning(false);
if (!this._stopRequested) {
Logger.log('答题完成,请检查并提交', 'success');
}
},
// ---- 10.2 考试自动答题 ----
async startExam() {
if (this._answering) {
Logger.log('答题任务正在进行中', 'info');
return;
}
this._answering = true;
this._stopRequested = false;
UIPanel.setRunning(true);
this._setAnswerButtonsRunning(true);
// 检测考试页面布局
const isPaginated = this._isExamPaginated();
Logger.log(`检测到考试布局: ${isPaginated ? '分页模式' : '列表模式'}`, 'info');
try {
if (isPaginated) {
await this._processExamPaginated();
} else {
await this._processExamList();
}
} catch (e) {
Logger.log(`考试答题异常: ${e.message || e}`, 'error');
}
this._answering = false;
this._stopRequested = false;
UIPanel.setRunning(false);
this._setAnswerButtonsRunning(false);
Logger.log('答题完成,请检查并提交', 'success');
},
// 检测是否为分页模式考试
_isExamPaginated() {
// 分页模式:有侧边栏题号导航
const sidebar = document.querySelector('.exam-sidebar, .leftItems, .questionList, .TiMu-list');
const navItems = sidebar ? sidebar.querySelectorAll('a, li, .item, .TiMu-item') : [];
return navItems.length > 0;
},
// 分页模式:通过侧边栏题号导航逐题处理
async _processExamPaginated() {
const sidebar = document.querySelector('.exam-sidebar, .leftItems, .questionList, .TiMu-list');
if (!sidebar) {
Logger.log('未找到侧边栏导航', 'error');
return;
}
const navItems = sidebar.querySelectorAll('a, li, .item, .TiMu-item');
this._totalQuestions = navItems.length;
this._currentQuestion = 0;
UIPanel.setProgress(0, this._totalQuestions);
Logger.log(`检测到 ${this._totalQuestions} 道题目`, 'info');
for (let i = 0; i < navItems.length; i++) {
if (this._stopRequested) {
Logger.log('答题已停止', 'info');
break;
}
this._currentQuestion = i + 1;
UIPanel.setProgress(this._currentQuestion, this._totalQuestions);
const navItem = navItems[i];
// 跳过已作答题目(.is-checked 标记)
if (navItem.classList.contains('is-checked') || navItem.querySelector('.is-checked')) {
Logger.log(`第 ${this._currentQuestion} 题已作答,跳过`, 'info');
continue;
}
// 点击导航切换到该题
navItem.click();
await this._delay(800);
Logger.log(`正在处理第 ${this._currentQuestion}/${this._totalQuestions} 题`, 'info');
// 获取当前显示的题目容器
const container = this._findCurrentExamQuestion();
if (!container) {
Logger.log(`第 ${this._currentQuestion} 题容器未找到,跳过`, 'error');
continue;
}
await this._processOneQuestion(container, this._currentQuestion);
// 题间延迟
if (i < navItems.length - 1 && !this._stopRequested) {
await this._delay(1000 + Math.random() * 1000);
}
}
},
// 列表模式:所有题目在同一页面
async _processExamList() {
const containers = document.querySelectorAll(
'.exam-main .questionLi, .exam-main .singleQuesId, .exam-main .multipleQuesId, ' +
'.exam-main .judgementQuesId, .exam-main .completionQuesId, ' +
'.questionLi, .singleQuesId, .multipleQuesId, .judgementQuesId, .completionQuesId'
);
if (containers.length === 0) {
Logger.log('未检测到题目', 'error');
return;
}
this._totalQuestions = containers.length;
this._currentQuestion = 0;
UIPanel.setProgress(0, this._totalQuestions);
Logger.log(`检测到 ${this._totalQuestions} 道题目`, 'info');
for (let i = 0; i < containers.length; i++) {
if (this._stopRequested) {
Logger.log('答题已停止', 'info');
break;
}
this._currentQuestion = i + 1;
UIPanel.setProgress(this._currentQuestion, this._totalQuestions);
Logger.log(`正在处理第 ${this._currentQuestion}/${this._totalQuestions} 题`, 'info');
const container = containers[i];
// 跳过已作答题目
if (container.classList.contains('is-checked') || container.querySelector('.is-checked')) {
Logger.log(`第 ${this._currentQuestion} 题已作答,跳过`, 'info');
continue;
}
await this._processOneQuestion(container, this._currentQuestion);
// 题间延迟
if (i < containers.length - 1 && !this._stopRequested) {
await this._delay(1000 + Math.random() * 1000);
}
}
},
// 处理单道题目(考试模式共用)
async _processOneQuestion(container, questionNum) {
try {
const qData = await Solver.recognize(container);
if (!qData.question) {
Logger.log(`第 ${questionNum} 题识别失败,跳过`, 'error');
return;
}
// 查询答案(失败后 2 秒重试一次)
let result = await this._queryAnswer(qData.question, qData.options, qData.type);
if (!result.answers || result.answers.length === 0) {
Logger.log(`第 ${questionNum} 题首次查询失败,2 秒后重试...`, 'info');
await this._delay(2000);
result = await this._queryAnswer(qData.question, qData.options, qData.type);
}
if (!result.answers || result.answers.length === 0) {
Logger.log(`第 ${questionNum} 题查询无结果,跳过`, 'error');
return;
}
// 考试模式不自动提交,只选择答案
const pageOptions = Solver._extractOptions(container);
const type = Solver._detectType(container);
if (type === Solver.TYPE_FILL) {
Solver._fillBlanks(result.answers, container);
} else {
const matchedIndices = Solver.matchAnswer(result.answers, pageOptions);
if (matchedIndices.length === 0) {
Logger.log(`第 ${questionNum} 题答案匹配失败,跳过`, 'error');
return;
}
Solver._selectOptions(matchedIndices, container, type);
}
Logger.log(`第 ${questionNum} 题已作答`, 'success');
} catch (e) {
Logger.log(`第 ${questionNum} 题处理异常: ${e.message || e}`, 'error');
}
},
// 查找当前显示的考试题目容器
_findCurrentExamQuestion() {
// 分页模式下,当前显示的题目
const visible = document.querySelector(
'.questionLi:not([style*="display: none"]):not([style*="display:none"]), ' +
'.singleQuesId:not([style*="display: none"]), ' +
'.multipleQuesId:not([style*="display: none"]), ' +
'.judgementQuesId:not([style*="display: none"]), ' +
'.completionQuesId:not([style*="display: none"])'
);
if (visible) return visible;
// 备选:查找 .exam-main 下的活跃题目
const active = document.querySelector('.exam-main .active, .exam-main .current');
if (active) return active;
// 再备选:取第一个题目容器
return document.querySelector('.questionLi, .singleQuesId, .multipleQuesId, .judgementQuesId, .completionQuesId');
},
// 检查题目是否已完成
_isQuestionCompleted(container) {
if (!container) return false;
// 检查 .is-checked 标记
if (container.classList.contains('is-checked') || container.querySelector('.is-checked')) return true;
// 检查已选中的选项
const checked = container.querySelector('input[type="radio"]:checked, input[type="checkbox"]:checked');
if (checked) return true;
// 检查填空题已填写
const textInputs = container.querySelectorAll('input[type="text"], textarea');
for (const input of textInputs) {
if (input.value && input.value.trim()) return true;
}
return false;
},
// 更新答题按钮状态
_setAnswerButtonsRunning(running) {
const doc = UIPanel._doc;
if (!doc) return;
const homeworkBtn = doc.getElementById('cx-btn-start-homework');
const examBtn = doc.getElementById('cx-btn-start-exam');
const stopBtn = doc.getElementById('cx-btn-stop-answer');
if (homeworkBtn) homeworkBtn.disabled = running;
if (examBtn) examBtn.disabled = running;
if (stopBtn) stopBtn.disabled = !running;
},
// 延迟工具
_delay(ms) {
return new Promise(r => setTimeout(r, ms));
},
// ---- 9.4 UI 相关 ----
_updateModeUI() {
const doc = UIPanel._doc;
if (!doc) return;
const mode = this._mode;
// 更新 radio 选中状态
doc.querySelectorAll('input[name="cx-answer-mode-tab"]').forEach(radio => {
radio.checked = radio.value === mode;
});
// 显示/隐藏对应配置区域
doc.getElementById('cx-answer-free-area').style.display = mode === 'free' ? 'block' : 'none';
doc.getElementById('cx-answer-paid-area').style.display = mode === 'paid' ? 'block' : 'none';
doc.getElementById('cx-answer-ai-area').style.display = mode === 'ai' ? 'block' : 'none';
},
// 答题状态
isAnswering() { return this._answering; },
getCurrentQuestion() { return this._currentQuestion; },
getTotalQuestions() { return this._totalQuestions; },
stopAnswering() { this._stopRequested = true; },
// 绑定答题模块事件
bindEvents() {
const doc = UIPanel._doc;
if (!doc) return;
// 模式切换(答题标签页内的 radio)
doc.querySelectorAll('input[name="cx-answer-mode-tab"]').forEach(radio => {
radio.addEventListener('change', (e) => {
this.setMode(e.target.value);
});
});
// 免费模式:验证按钮
doc.getElementById('cx-btn-verify')?.addEventListener('click', () => {
const code = doc.getElementById('cx-free-code')?.value?.trim();
if (!code || code.length !== 4) {
this._updateFreeStatus(false, '请输入4位验证码');
return;
}
this.verifyCode(code);
});
// 免费模式:回车键验证
doc.getElementById('cx-free-code')?.addEventListener('keypress', (e) => {
if (e.key === 'Enter') doc.getElementById('cx-btn-verify')?.click();
});
// 付费模式:激活按钮
doc.getElementById('cx-btn-activate')?.addEventListener('click', () => {
const code = doc.getElementById('cx-paid-code')?.value?.trim();
if (!code) {
this._showPaidStatus(false, '请输入激活码');
return;
}
this.activateCode(code);
});
// 付费模式:刷新积分
doc.getElementById('cx-btn-refresh-credits')?.addEventListener('click', () => {
this.getCredits();
Logger.log('正在查询积分余额...', 'info');
});
// AI 模式:保存 API Key
doc.getElementById('cx-btn-save-aikey')?.addEventListener('click', () => {
const key = doc.getElementById('cx-ai-apikey')?.value?.trim();
if (!key) {
Logger.log('请输入 API Key', 'error');
return;
}
this.setAIApiKey(key);
const statusEl = doc.getElementById('cx-ai-status');
if (statusEl) {
statusEl.textContent = '✅ API Key 已保存';
statusEl.style.color = '#67c23a';
}
});
// 同步设置面板中的答题模式 radio
doc.querySelectorAll('input[name="cx-answer-mode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
this.setMode(e.target.value);
// 同步答题标签页的 radio
const tabRadio = doc.querySelector(`input[name="cx-answer-mode-tab"][value="${e.target.value}"]`);
if (tabRadio) tabRadio.checked = true;
});
});
// 作业答题按钮
doc.getElementById('cx-btn-start-homework')?.addEventListener('click', () => {
this.startHomework();
});
// 考试答题按钮
doc.getElementById('cx-btn-start-exam')?.addEventListener('click', () => {
this.startExam();
});
// 停止答题按钮
doc.getElementById('cx-btn-stop-answer')?.addEventListener('click', () => {
this.stopAnswering();
Logger.log('正在停止答题(当前题目处理完后停止)...', 'info');
});
},
// 加载配置并恢复 UI 状态
loadConfig() {
const cfg = ConfigStore.getAnswerConfig();
this._mode = cfg.mode || 'free';
this._updateModeUI();
// 恢复免费模式状态
if (this.isVerifyValid()) {
const validUntil = cfg.verifyValidUntil;
const validUntilStr = new Date(validUntil * 1000).toLocaleString('zh-CN');
this._updateFreeStatus(true, validUntilStr);
}
// 恢复 AI API Key
const doc = UIPanel._doc;
if (doc && cfg.aiApiKey) {
const keyInput = doc.getElementById('cx-ai-apikey');
if (keyInput) keyInput.value = cfg.aiApiKey;
}
// 同步设置面板中的答题模式
if (doc) {
const settingsRadio = doc.querySelector(`input[name="cx-answer-mode"][value="${this._mode}"]`);
if (settingsRadio) settingsRadio.checked = true;
}
// 如果是付费模式,查询积分
if (this._mode === 'paid') {
this.getCredits();
}
// 定期检查免费模式有效期
setInterval(() => this._checkFreeExpiry(), 60000);
}
};
// ==========================================
// 脚本入口 — 初始化流程 (12.1, 12.2, 12.3)
// ==========================================
/**
* 运行状态管理器 (12.2)
* 负责保存/恢复运行状态,控制自动化任务的开始/停止
*/
const RunStateManager = {
_timerInterval: null,
/** 启动模块并保存运行状态 */
startModule(moduleName) {
const state = {
status: 'running',
activeModule: moduleName,
lastActiveTime: Date.now(),
startTime: Date.now()
};
ConfigStore.setRunState(state);
UIPanel.setRunning(true);
UIPanel.setStatus('运行中', 'success');
},
/** 停止当前模块并清除运行状态(2秒内完成) */
stopModule() {
const state = ConfigStore.getRunState();
if (!state || state.status !== 'running') return;
const moduleName = state.activeModule;
ConfigStore.clearRunState();
// 停止对应模块
if (moduleName === 'video') {
VideoModule.stop();
} else if (moduleName === 'answer') {
AnswerModule.stopAnswering();
}
UIPanel.setRunning(false);
UIPanel.setStatus('已停止', 'info');
},
/** 页面加载后检查是否需要自动恢复 (12.2) */
tryAutoResume() {
const state = ConfigStore.getRunState();
if (!state || state.status !== 'running') return;
const elapsed = Date.now() - (state.lastActiveTime || 0);
// 5分钟内的运行状态才自动恢复
if (elapsed > 5 * 60 * 1000) {
Logger.log('上次运行状态已过期(超过5分钟),不自动恢复', 'info');
ConfigStore.clearRunState();
return;
}
const moduleName = state.activeModule;
Logger.log(`检测到上次运行状态,自动恢复模块: ${moduleName}`, 'info');
if (moduleName === 'video') {
UIPanel.switchTab('video');
// 延迟启动,等待页面完全加载
setTimeout(() => {
VideoModule.start();
this.startModule('video');
}, 1500);
} else if (moduleName === 'answer') {
UIPanel.switchTab('answer');
Logger.log('答题模块需要手动重新启动', 'info');
ConfigStore.clearRunState();
}
},
/** 定期更新 lastActiveTime 保持状态活跃 */
keepAlive() {
const state = ConfigStore.getRunState();
if (state && state.status === 'running') {
state.lastActiveTime = Date.now();
ConfigStore.setRunState(state);
}
}
};
/**
* 脚本初始化 (12.1)
* 页面匹配 → DOM 就绪 → 读取配置 → 注入 UI → 绑定模块 → 恢复运行状态
*/
async function initScript() {
const initStart = Date.now();
try {
// 1. 页面匹配检查
const url = window.location.href;
if (!/chaoxing\.com/i.test(url)) {
return; // 非超星页面,不初始化
}
// 2. 等待 DOM 就绪
if (!document.body) {
await new Promise(resolve => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', resolve, { once: true });
} else {
resolve();
}
});
}
// 3. 读取配置
const seatCfg = ConfigStore.getSeatConfig();
const videoCfg = ConfigStore.getVideoConfig();
const answerCfg = ConfigStore.getAnswerConfig();
Logger.log('学习通刷课助手已加载', 'success');
// 4. 注入 UI 面板
UIPanel.init();
// 5. 绑定所有模块事件并加载配置
SeatModule.bindEvents();
SeatModule.loadConfig();
VideoModule.bindEvents();
AnswerModule.bindEvents();
AnswerModule.loadConfig();
// 加载视频配置到设置面板
const doc = UIPanel._doc;
if (doc) {
const rateSelect = doc.getElementById('cx-cfg-playback-rate');
if (rateSelect) rateSelect.value = String(videoCfg.playbackRate || 2);
const autoAdvance = doc.getElementById('cx-cfg-auto-advance');
if (autoAdvance) autoAdvance.checked = videoCfg.autoAdvance !== false;
const autoSkipQuiz = doc.getElementById('cx-cfg-auto-skip-quiz');
if (autoSkipQuiz) autoSkipQuiz.checked = videoCfg.autoSkipQuiz !== false;
// 同步内联视频设置控件
const rateInline = doc.getElementById('cx-video-rate-inline');
if (rateInline) rateInline.value = String(videoCfg.playbackRate || 2);
const advanceInline = doc.getElementById('cx-video-advance-inline');
if (advanceInline) advanceInline.checked = videoCfg.autoAdvance !== false;
const skipQuizInline = doc.getElementById('cx-video-skip-quiz-inline');
if (skipQuizInline) skipQuizInline.checked = videoCfg.autoSkipQuiz !== false;
}
// 6. 增强视频模块的 start/stop 以集成运行状态管理 (12.2)
const _origVideoStart = VideoModule.start.bind(VideoModule);
const _origVideoStop = VideoModule.stop.bind(VideoModule);
VideoModule.start = function() {
_origVideoStart();
if (VideoModule._playing) {
RunStateManager.startModule('video');
}
};
VideoModule.stop = function() {
_origVideoStop();
ConfigStore.clearRunState();
};
// 增强答题模块的 startHomework/startExam/stopAnswering
const _origStartHomework = AnswerModule.startHomework.bind(AnswerModule);
const _origStartExam = AnswerModule.startExam.bind(AnswerModule);
const _origStopAnswering = AnswerModule.stopAnswering.bind(AnswerModule);
AnswerModule.startHomework = async function() {
await _origStartHomework();
if (AnswerModule._answering) {
RunStateManager.startModule('answer');
}
};
AnswerModule.startExam = async function() {
await _origStartExam();
if (AnswerModule._answering) {
RunStateManager.startModule('answer');
}
};
AnswerModule.stopAnswering = function() {
_origStopAnswering();
ConfigStore.clearRunState();
UIPanel.setRunning(false);
UIPanel.setStatus('已停止', 'info');
};
// 7. 定期更新运行状态的 lastActiveTime(每30秒)
setInterval(() => RunStateManager.keepAlive(), 30000);
// 8. 检查保存的运行状态,决定是否自动恢复
RunStateManager.tryAutoResume();
const elapsed = Date.now() - initStart;
Logger.log(`初始化完成,耗时 ${elapsed}ms`, 'success');
} catch (err) {
console.error('[学习通刷课助手] 初始化错误:', err);
Logger.log('初始化失败: ' + (err.message || String(err)), 'error');
}
}
// 启动初始化
initScript();
})();