// ==UserScript==
// @name 超星小助手
// @namespace https://ayu.local/cx-h5p
// @version 5.14.2
// @description 超星/学习通学习辅助 | 自动播放视频、任务点自动切换、AI 搜题答题、本地题库、视频工具(倍速/截图/下载/画中画/全屏)。永久免费·无使用限制。
// @license MIT
// @author 阿宇
// @match *://*.chaoxing.com/*
// @match *://chaoxing.com/*
// @match *://*.xuexitong.com/*
// @match *://xuexitong.com/*
// @connect bing.com
// @connect baidu.com
// @connect www.bing.com
// @connect www.baidu.com
// @connect api.deepseek.com
// @grant unsafeWindow
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_getTab
// @grant GM_saveTab
// @grant GM_getTabs
// @grant GM_openInTab
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @run-at document-start
// ==/UserScript==
/*
* 版权与来源说明
* - 本脚本全部代码均为自有实现,未包含第三方脚本代码。
* - 视频工具(倍速/截图/下载/画中画/全屏)、题库匹配、章节测验答题、任务点识别、搜题、面板等逻辑均为自有实现。
* - 以 MIT 许可证发布。免费使用,允许修改与再分发,需保留本版权说明。
* - 免责声明:本脚本仅供个人学习交流使用,使用风险自负,请遵守所在平台的用户协议。
*/
(function () {
// ========== 视频工具(自研:截图 / 下载 / 画中画 / 全屏) ==========
// 倍速逻辑在下方「倍速与强制守护」。本模块不依赖任何第三方脚本。
// 选出当前最值得操作的视频:优先正在播放、其次画面面积最大
function pickActiveVideo() {
const list = [];
for (const v of getAllVideos()) {
let r;
try { r = v.getBoundingClientRect(); } catch (e) { continue; }
if (!r || r.width < 40 || r.height < 40) continue;
const src = v.currentSrc || v.src || '';
if (!src && !v.videoWidth) continue;
list.push({ v, area: r.width * r.height, playing: !v.paused && !v.ended });
}
if (!list.length) return null;
list.sort((a, b) => (b.playing - a.playing) || (b.area - a.area));
return list[0].v;
}
function videoFileName(v, ext) {
let name = '';
try {
const src = v.currentSrc || v.src || '';
if (src && !/^(blob|data):/i.test(src)) {
const u = new URL(src, location.href);
name = decodeURIComponent((u.pathname.split('/').pop() || '')).replace(/\.[^.]+$/, '');
}
} catch (e) {}
if (!name) {
name = String(document.title || 'video').replace(/[-_|].*$/, '').trim().slice(0, 40) || 'video';
}
name = name.replace(/[\\/:*?"<>|\s]+/g, '_');
return `${name}_${Date.now()}.${ext}`;
}
function saveBlob(blob, filename) {
let url = '';
try {
url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
return true;
} catch (e) {
return false;
} finally {
if (url) setTimeout(() => {
try { URL.revokeObjectURL(url); } catch (e) {}
}, 5000);
}
}
// 截图:canvas 取当前帧存 PNG
function shotVideo(v) {
v = v || pickActiveVideo();
if (!v) { setStatus('截图:未找到可截取的视频', 'warn'); return; }
const w = v.videoWidth, h = v.videoHeight;
if (!w || !h) { setStatus('截图:画面还没加载完成', 'warn'); return; }
try {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
c.getContext('2d').drawImage(v, 0, 0, w, h);
const finish = (blob) => {
if (!blob) { setStatus('截图:生成图片失败', 'err'); return; }
const ok = saveBlob(blob, videoFileName(v, 'png'));
setStatus(ok ? '截图已保存' : '截图保存失败', ok ? 'ok' : 'err');
};
if (typeof c.toBlob === 'function') {
c.toBlob(finish, 'image/png');
} else {
const b64 = c.toDataURL('image/png').split(',')[1];
const bin = atob(b64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
finish(new Blob([buf], { type: 'image/png' }));
}
} catch (e) {
setStatus('截图失败:该视频源不允许截取画面', 'err');
}
}
// 下载:直链视频直接取 Blob 保存;m3u8 分片流提示不支持
function downloadVideo(v) {
v = v || pickActiveVideo();
if (!v) { setStatus('下载:未找到视频', 'warn'); return; }
const src = v.currentSrc || v.src || '';
if (!src) { setStatus('下载:没拿到视频地址', 'warn'); return; }
if (/\.m3u8(\?|#|$)/i.test(src) || /mpegurl/i.test(v.type || '')) {
setStatus('下载:该视频是 m3u8 分片流,暂不支持直接下载', 'warn');
return;
}
const name = videoFileName(v, 'mp4');
const done = (blob) => {
if (!blob || !blob.size) { setStatus('下载:获取视频失败(可能是防盗链)', 'err'); return; }
setStatus(saveBlob(blob, name) ? `下载中:${(blob.size / 1048576).toFixed(1)}MB 已保存` : '下载保存失败', 'ok');
};
const fail = () => setStatus('下载:获取视频失败(可能是防盗链)', 'err');
try {
if (typeof GM_xmlhttpRequest === 'function' && /^https?:/i.test(src)) {
setStatus('下载:正在获取视频…', 'warn');
GM_xmlhttpRequest({
method: 'GET',
url: src,
responseType: 'blob',
timeout: 120000,
onprogress: (e) => {
if (e && e.lengthComputable && e.total) {
setStatus(`下载:${Math.round(e.loaded / e.total * 100)}%`, 'warn');
}
},
onload: (r) => {
if (r.status >= 200 && r.status < 400 && r.response) done(r.response);
else fail();
},
onerror: fail,
ontimeout: fail
});
return;
}
setStatus('下载:正在获取视频…', 'warn');
fetch(src, { credentials: 'include' })
.then(r => r.ok ? r.blob() : Promise.reject(new Error('http ' + r.status)))
.then(done)
.catch(fail);
} catch (e) {
fail();
}
}
// 画中画:进入 / 退出
function togglePiP(v) {
v = v || pickActiveVideo();
if (!v) { setStatus('画中画:未找到视频', 'warn'); return; }
if (!document.pictureInPictureEnabled || typeof v.requestPictureInPicture !== 'function') {
setStatus('画中画:当前浏览器不支持', 'err');
return;
}
if (document.pictureInPictureElement) {
document.exitPictureInPicture()
.then(() => setStatus('已退出画中画', 'ok'))
.catch(() => setStatus('退出画中画失败', 'err'));
return;
}
v.requestPictureInPicture()
.then(() => setStatus('已进入画中画', 'ok'))
.catch((e) => setStatus('画中画不可用:' + ((e && e.message) || '需要先点一下视频'), 'err'));
}
// 全屏:优先全屏播放器容器,其次 video 本身
function toggleFullscreen(v) {
v = v || pickActiveVideo();
if (!v) { setStatus('全屏:未找到视频', 'warn'); return; }
let el = v;
try {
el = v.closest('[class*="video"],[class*="player"],.vjs-player,.xgplayer,.prism-player') ||
v.parentElement || v;
} catch (e) {}
try {
if (document.fullscreenElement || document.webkitFullscreenElement) {
(document.exitFullscreen || document.webkitExitFullscreen || function () {}).call(document);
setStatus('已退出全屏', 'ok');
return;
}
const req = el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen;
if (!req) { setStatus('全屏:当前浏览器不支持', 'err'); return; }
const r = req.call(el);
if (r && r.then) r.then(() => setStatus('已进入全屏', 'ok')).catch(() => setStatus('全屏失败(可能需要先点一下页面)', 'err'));
else setStatus('已进入全屏', 'ok');
} catch (e) {
setStatus('全屏失败:' + ((e && e.message) || '浏览器限制'), 'err');
}
}
// 快捷键:Alt+S / Alt+D / Alt+P / Alt+F
try {
document.addEventListener('keydown', (e) => {
if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
const t = e.target;
if (t && (/^(input|textarea|select)$/i.test(t.tagName) || t.isContentEditable)) return;
const key = String(e.key || '').toLowerCase();
const fn = { s: shotVideo, d: downloadVideo, p: togglePiP, f: toggleFullscreen }[key];
if (!fn) return;
e.preventDefault();
e.stopPropagation();
fn();
}, true);
} catch (e) {}
// ========== 倍速与强制守护(自研,不依赖第三方脚本) ==========
let currentRate = 1;
let forceAutoRate = false;
// 开机第一时间抓取"原生" playbackRate 存取器(防止被页面脚本改写后拿不到)
let NATIVE_RATE_GET = null, NATIVE_RATE_SET = null;
try {
const nd = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate');
if (nd && nd.get && nd.set) { NATIVE_RATE_GET = nd.get; NATIVE_RATE_SET = nd.set; }
} catch (e) {}
// 递归获取所有 video(含同源 iframe)
function getAllVideos() {
const vids = [];
function collect(doc, depth) {
if (depth > 6) return;
try {
doc.querySelectorAll('video').forEach(v => vids.push(v));
doc.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) collect(f.contentDocument, depth + 1); } catch (e) {}
});
} catch (e) {}
}
collect(document, 0);
return vids;
}
// 强制设置单个 video 的倍速(原生 setter + 实例锁定,平台改不回去)
function forceVideoRate(v, rate) {
try {
// 1. 用原生 setter 直接改(绕过页面自定义的 setter)
if (NATIVE_RATE_SET) {
try { NATIVE_RATE_SET.call(v, rate); } catch (e) {}
} else {
v.playbackRate = rate;
}
try { if (v.defaultPlaybackRate !== rate) v.defaultPlaybackRate = rate; } catch (e) {}
// 2. 在实例上锁定 playbackRate:平台再赋值也保持目标倍速
if (v.__cxLockRate !== rate) {
v.__cxLockRate = rate;
try {
Object.defineProperty(v, 'playbackRate', {
configurable: true,
enumerable: true,
get() {
try { return NATIVE_RATE_GET ? NATIVE_RATE_GET.call(v) : rate; } catch (e) { return rate; }
},
set() { /* 忽略平台的修改,倍速保持锁定 */ }
});
} catch (e) {}
}
// 3. 最后兜底:万一仍被改回去,再设一次
if (Math.abs(v.playbackRate - rate) > 0.01 && NATIVE_RATE_SET) {
try { NATIVE_RATE_SET.call(v, rate); } catch (e) {}
}
} catch (e) {}
}
// 解除锁定(停止挂机时调用,恢复正常可控)
function unlockVideos() {
for (const v of getAllVideos()) {
try { delete v.playbackRate; } catch (e) {}
try { delete v.__cxLockRate; } catch (e) {}
}
}
// 只处理当前窗口的 video(跨域 iframe 里由各自脚本实例处理)
function applyRateLocal(rate) {
try { document.querySelectorAll('video').forEach(v => forceVideoRate(v, rate)); } catch (e) {}
}
// 向所有子 frame 广播倍速(跨域 iframe 也能收到)
function broadcastRate(rate) {
const send = (win) => {
try { win.postMessage({ __cxRate: rate }, '*'); } catch (e) {}
let frames, len = 0;
try { frames = win.frames; len = frames.length; } catch (e) { return; }
for (let i = 0; i < len; i++) {
try { send(frames[i]); } catch (e) {}
}
};
try { send(window); } catch (e) {}
}
// 每个窗口(含跨域 iframe)都监听倍速广播并本地强制
let frameRate = 0; // 由上级窗口广播过来的目标倍速(仅 iframe 使用)
try {
window.addEventListener('message', (e) => {
if (e.source === window) return; // 忽略自己发出的广播
const d = e.data;
if (!d || typeof d !== 'object' || typeof d.__cxRate !== 'number') return;
if (d.__cxRate < 0) { frameRate = 0; return; } // 停止挂机,解除强制
frameRate = d.__cxRate;
applyRateLocal(d.__cxRate);
});
} catch (e) {}
// 平台若在本窗口把倍速改回去,立即再强制一次(ratechange 实时纠偏)
try {
document.addEventListener('ratechange', (e) => {
const v = e.target;
if (!v || v.tagName !== 'VIDEO') return;
const target = forceAutoRate ? currentRate : frameRate;
if (!target) return;
if (Math.abs(v.playbackRate - target) > 0.01) {
setTimeout(() => forceVideoRate(v, target), 50);
}
}, true);
} catch (e) {}
function setRate(rate) {
currentRate = rate;
// 本窗口 + 同源 iframe 的 video
applyRateLocal(rate);
for (const v of getAllVideos()) forceVideoRate(v, rate);
// 通知所有 iframe(含跨域)各自强制
broadcastRate(rate);
}
// 独立的高频倍速守护(不依赖 tick;电脑端播放器会频繁把倍速改回 1x)
let rateTimer = null;
function startRateKeeper() {
if (rateTimer) return;
rateTimer = setInterval(() => {
if (!forceAutoRate) return;
applyRateLocal(currentRate);
for (const v of getAllVideos()) forceVideoRate(v, currentRate);
broadcastRate(currentRate);
}, 500);
}
function stopRateKeeper() {
if (rateTimer) { clearInterval(rateTimer); rateTimer = null; }
}
// 定时确保倍速不被重置(顶层 + 跨域 iframe 广播)
function ensureRate() {
if (!forceAutoRate) return;
setRate(currentRate);
}
function initPanel() {
// 只在顶层窗口创建面板,避免 iframe 重复创建
if (window.top !== window.self) return;
if (window.__cxPanel) return;
window.__cxPanel = true;
const host = document.createElement('div');
host.style.cssText = 'position:fixed;top:16px;right:16px;z-index:2147483647;';
document.documentElement.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
window.__shadow = shadow; // 暴露给题库弹窗等功能使用
const style = document.createElement('style');
style.textContent = `
.box{width:200px;font:13px/1.5 system-ui,"Microsoft YaHei",sans-serif;
color:#e8eaed;background:#1f2430;border:1px solid #39415a;
border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.35);overflow:hidden}
.hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 12px;background:#2a3247;cursor:move;user-select:none;
-webkit-user-select:none;touch-action:none}
.hd b{font-size:13px;font-weight:600}
.hd .fold{cursor:pointer;opacity:.7;padding:0 6px;font-size:16px}
.bd{padding:10px 12px}
.row{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
.btn{flex:1;padding:9px 0;border:0;border-radius:6px;cursor:pointer;
font-size:13px;font-weight:600;color:#fff;background:#3d6bf5}
.btn.off{background:#59627a}
select{padding:6px 8px;border-radius:6px;border:1px solid #39415a;
background:#1a1e28;color:#e8eaed;font-size:13px;cursor:pointer}
.tip{color:#9aa3b8;font-size:12px}
.ok{color:#4ade80}
.warn{color:#fbbf24}
.tools{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:10px}
.tbtn{padding:7px 0;border:0;border-radius:6px;cursor:pointer;
font-size:12px;color:#e8eaed;background:#2a3247;border:1px solid #39415a}
.tbtn:active{background:#3d6bf5}
.linkbtn{display:block;text-align:center;padding:8px 0;border-radius:6px;
font-size:13px;color:#93c5fd;background:#1a1e28;border:1px solid #39415a;
text-decoration:none}
.linkbtn:active{background:#3d6bf5;color:#fff}
.mbtn{flex:1;padding:6px 0;border:1px solid #39415a;border-radius:6px;
font-size:12px;color:#9aa3b8;background:#1a1e28;cursor:pointer}
.mbtn.on{color:#fff;background:#3d6bf5;border-color:#3d6bf5}
.gbtn{padding:4px 10px;border:0;border-radius:6px;font-size:12px;
color:#fff;background:#3d6bf5;cursor:pointer;white-space:nowrap}
.gbtn:active{background:#2d5be5}
.sec{margin-bottom:8px;border:1px solid #39415a;border-radius:8px;background:#1a1e28;overflow:hidden}
.sec summary{display:flex;align-items:center;padding:7px 10px;cursor:pointer;
font-size:12px;color:#cbd5e1;list-style:none;user-select:none;-webkit-user-select:none}
.sec summary::-webkit-details-marker{display:none}
.sec summary>span:first-child{flex:1}
.sec summary::after{content:'▾';color:#7c869e;font-size:10px;margin-left:8px}
.sec[open] summary::after{content:'▴'}
.secbody{padding:8px 10px 0;border-top:1px solid #2a3247}
.badge{font-size:11px;color:#93c5fd;background:#232a3a;border-radius:10px;padding:1px 8px}
.orow{display:flex;align-items:center;margin-bottom:8px}
.orow input{margin-right:6px}
#dsRow{display:flex;gap:6px;align-items:center}
.banklink{color:#93c5fd;text-decoration:none;cursor:pointer}
.banklink:active{color:#bfdbfe}
.bankmask{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:2147483646;
display:flex;align-items:center;justify-content:center;padding:20px}
.bankbox{background:#1e2330;border-radius:12px;padding:16px;width:100%;max-width:420px;
max-height:80vh;display:flex;flex-direction:column}
.bankhd{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.bankhd b{color:#fff;font-size:15px}
.bankclose{color:#9aa3b8;font-size:20px;cursor:pointer;background:none;border:0;padding:0 6px}
.banktools{display:flex;gap:6px;margin-left:auto;margin-right:8px}
.banktools button{padding:3px 8px;background:none;border:1px solid #3b4a6b;color:#93c5fd;
border-radius:5px;font-size:12px;cursor:pointer}
.banklist{overflow-y:auto;flex:1}
.bankitem{padding:10px 0;border-bottom:1px solid #2a3247;font-size:13px;color:#e8eaed}
.bankitem .bq{color:#cbd5e1;margin-bottom:4px;word-break:break-all}
.bankitem .ba{color:#4ade80;font-weight:bold}
.bankempty{color:#9aa3b8;text-align:center;padding:30px 0}
.bankclear{margin-top:10px;padding:8px;border:1px solid #ef4444;border-radius:6px;
background:none;color:#ef4444;font-size:12px;cursor:pointer}
.bankclear:active{background:#ef4444;color:#fff}
.ctxm{position:fixed;left:0;top:0;z-index:2147483647;display:none;min-width:158px;
background:#1f2430;border:1px solid #39415a;border-radius:8px;padding:4px;
box-shadow:0 8px 28px rgba(0,0,0,.5);color:#e8eaed;font-size:13px}
.ctxm.show{display:block}
.ctxm .ti{font-size:11px;opacity:.5;padding:2px 10px 4px}
.ctxm .it{padding:8px 10px;border-radius:6px;cursor:pointer;white-space:nowrap}
.ctxm .it:hover{background:#2b3346}
.ctxm .sep{height:1px;background:#39415a;margin:4px 2px}
`;
shadow.appendChild(style);
const box = document.createElement('div');
box.className = 'box';
box.innerHTML = `
超星小助手v5.14.2—
倍速
任务点:识别中…
状态:待启动
进度:-
答题设置跳过答题
题库0 题
其他功能工具
✓ 永久免费 · 无使用限制
仅供学习交流 · 使用风险自负
`;
shadow.appendChild(box);
const $fold = box.querySelector('.fold');
const $body = box.querySelector('.bd');
const $hang = box.querySelector('[data-act=hang]');
const $rate = box.querySelector('.rate');
const $mute = box.querySelector('.mute');
const $skip = box.querySelector('.skip');
const $stat = box.querySelector('.stat');
const $tasktype = box.querySelector('.tasktype');
const $prog = box.querySelector('.prog');
const $banksize = box.querySelector('.banksize');
const $banklink = box.querySelector('.banklink');
const $ai = box.querySelector('.ai');
const $aiModeRow = box.querySelector('#aiModeRow');
const $dsRow = box.querySelector('#dsRow');
const $dsTip = box.querySelector('#dsTip');
const $dskey = box.querySelector('.dskey');
const $getkeyBtn = box.querySelector('[data-act=getkey]');
const $modeBtns = box.querySelectorAll('.mbtn');
// 初始化题库题数显示
try { $banksize.textContent = getBankSize(); } catch (e) {}
// 倍速选项(新存储键,默认 1x,忽略旧版本保存的值)
let savedRate = 1;
try { savedRate = parseInt(GM_getValue('cx_rate2', '1')) || 1; } catch (e) {}
for (let r = 1; r <= 16; r++) {
const o = document.createElement('option');
o.value = String(r);
o.textContent = r + 'x';
if (r === savedRate) o.selected = true;
$rate.appendChild(o);
}
currentRate = savedRate;
$fold.addEventListener('click', () => {
const hidden = $body.style.display === 'none';
$body.style.display = hidden ? '' : 'none';
$fold.textContent = hidden ? '—' : '+';
});
$rate.addEventListener('change', () => {
currentRate = parseFloat($rate.value) || 1;
try { GM_setValue('cx_rate2', String(currentRate)); } catch (e) {}
forceAutoRate = true; // 选了倍速就持续保持
setRate(currentRate); // 选倍速后立即生效,不管是否挂机
startRateKeeper(); // 开启高频守护,电脑端也不被改回 1x
});
$hang.addEventListener('click', () => {
if (running) stopHang();
else startHang();
});
// 加载已保存的搜题模式(key 或 search)
let aiMode = 'key';
try { aiMode = GM_getValue('ai_mode', 'key') || 'key'; } catch (e) {}
// 切换模式按钮高亮
function setAiMode(mode) {
aiMode = mode;
try { GM_setValue('ai_mode', mode); } catch (e) {}
$modeBtns.forEach(b => {
b.classList.toggle('on', b.dataset.mode === mode);
});
// 选 Key 才显示输入框和提示
const showDs = mode === 'key';
$dsRow.style.display = showDs ? '' : 'none';
$dsTip.style.display = showDs ? '' : 'none';
}
// AI 开关:打开时显示模式选择行;两个答题模式互斥,开 AI 就关掉"跳过答题"
$ai.addEventListener('change', () => {
if ($ai.checked) {
$skip.checked = false;
try { GM_setValue('cx_quiz_mode', 'ai'); } catch (e) {}
} else {
try { GM_setValue('cx_quiz_mode', ''); } catch (e) {}
}
$aiModeRow.style.display = $ai.checked ? '' : 'none';
if ($ai.checked) setAiMode(aiMode);
else { $dsRow.style.display = 'none'; $dsTip.style.display = 'none'; }
try { savePrefs(); } catch (e) {}
try { updateQmodeBadge(); } catch (e) {}
});
// "跳过答题":勾上就取消 AI搜题答题(互斥)
$skip.addEventListener('change', () => {
if ($skip.checked) {
$ai.checked = false;
$aiModeRow.style.display = 'none';
$dsRow.style.display = 'none';
$dsTip.style.display = 'none';
try { GM_setValue('cx_quiz_mode', 'skip'); } catch (e) {}
} else {
try { GM_setValue('cx_quiz_mode', ''); } catch (e) {}
}
try { savePrefs(); } catch (e) {}
try { updateQmodeBadge(); } catch (e) {}
});
// 模式按钮点击
$modeBtns.forEach(b => {
b.addEventListener('click', () => setAiMode(b.dataset.mode));
});
// 初始化模式显示
setAiMode(aiMode);
// ========== 复选框状态持久化(刷新不丢) ==========
const PREFS_KEY = 'cx_prefs';
const $checkboxes = box.querySelectorAll('input[type="checkbox"]');
// 加载已保存的勾选状态
try {
const prefs = JSON.parse(GM_getValue(PREFS_KEY, '{}') || '{}');
$checkboxes.forEach(cb => {
const cls = cb.className;
if (prefs.hasOwnProperty(cls)) cb.checked = prefs[cls];
});
} catch (e) {}
// 勾选变化时自动保存
function savePrefs() {
try {
const prefs = {};
$checkboxes.forEach(cb => { prefs[cb.className] = cb.checked; });
GM_setValue(PREFS_KEY, JSON.stringify(prefs));
} catch (e) {}
}
$checkboxes.forEach(cb => cb.addEventListener('change', savePrefs));
// 折叠区徽标:随时显示当前答题模式
const $qmodeBadge = box.querySelector('.qmode');
function updateQmodeBadge() {
if (!$qmodeBadge) return;
$qmodeBadge.textContent = $ai.checked ? 'AI搜题' : ($skip.checked ? '跳过答题' : '未开启');
}
updateQmodeBadge();
// 旧版本可能两个答题模式都勾着:只保留一个(按上次选择的模式,没记录则优先 AI搜题答题)
if ($skip.checked && $ai.checked) {
let savedMode = '';
try { savedMode = GM_getValue('cx_quiz_mode', '') || ''; } catch (e) {}
if (savedMode === 'skip') $ai.checked = false; else $skip.checked = false;
savePrefs();
}
// AI 开关恢复时同步显示模式行
$aiModeRow.style.display = $ai.checked ? '' : 'none';
if ($ai.checked) setAiMode(aiMode); else { $dsRow.style.display = 'none'; $dsTip.style.display = 'none'; }
updateQmodeBadge();
// 加载已保存的 DeepSeek Key(刷新不丢)
try {
const savedKey = GM_getValue('ds_api_key', '');
if (savedKey) $dskey.value = savedKey;
} catch (e) {}
// 多重事件兜底保存:input / change / blur / paste,确保手机端粘贴也能记住
function saveDsKey() {
try { GM_setValue('ds_api_key', $dskey.value.trim()); } catch (e) {}
}
$dskey.addEventListener('input', saveDsKey);
$dskey.addEventListener('change', saveDsKey);
$dskey.addEventListener('blur', saveDsKey);
$dskey.addEventListener('paste', () => setTimeout(saveDsKey, 100));
// 获取Key按钮:一键直接跳转到 API Keys 创建页
// 用动态 标签点击,浏览器原生行为,不会被拦截
$getkeyBtn.addEventListener('click', () => {
const url = 'https://platform.deepseek.com/api_keys';
try {
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch (e) {
location.href = url;
}
});
// 点击题库链接:显示全部已保存题目
$banklink.addEventListener('click', () => showBankDetail());
// ========== 保存设置 / 测试 AI / 题库导入导出 ==========
const $bankfile = box.querySelector('.bankfile');
const $savecfgBtn = box.querySelector('[data-act=savecfg]');
const $testaiBtn = box.querySelector('[data-act=testai]');
const $importbankBtn = box.querySelector('[data-act=importbank]');
const $exportbankBtn = box.querySelector('[data-act=exportbank]');
// 保存设置:把当前所有勾选、倍速、搜题模式、Key 一次性落盘
$savecfgBtn.addEventListener('click', () => {
try {
savePrefs();
saveDsKey();
GM_setValue('cx_rate2', String(currentRate));
GM_setValue('ai_mode', aiMode);
GM_setValue('cx_quiz_mode', $ai.checked ? 'ai' : ($skip.checked ? 'skip' : ''));
} catch (e) {}
setStatus('设置已保存(刷新不会丢)', 'ok');
});
// 测试 AI:Key 模式测 DeepSeek,搜索引擎模式测 Bing 连通性
$testaiBtn.addEventListener('click', async () => {
if (aiMode === 'key') {
const key = ($dskey.value || '').trim();
if (!key) { setStatus('请先填写 DeepSeek Key', 'err'); return; }
setStatus('测试 DeepSeek 中…');
try {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://api.deepseek.com/v1/chat/completions',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + key },
data: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: '回复:OK' }],
temperature: 0,
max_tokens: 5
}),
timeout: 20000,
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText || '{}');
const c = data.choices && data.choices[0] && data.choices[0].message
? data.choices[0].message.content : '';
if (resp.status === 200 && c) setStatus('AI 可用:' + String(c).trim().slice(0, 20), 'ok');
else if (resp.status === 401) setStatus('AI 测试失败:Key 无效(401)', 'err');
else if (resp.status === 402 || resp.status === 403) setStatus('AI 测试失败:余额不足或无权限', 'err');
else setStatus('AI 测试失败:HTTP ' + resp.status, 'err');
} catch (e) { setStatus('AI 测试失败:返回无法解析', 'err'); }
},
onerror: () => setStatus('AI 测试失败:网络错误', 'err'),
ontimeout: () => setStatus('AI 测试失败:请求超时', 'err')
});
} catch (e) {
setStatus('AI 测试失败:请求发不出去', 'err');
}
} else {
setStatus('测试搜索引擎中…');
try {
const txt = await fetchSearchText('https://www.bing.com/search?q=' + encodeURIComponent('学习通'));
if (txt && txt.length > 300) setStatus('搜索引擎可用(Bing)', 'ok');
else setStatus('Bing 请求失败或被拦截,可换 Key 模式', 'err');
} catch (e) {
setStatus('搜索引擎测试失败', 'err');
}
}
});
// 导入题库
$importbankBtn.addEventListener('click', () => $bankfile.click());
$bankfile.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const n = importBankJson(String(reader.result || ''));
try { $banksize.textContent = getBankSize(); } catch (e2) {}
if (n > 0) setStatus(`题库导入 ${n} 题,共 ${getBankSize()} 题`, 'ok');
else setStatus('题库导入失败:不是合法 JSON', 'err');
};
reader.readAsText(file, 'utf-8');
e.target.value = '';
});
// 导出题库
$exportbankBtn.addEventListener('click', () => {
if (getBankSize() === 0) { setStatus('题库为空,无可导出内容'); return; }
exportBankJson();
});
// ========== 视频工具按钮(截图 / 下载 / 画中画 / 全屏) ==========
const $shotBtn = box.querySelector('[data-act=shot]');
const $dlBtn = box.querySelector('[data-act=dl]');
const $pipBtn = box.querySelector('[data-act=pip]');
const $fsBtn = box.querySelector('[data-act=fs]');
if ($shotBtn) $shotBtn.addEventListener('click', () => shotVideo());
if ($dlBtn) $dlBtn.addEventListener('click', () => downloadVideo());
if ($pipBtn) $pipBtn.addEventListener('click', () => togglePiP());
if ($fsBtn) $fsBtn.addEventListener('click', () => toggleFullscreen());
// ========== 鼠标右键快捷菜单(在鼠标位置弹出学习通入口) ==========
const $ctxmenu = box.querySelector('.ctxmenu');
const $ctx = document.createElement('div');
$ctx.className = 'ctxm';
shadow.appendChild($ctx);
let ctxOpen = false;
const CX_LINKS = [
{ t: '📖 超星首页', u: 'https://www.chaoxing.com/' },
{ t: '📚 我的课程', u: 'https://i.chaoxing.com/' },
{ t: '🎬 开始学习', u: 'https://mooc1.chaoxing.com/' },
{ t: '🔑 获取 DeepSeek Key', u: 'https://platform.deepseek.com/api_keys' }
];
function openUrl(url) {
try {
const a = document.createElement('a');
a.href = url; a.target = '_blank'; a.rel = 'noopener';
a.style.display = 'none';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
} catch (e) { location.href = url; }
}
function hideCtx() { $ctx.classList.remove('show'); ctxOpen = false; }
function showCtx(x, y) {
$ctx.innerHTML = '超星快捷入口
' +
CX_LINKS.map((l, i) => `${l.t}
`).join('') +
'✕ 关闭菜单
';
$ctx.classList.add('show');
const r = $ctx.getBoundingClientRect();
const px = Math.max(8, Math.min(x, window.innerWidth - r.width - 8));
const py = Math.max(8, Math.min(y, window.innerHeight - r.height - 8));
$ctx.style.left = px + 'px';
$ctx.style.top = py + 'px';
ctxOpen = true;
}
$ctx.addEventListener('click', (e) => {
const it = e.target.closest('.it');
if (!it) return;
const i = parseInt(it.dataset.i, 10);
hideCtx();
if (i >= 0 && CX_LINKS[i]) openUrl(CX_LINKS[i].u);
});
document.addEventListener('contextmenu', (e) => {
if (!$ctxmenu.checked) return;
const t = e.target;
const tag = (t && t.tagName || '').toLowerCase();
// 输入框、可编辑区、已选中文字时不劫持,保留原生菜单
if (tag === 'input' || tag === 'textarea' || (t && t.isContentEditable)) return;
if (window.getSelection && String(window.getSelection()).length > 0) return;
e.preventDefault();
showCtx(e.clientX, e.clientY);
}, true);
document.addEventListener('mousedown', (e) => {
if (!ctxOpen) return;
const path = e.composedPath ? e.composedPath() : [];
if (path.includes($ctx)) return;
hideCtx();
}, true);
window.addEventListener('scroll', hideCtx, true);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideCtx(); });
// 拖动
let sx=0, sy=0, ox=0, oy=0, on=false;
host.style.touchAction = 'none';
host.addEventListener('pointerdown', (e) => {
const path = e.composedPath ? e.composedPath() : [];
for (const el of path) {
if (el === host) break;
if (el.nodeType !== 1) continue;
if (el.matches && el.matches('button,select,input,label,summary,a')) return;
}
on = true; sx = e.clientX; sy = e.clientY;
const r = host.getBoundingClientRect();
ox = r.left; oy = r.top;
host.style.right = 'auto'; host.style.left = ox + 'px';
});
document.addEventListener('pointermove', (e) => {
if (!on) return;
let nx = ox + (e.clientX - sx);
let ny = oy + (e.clientY - sy);
nx = Math.max(0, Math.min(window.innerWidth - host.offsetWidth, nx));
ny = Math.max(0, Math.min(window.innerHeight - host.offsetHeight, ny));
host.style.left = nx + 'px';
host.style.top = ny + 'px';
});
document.addEventListener('pointerup', () => { on = false; });
document.addEventListener('pointercancel', () => { on = false; });
// 页面初始渲染完成后应用一次倍速
setTimeout(() => { setRate(currentRate); }, 0);
window.__ui = { $stat, $prog, $hang, $banksize, $tasktype };
// 页面跳转(切任务点/换章节)后自动恢复挂机,5 分钟内有效
try {
const saved = JSON.parse(GM_getValue('cx_running', '') || '{}');
if (isChaoxing && saved && saved.on && Date.now() - (saved.t || 0) < 5 * 60 * 1000) {
setTimeout(() => { if (!running) startHang(); }, 1200);
}
} catch (e) {}
}
function setStatus(text, cls) {
if (!window.__ui) return;
window.__ui.$stat.textContent = text;
window.__ui.$stat.className = 'stat' + (cls ? ' ' + cls : '');
}
function setProg(text) {
if (!window.__ui) return;
window.__ui.$prog.textContent = text;
}
function setTaskType(text) {
if (!window.__ui || !window.__ui.$tasktype) return;
if (window.__ui.$tasktype.textContent === text) return;
window.__ui.$tasktype.textContent = text;
}
// ========== 学习通自动刷课逻辑 ==========
const isChaoxing = /chaoxing\.com|xuexitong\.com|xueyinonline\.com|mooc[1-9]?\.chaoxing\.com/i.test(location.href);
let running = false;
let tickTimer = null;
let quizCooldown = 0;
let lastNextClickTime = 0; // 上次点"下一节"的时间戳
const NEXT_CLICK_INTERVAL = 8000; // 两次点击之间最小间隔(毫秒),AI 答题不受此限制
let scrollCount = 0;
let fallbackCooldown = 0; // 兜底跳转(滚动/目录)的冷却,避免每秒重复执行
function startHang() {
if (running) return;
running = true;
forceAutoRate = true;
try { GM_setValue('cx_running', JSON.stringify({ on: true, t: Date.now() })); } catch (e) {}
if (window.__ui) {
window.__ui.$hang.textContent = '■ 停止挂机';
window.__ui.$hang.classList.remove('off');
}
setRate(currentRate);
startRateKeeper();
setStatus('启动中…');
docScrollState = null;
fontDecryptAttempted = false;
if (tickTimer) clearInterval(tickTimer);
tickTimer = setInterval(tick, 1000);
// 后台保活:静音音频循环 + 切回标签页立即恢复播放
initBackgroundKeepAlive();
if (isChaoxing) tick();
}
// ========== 后台保活 ==========
let keepAliveAudio = null;
function initBackgroundKeepAlive() {
if (keepAliveAudio) return;
try {
// 用一个静音音频循环播放,防止浏览器把标签页过度节流
keepAliveAudio = document.createElement('audio');
keepAliveAudio.loop = true;
keepAliveAudio.muted = true;
keepAliveAudio.volume = 0;
// base64 编码的极短静音 wav
keepAliveAudio.src = 'data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA';
keepAliveAudio.play().catch(() => {});
} catch (e) {}
// 切回标签页时立即恢复播放
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
const videos = collectVideos();
videos.forEach(item => {
try {
if (item.v.paused) item.v.play().catch(() => {});
} catch (e) {}
});
}
});
// 每 2 秒尝试恢复后台暂停的视频(即使标签页不可见)
setInterval(() => {
if (document.querySelector('.keepalive')?.checked === false) return;
const videos = collectVideos();
videos.forEach(item => {
try {
if (item.v.paused && !item.v.ended) {
item.v.muted = true;
item.v.play().catch(() => {});
}
} catch (e) {}
});
}, 2000);
}
function stopHang() {
running = false;
forceAutoRate = false;
try { GM_setValue('cx_running', ''); } catch (e) {}
if (tickTimer) { clearInterval(tickTimer); tickTimer = null; }
stopRateKeeper();
// 通知所有 iframe 解除倍速强制
broadcastRate(-1);
unlockVideos();
if (window.__ui) {
window.__ui.$hang.textContent = '▶ 开始挂机';
window.__ui.$hang.classList.add('off');
}
setStatus('已停止');
}
function collectVideos() {
const vids = [];
function collect(doc, depth) {
if (depth > 5) return;
try {
doc.querySelectorAll('video').forEach(v => {
// 跳过隐藏/未激活的视频(避免处理其它任务点的残留播放器)
try {
const r = v.getBoundingClientRect();
if (r.width < 10 || r.height < 10) return;
} catch (e) {}
vids.push({ v, doc });
});
doc.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) collect(f.contentDocument, depth + 1); } catch (e) {}
});
} catch (e) {}
}
collect(document, 0);
return vids;
}
// 递归收集所有 document(包括 iframe),供跨框架检测使用
function getAllDocs() {
const docs = [];
function collect(d, depth) {
if (depth > 5 || !d) return;
if (docs.indexOf(d) === -1) docs.push(d);
try {
d.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) collect(f.contentDocument, depth + 1); } catch (e) {}
});
} catch (e) {}
}
try { collect(document, 0); } catch (e) {}
return docs;
}
// 找到当前视频所属的任务点容器(.ans-attach-ct / .ans-job-ct)
function getTaskContainerFor(v, doc) {
try {
for (const d of getAllDocs()) {
let iframes;
try { iframes = d.querySelectorAll('iframe'); } catch (e) { continue; }
for (const f of iframes) {
let cd = null;
try { cd = f.contentDocument || (f.contentWindow && f.contentWindow.document); } catch (e) {}
if (cd && cd === doc) {
const box = f.closest('.ans-attach-ct, .ans-job-ct, .ans-attach');
if (box) return box;
}
}
}
} catch (e) {}
try {
const box = v && v.closest && v.closest('.ans-attach-ct, .ans-job-ct, .ans-attach');
if (box) return box;
} catch (e) {}
return null;
}
function isTaskCompleted(doc, v) {
try {
// 1) 只看"当前这个视频"的任务点容器,避免章节里其它任务已完成导致误判
const box = getTaskContainerFor(v, doc);
if (box) {
// 容器自身或上两级祖先带完成标记
let n = box;
for (let i = 0; i < 3 && n; i++, n = n.parentElement) {
const cls = (n.className || '').toString();
if (/ans-job-finished/.test(cls)) return true;
if (/已完成/.test((n.getAttribute && n.getAttribute('aria-label')) || '')) return true;
}
try {
const icon = box.querySelector('.ans-job-icon[aria-label*="已完成"], .ans-job-icon[title*="已完成"]');
if (icon) return true;
} catch (e) {}
const t = (box.textContent || '').replace(/\s+/g, '');
if (/任务点已完成/.test(t)) return true;
return false; // 容器明确存在且未完成
}
// 2) 定位不到容器时,只在视频所在文档内判断,不做全页搜索
if (doc && doc.body) {
const t = (doc.body.innerText || '').replace(/\s+/g, '');
if (/任务点已完成/.test(t)) return true;
}
} catch (e) {}
return false;
}
function isQuizPage() {
// 跨 iframe 判断:有题目特征(题型/单选圈)才算答题页
const docs = getAllDocs();
for (const d of docs) {
if (!d.body) continue;
// 学习通标准测验结构(最可靠)
try {
if (d.querySelector('input[id^="answertype"]') || d.querySelector('.TiMu, .singleQuesId, .Zy_TItle, #form1 #RightCon')) return true;
} catch (e) {}
const text = d.body.innerText || '';
if (/(单选题|多选题|判断题)/.test(text) && /(测验|答题|题目|下列)/.test(text)) return true;
if (/测验/.test(text) &&
d.querySelector('input[type="radio"], input[type="checkbox"], .singleQuesId, .mulQuesId, .judgeQuesId')) {
return true;
}
}
return false;
}
// 判断任务点标签/目录项是否处于"当前选中"状态(只认完整类名词,避免 icon/button 被误判)
function isActiveTaskEl(el, ignoreCursor) {
try {
let n = el;
for (let i = 0; i < 4 && n && n.getAttribute; i++, n = n.parentElement) {
const cls = (n.className || '').toString();
if (/posCatalog_select/i.test(cls)) return true;
if (/(^|\s)_select(\s|$)/.test(cls)) return true;
const tokens = cls.split(/\s+/);
for (const t of tokens) {
if (/^(active|current|selected|cur|curr|checked|focus|on)$/i.test(t)) return true;
}
const style = n.getAttribute('style') || '';
if (!ignoreCursor && /cursor:\s*default/i.test(style)) return true;
if (n.getAttribute('aria-selected') === 'true') return true;
}
} catch (e) {}
return false;
}
// 是否位于右侧目录树(章节列表)里 —— 目录里的"07 直播/08 阅读"是章节名,不是任务点
function inCatalogTree(el) {
try {
let n = el;
for (let i = 0; i < 6 && n && n.getAttribute; i++, n = n.parentElement) {
const cls = (n.className || '').toString();
const id = (n.id || '').toString();
if (/posCatalog_level|chapterCatalog|catalogTree|catalogBox/i.test(cls)) return true;
if (/catalog/i.test(id)) return true;
}
const r = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
// 右侧目录区(视口右侧 38%)
if (r && r.width > 0 && r.left > (window.innerWidth || 1000) * 0.62) return true;
} catch (e) {}
return false;
}
function isLiveStream() {
// 检测直播任务:只认内容区的直播任务点,绝不把右侧目录里的"07 直播"章节名当成直播
try {
const docs = getAllDocs();
for (const d of docs) {
// 1. 直播播放器 / 直播专属按钮 → 当前确实在直播任务里
try {
if (d.querySelector('iframe[src*="live"], iframe[src*="zhibo"], [id*="livePlayer"], [class*="livePlayer"], [class*="live-player"], [class*="live_room"], .liveBox')) return true;
} catch (e) {}
let btns = [];
try { btns = d.querySelectorAll('button, a, span, div'); } catch (e) {}
for (const b of btns) {
const t = (b.textContent || '').trim();
if (t !== '进入直播' && t !== '观看直播' && t !== '进入直播间') continue;
const r = b.getBoundingClientRect();
if (r.width > 0 && r.height > 0 && !inCatalogTree(b)) return true;
}
// 2. 当前激活的任务点标签是"直播"(单个序号,排除右侧目录树)
let labels = [];
try { labels = d.querySelectorAll('.ans-attach-ct, .ans-job-ct, .ans-attach, .posCatalog_select'); } catch (e) {}
for (const el of labels) {
let t = '';
try { t = (el.textContent || '').replace(/\s+/g, ''); } catch (e) {}
if (!t || t.length > 8) continue;
if (!/^\d?直播(视频|回放|任务)?$/.test(t)) continue;
if (inCatalogTree(el)) continue;
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) continue;
if (isActiveTaskEl(el, true)) return true;
}
}
} catch (e) {}
return false;
}
// ========== 视频弹题自动作答 ==========
let quizState = { answered: false, secondTry: false, aiTried: false };
function isVideoQuiz() {
// 检测视频弹题:选项+提交 或 题型标题+提交
const docs = getAllDocs();
let hasVideo = false;
for (const d of docs) {
try { if (d.body && /