// ==UserScript==
// @name 雨课堂刷课助手
// @namespace http://tampermonkey.net/
// @version 3.3.9
// @description 针对雨课堂视频进行自动播放,配置AI自动答题(基于原作者 cochle 的开源脚本修改)
// @author cochle (原作者);本分支在原脚本上迭代
// @license GPL3
// @match *://*.yuketang.cn/*
// @match *://*.gdufemooc.cn/*
// @run-at document-start
// @icon http://yuketang.cn/favicon.ico
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @connect api.openai.com
// @connect api.moonshot.cn
// @connect api.deepseek.com
// @connect dashscope.aliyuncs.com
// @connect api.anthropic.com
// @connect *
// @connect cdn.jsdelivr.net
// @connect unpkg.com
// @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js
// @require https://unpkg.com/tesseract.js@v2.1.0/dist/tesseract.min.js
// ==/UserScript==
/*
* 致谢:
* 本脚本最初由 cochle 编写并开源(GPL3)。
* 当前文件在其基础上做了功能与文案迭代(倍速按钮、中文日志、学习空间 forum 处理等)。
* 保留原作者署名;若你再分发,请继续保留原作者信息与许可证。
*/
(() => {
'use strict';
let panel; // UI 面板实例后置初始化
// ---- 脚本配置,用户可修改 ----
const Config = {
version: '3.3.9', // 版本号
playbackRate: 2, // 视频播放倍速(启动时会读 localStorage)
pptInterval: 3000, // ppt翻页间隔
storageKeys: { // 使用者勿动
progress: '[雨课堂脚本]刷课进度信息',
ai: 'ykt_ai_conf',
proClassCount: 'pro_lms_classCount',
feature: 'ykt_feature_conf', // 是否开启AI作答/自动评论
pendingAutoStart: 'ykt_pending_auto_start',
playbackRate: 'ykt_playback_rate'
}
};
// 页面/路由英文名 → 中文(面板日志用)
const TypeLabel = {
video: '视频',
audio: '音频',
exercise: '作业',
graph: '知识图谱',
discuss: '讨论',
discussion: '讨论',
forum: '讨论区',
topic: '话题',
shipin: '视频',
zuoye: '作业',
tuwen: '图文',
taolun: '讨论',
kejian: '课件',
ketang: '课堂',
piliang: '批量区',
kaoshi: '考试',
lms: '学习模块',
leaf: '学习单元',
chapter: '章节'
};
const Utils = {
// 短暂睡眠,等待网页加载
sleep: (ms = 1000) => new Promise(resolve => setTimeout(resolve, ms)),
// 将一个 JSON 字符串解析为 JavaScript 对象
safeJSONParse(value, fallback) {
try {
return JSON.parse(value);
} catch (_) {
return fallback;
}
},
// 每隔一段时间检查某个条件是否满足(通过 checker 函数),如果满足就成功返回;如果超时仍未满足,就失败返回
poll(checker, { interval = 1000, timeout = 20000 } = {}) {
return new Promise(resolve => {
const start = Date.now();
const timer = setInterval(() => {
if (checker()) {
clearInterval(timer);
resolve(true);
return;
}
if (Date.now() - start > timeout) {
clearInterval(timer);
resolve(false);
}
}, interval);
});
},
// 使用UI课程完成度来判别是否完成课程
isProgressDone(text) {
if (!text) return false;
return text.includes('100%') || text.includes('99%') || text.includes('98%') || text.includes('已完成');
},
// 主要是规避firefox会创建多个iframe的问题
inIframe() {
return window.top !== window.self;
},
// 下滑到最底部,触发课程加载
scrollToBottom(containerSelector) {
const el = document.querySelector(containerSelector);
if (el) el.scrollTop = el.scrollHeight;
},
getCurrentClassroomId() {
const query = new URLSearchParams(location.search);
const queryId = query.get('classroom_id');
if (queryId) return queryId;
const path = location.pathname;
return path.match(/^\/ai-workspace\/lms-graph\/([^/]+)/)?.[1]
|| path.match(/^\/v2\/web\/studentLog\/([^/]+)/)?.[1]
|| path.match(/\/(\d+)\/studycontent$/)?.[1]
|| '';
},
returnUrl() { // 得到课程开始的url
if (location.pathname.includes('/v2/web/studentLog/') || location.pathname.includes('pro/lms/')) {
return location.href
}
return ""
},
isSupportedLearningPage() {
const path = location.pathname;
return path.includes('/ai-workspace/lms-graph/')
|| path.includes('/v2/web/')
|| path.includes('/pro/lms/');
},
waitForMountTarget(timeout = 15000) {
const getTarget = () => document.body || document.documentElement;
const existing = getTarget();
if (existing) return Promise.resolve(existing);
return new Promise(resolve => {
let done = false;
const finish = () => {
if (done) return;
done = true;
observer.disconnect();
clearTimeout(timer);
resolve(getTarget());
};
const observer = new MutationObserver(() => {
if (getTarget()) finish();
});
observer.observe(document, { childList: true, subtree: true });
document.addEventListener('DOMContentLoaded', finish, { once: true });
window.addEventListener('load', finish, { once: true });
const timer = setTimeout(finish, timeout);
});
},
async getDDL() {
const element = document.querySelector('video') || document.querySelector('audio');
const fallback = 180_000;
if (!element) return fallback;
let duration = Number(element.duration);
if (!Number.isFinite(duration) || duration <= 0) {
await new Promise(resolve => element.addEventListener('loadedmetadata', resolve, { once: true }));
duration = Number(element.duration);
}
const elementDurationMs = duration * 1000; // 转为秒
const timeout = Math.max(elementDurationMs * 3, 10_000); // 至少 10 秒(防极短视频);
return timeout;
},
// 关闭雨课堂的挂机/离开检测弹窗,避免遮罩拦截刷课流程
dismissPopups() {
const wrappers = document.querySelectorAll('.el-dialog__wrapper, .el-message-box__wrapper');
for (const wrapper of wrappers) {
const style = getComputedStyle(wrapper);
const rect = wrapper.getBoundingClientRect();
if (style.display === 'none' || style.visibility === 'hidden' || rect.width === 0) continue;
const text = wrapper.innerText || '';
const buttons = [...wrapper.querySelectorAll('button')];
const clickBtn = label => {
const btn = buttons.find(b => (b.innerText || '').trim().includes(label));
if (btn) btn.click();
};
if (text.includes('好好学习') || text.includes('继续观看')) {
clickBtn('继续观看');
} else if (text.includes('报告老师')) {
clickBtn('取消');
}
}
}
};
// ---- 存储工具 ----
const Store = {
getProgress(url) {
const raw = localStorage.getItem(Config.storageKeys.progress);
const all = Utils.safeJSONParse(raw, {}) || { url: { outside: 0, inside: 0 } };
if (!all[url]) {
all[url] = { outside: 0, inside: 0 };
localStorage.setItem(Config.storageKeys.progress, JSON.stringify(all));
}
return { all, current: all[url] };
},
setProgress(url, outside, inside = 0) {
const raw = localStorage.getItem(Config.storageKeys.progress);
const all = Utils.safeJSONParse(raw, {});
all[url] = { outside, inside };
localStorage.setItem(Config.storageKeys.progress, JSON.stringify(all));
},
removeProgress(url) {
const raw = localStorage.getItem(Config.storageKeys.progress);
const all = Utils.safeJSONParse(raw, {});
delete all[url];
localStorage.setItem(Config.storageKeys.progress, JSON.stringify(all));
},
getAIConf() {
const raw = localStorage.getItem(Config.storageKeys.ai);
const saved = Utils.safeJSONParse(raw, {}) || {};
const conf = {
url: saved.url ?? "https://api.deepseek.com/chat/completions",
key: saved.key ?? "sk-xxxxxxx",
model: saved.model ?? "deepseek-flash",
apiFormat: saved.apiFormat ?? "openai", // openai 或 anthropic
authMethod: saved.authMethod ?? "bearer", // bearer 或 x-api-key
};
localStorage.setItem(Config.storageKeys.ai, JSON.stringify(conf));
return conf;
},
setAIConf(conf) {
localStorage.setItem(Config.storageKeys.ai, JSON.stringify(conf));
},
getProClassCount() {
const value = localStorage.getItem(Config.storageKeys.proClassCount);
return value ? Number(value) : 1;
},
setProClassCount(count) {
localStorage.setItem(Config.storageKeys.proClassCount, count);
},
getFeatureConf() {
const raw = localStorage.getItem(Config.storageKeys.feature);
const saved = Utils.safeJSONParse(raw, {}) || {};
const conf = {
autoAI: saved.autoAI ?? false,
autoComment: saved.autoComment ?? false,
};
localStorage.setItem(Config.storageKeys.feature, JSON.stringify(conf));
return conf;
},
setFeatureConf(conf) {
localStorage.setItem(Config.storageKeys.feature, JSON.stringify(conf));
},
getPlaybackRate() {
const value = Number(localStorage.getItem(Config.storageKeys.playbackRate));
return value === 1 || value === 2 ? value : 2;
},
setPlaybackRate(rate) {
const value = rate === 1 ? 1 : 2;
localStorage.setItem(Config.storageKeys.playbackRate, String(value));
Config.playbackRate = value;
return value;
},
getPendingAutoStart() {
const raw = localStorage.getItem(Config.storageKeys.pendingAutoStart);
const saved = Utils.safeJSONParse(raw, null);
if (!saved || !saved.classroomId || !saved.ts) return null;
if (Date.now() - saved.ts > 30 * 60 * 1000) {
localStorage.removeItem(Config.storageKeys.pendingAutoStart);
return null;
}
return saved;
},
setPendingAutoStart(classroomId = '', returnUrl = '') {
if (!classroomId) return;
const prev = this.getPendingAutoStart() || {};
localStorage.setItem(Config.storageKeys.pendingAutoStart, JSON.stringify({
classroomId,
returnUrl: returnUrl || prev.returnUrl || '',
ts: Date.now()
}));
},
clearPendingAutoStart() {
localStorage.removeItem(Config.storageKeys.pendingAutoStart);
},
};
// ---- UI 面板 ----
function createPanel() {
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.top = '40px';
iframe.style.left = '40px';
iframe.style.width = '520px';
iframe.style.height = '380px';
iframe.style.zIndex = '999999';
iframe.style.border = '1px solid #a3a3a3';
iframe.style.borderRadius = '10px';
iframe.style.background = '#fff';
iframe.style.overflow = 'hidden';
iframe.style.boxShadow = '6px 4px 17px 2px #000000';
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('id', 'ykt-helper-iframe');
iframe.setAttribute('allowtransparency', 'true');
const mountTarget = document.body || document.documentElement;
if (!mountTarget) {
throw new Error('面板挂载点不存在');
}
mountTarget.appendChild(iframe);
const doc = iframe.contentDocument || iframe.contentWindow.document;
doc.open();
doc.write(`
展开
- 🐵雨课堂刷课助手(原作者 cochle)
- 🙏 感谢原作者 cochle 开源本脚本(GPL3)
- 📢 使用必读:自动答题需先点击[AI配置]开启并填入API Key
- 🚀 配置完成后,点击[开始刷课]即可启动视频与作业挂机
- ⚙️ 倍速可用下方「单倍 / 双倍」按钮实时切换
- ❓ 帮助:点标题栏 ?
倍速
`);
doc.close();
const ui = {
iframe,
doc,
panel: doc.getElementById('panel'),
header: doc.getElementById('header'),
info: doc.getElementById('info'),
btnStart: doc.getElementById('btn-start'),
btnClear: doc.getElementById('btn-clear'),
btnSetting: doc.getElementById('btn-setting'),
btnStop: doc.getElementById('btn-stop'),
btnReload: doc.getElementById('btn-reload'),
settings: doc.getElementById('settings'),
saveSettings: doc.getElementById('save_settings'),
closeSettings: doc.getElementById('close_settings'),
aiUrlInput: doc.getElementById('ai_url'),
aiKeyInput: doc.getElementById('ai_key'),
aiModelInput: doc.getElementById('ai_model'),
aiFormatSelect: doc.getElementById('ai_format'),
authMethodSelect: doc.getElementById('auth_method'),
featureAutoAI: doc.getElementById('feature_auto_ai'),
featureAutoComment: doc.getElementById('feature_auto_comment'),
minimality: doc.getElementById('minimality'),
question: doc.getElementById('question'),
miniBasic: doc.getElementById('mini-basic'),
btnSpeed1: doc.getElementById('btn-speed-1'),
btnSpeed2: doc.getElementById('btn-speed-2')
};
let isDragging = false;
let startX = 0, startY = 0, startLeft = 0, startTop = 0;
const hostWindow = window.parent || window;
const onMove = e => {
if (!isDragging) return;
const deltaX = e.screenX - startX;
const deltaY = e.screenY - startY;
const maxLeft = Math.max(0, hostWindow.innerWidth - iframe.offsetWidth);
const maxTop = Math.max(0, hostWindow.innerHeight - iframe.offsetHeight);
iframe.style.left = Math.min(Math.max(0, startLeft + deltaX), maxLeft) + 'px';
iframe.style.top = Math.min(Math.max(0, startTop + deltaY), maxTop) + 'px';
};
const stopDrag = () => {
if (!isDragging) return;
isDragging = false;
iframe.style.transition = '';
doc.body.style.userSelect = '';
};
ui.header.addEventListener('mousedown', e => {
isDragging = true;
startX = e.screenX;
startY = e.screenY;
startLeft = parseFloat(iframe.style.left) || 0;
startTop = parseFloat(iframe.style.top) || 0;
iframe.style.transition = 'none';
doc.body.style.userSelect = 'none';
e.preventDefault();
});
doc.addEventListener('mousemove', onMove);
hostWindow.addEventListener('mousemove', onMove);
doc.addEventListener('mouseup', stopDrag);
hostWindow.addEventListener('mouseup', stopDrag);
hostWindow.addEventListener('blur', stopDrag);
const normalSize = { width: parseFloat(iframe.style.width), height: parseFloat(iframe.style.height) };
const miniSize = 64;
let isMinimized = false;
const enterMini = () => {
if (isMinimized) return;
isMinimized = true;
ui.panel.style.display = 'none';
ui.miniBasic.classList.add('show');
iframe.style.width = miniSize + 'px';
iframe.style.height = miniSize + 'px';
};
const exitMini = () => {
if (!isMinimized) return;
isMinimized = false;
ui.panel.style.display = '';
ui.miniBasic.classList.remove('show');
iframe.style.width = normalSize.width + 'px';
iframe.style.height = normalSize.height + 'px';
};
ui.minimality.addEventListener('click', enterMini);
ui.miniBasic.addEventListener('click', exitMini);
ui.question.addEventListener('click', () => {
window.parent.alert('雨课堂刷课助手\n原作者:cochle(GPL3)\n本分支在其开源脚本上迭代\n请勿用于违规用途');
});
const log = message => {
const li = doc.createElement('li');
li.innerText = message;
ui.info.appendChild(li);
if (ui.info.lastElementChild) ui.info.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
};
const warn = message => {
const li = doc.createElement('li');
li.innerText = '⚠️警告:' + message;
ui.info.appendChild(li);
if (ui.info.lastElementChild) ui.info.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
};
const error = message => {
const li = doc.createElement('li');
li.innerText = '🚨报错:' + message;
ui.info.appendChild(li);
if (ui.info.lastElementChild) ui.info.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
};
const defaultAI = { url: 'https://api.deepseek.com/chat/completions', key: 'sk-xxxxxxx', model: 'deepseek-flash', apiFormat: 'openai', authMethod: 'bearer' };
const loadAIConf = () => {
const saved = Store.getAIConf();
ui.aiUrlInput.value = saved.url || defaultAI.url;
ui.aiKeyInput.value = saved.key || defaultAI.key;
ui.aiModelInput.value = saved.model || defaultAI.model;
ui.aiFormatSelect.value = saved.apiFormat || defaultAI.apiFormat;
ui.authMethodSelect.value = saved.authMethod || defaultAI.authMethod;
};
const loadFeatureConf = () => {
const saved = Store.getFeatureConf();
ui.featureAutoAI.checked = saved.autoAI;
ui.featureAutoComment.checked = saved.autoComment;
};
loadAIConf();
loadFeatureConf();
// ---- 倍速按钮:单倍 / 双倍(实时生效) ----
// ScriptCat/油猴沙箱里 instanceof 可能对不上页面元素,改用 tagName;并优先拿页面真实 document
const getPageDoc = () => {
try {
if (typeof unsafeWindow !== 'undefined' && unsafeWindow && unsafeWindow.document) {
return unsafeWindow.document;
}
} catch (_) { }
return document;
};
const collectMediaInDoc = doc => {
const list = [];
if (!doc) return list;
try {
list.push(...doc.querySelectorAll('video, audio'));
} catch (_) { }
try {
[...doc.querySelectorAll('iframe')].forEach(frame => {
try {
list.push(...collectMediaInDoc(frame.contentDocument));
} catch (_) { }
});
} catch (_) { }
return list;
};
const forceMediaRate = rate => {
const medias = collectMediaInDoc(getPageDoc());
let hit = 0;
medias.forEach(media => {
const tag = (media && media.tagName ? media.tagName : '').toUpperCase();
if (tag !== 'VIDEO' && tag !== 'AUDIO') return;
try {
media.defaultPlaybackRate = rate;
media.playbackRate = rate;
hit++;
} catch (_) { }
});
return hit;
};
// 持续守护:播放器若把速度改回去,这里再按目标倍速点一次菜单
let rateGuardTimer = null;
const startRateGuard = () => {
if (rateGuardTimer) return;
rateGuardTimer = setInterval(() => {
const rate = Config.playbackRate;
const v = document.querySelector('video');
if (!v) return;
if (Math.abs(Number(v.playbackRate) - rate) < 0.05) return;
try { Player.applySpeed(); } catch (_) { }
try { v.playbackRate = rate; } catch (_) { }
}, 800);
};
const applyPlaybackRateToPage = rate => {
Config.playbackRate = rate;
Store.setPlaybackRate(rate);
// 走播放器真实倍速菜单(li[data-speed]),不要只写 video.playbackRate
try { Player.applySpeed(); } catch (_) { }
forceMediaRate(rate);
startRateGuard();
refreshSpeedButtons(rate, true);
const sample = collectMediaInDoc(getPageDoc()).find(m => (m.tagName || '').toUpperCase() === 'VIDEO');
const nowRate = sample ? Number(sample.playbackRate) : NaN;
log(`▶️ 倍速已切换为 ${rate === 1 ? '单倍' : '双倍'}(${rate}x),当前 video=${Number.isFinite(nowRate) ? nowRate + 'x' : '未找到'}`);
};
const refreshSpeedButtons = (rateArg, animate = false) => {
const rate = rateArg != null ? rateArg : Store.getPlaybackRate();
Config.playbackRate = rate;
[ui.btnSpeed1, ui.btnSpeed2].forEach(btn => {
if (!btn) return;
const btnRate = Number(btn.getAttribute('data-rate'));
const on = btnRate === rate;
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
if (on && animate) {
btn.classList.remove('just-clicked');
void btn.offsetWidth;
btn.classList.add('just-clicked');
}
});
};
refreshSpeedButtons();
// 页面已有视频时也启动守护,保证默认倍速生效
startRateGuard();
ui.btnSpeed1 && (ui.btnSpeed1.onclick = () => applyPlaybackRateToPage(1));
ui.btnSpeed2 && (ui.btnSpeed2.onclick = () => applyPlaybackRateToPage(2));
ui.btnSetting.onclick = () => {
loadAIConf();
loadFeatureConf();
ui.settings.style.display = 'block';
};
ui.closeSettings.onclick = () => {
ui.settings.style.display = 'none';
};
ui.saveSettings.onclick = () => {
const conf = {
url: ui.aiUrlInput.value.trim(),
key: ui.aiKeyInput.value.trim(),
model: ui.aiModelInput.value.trim(),
apiFormat: ui.aiFormatSelect.value,
authMethod: ui.authMethodSelect.value
};
Store.setAIConf(conf);
const featureConf = {
autoAI: ui.featureAutoAI.checked,
autoComment: ui.featureAutoComment.checked
};
Store.setFeatureConf(featureConf);
ui.settings.style.display = 'none';
log('✅ AI 配置已保存');
};
ui.btnClear.onclick = () => {
Store.removeProgress(window.parent.location.href);
localStorage.removeItem(Config.storageKeys.proClassCount);
Store.clearPendingAutoStart();
log('已清除当前课程的刷课进度缓存');
};
// 停止刷课:清除自动恢复标记后刷新页面,刷新后脚本回到空闲状态(进度缓存保留)
ui.btnStop.onclick = () => {
Store.clearPendingAutoStart();
log('已停止刷课,页面即将刷新');
window.parent.location.reload();
};
// 重新加载:重建自动恢复标记后刷新页面,刷新后自动恢复刷课(停止后点击同样生效)
ui.btnReload.onclick = () => {
Store.setPendingAutoStart(Utils.getCurrentClassroomId());
log('正在重新加载脚本...');
window.parent.location.reload();
};
let startHandler = null;
let running = false;
const invokeStart = () => {
if (running) {
log('已在刷课中,忽略重复启动');
return;
}
running = true;
log('启动中...');
ui.btnStart.innerText = '刷课中...';
startHandler && startHandler();
};
// 后面赋值给panel
return {
...ui,
log,
warn,
error,
setStartHandler(fn) {
startHandler = fn;
ui.btnStart.onclick = invokeStart;
},
start() {
invokeStart();
},
resetStartButton(text = '开始刷课') {
ui.btnStart.innerText = text;
if (text !== '刷课中...') running = false;
}
};
}
// ---- 播放器工具 ----
const Player = {
isNearEnd(media, threshold = 1) {
if (!media) return false;
const duration = Number(media.duration || 0);
const currentTime = Number(media.currentTime || 0);
return Number.isFinite(duration) && duration > 1 && currentTime > 0 && duration - currentTime <= threshold;
},
applySpeed() {
const rate = Config.playbackRate;
const video = document.querySelector('video');
if (video && Math.abs(Number(video.playbackRate) - rate) < 0.05) return;
// 雨课堂倍速菜单:xt-speedlist > li[data-speed="1|1.25|1.5|2"]
const speedWrap = document.getElementsByTagName('xt-speedbutton')[0];
const items = [...document.querySelectorAll('xt-speedlist li')];
const target = items.find(li => {
const s = parseFloat(li.getAttribute('data-speed'));
return Number.isFinite(s) && Math.abs(s - rate) < 0.05;
});
if (speedWrap && target) {
const mousemove = document.createEvent('MouseEvent');
mousemove.initMouseEvent('mousemove', true, true, unsafeWindow, 0, 10, 10, 10, 10, 0, 0, 0, 0, 0, null);
speedWrap.dispatchEvent(mousemove);
target.click();
setTimeout(() => {
const v = document.querySelector('video');
if (v) {
try { v.playbackRate = rate; } catch (_) { }
}
}, 80);
} else if (video) {
video.playbackRate = rate;
}
},
mute() {
const muteBtn = document.querySelector('#video-box > div > xt-wrap > xt-controls > xt-inner > xt-volumebutton > xt-icon');
if (muteBtn) muteBtn.click();
const video = document.querySelector('video');
if (video) video.volume = 0;
},
applyMediaDefault(media) {
if (!media) return;
media.play();
media.volume = 0;
media.playbackRate = Config.playbackRate;
},
observePause(video, shouldResume = () => true) {
if (!video) return () => { };
const canResume = () => shouldResume() && !video.ended && !this.isNearEnd(video);
// 自动播放
const playVideo = () => {
if (!canResume()) return;
video.play().catch(e => {
if (!canResume()) return;
console.warn('自动播放失败:', e);
setTimeout(playVideo, 3000);
});
};
playVideo();
// 直接监听 pause 事件,不依赖播放器 UI 元素
const onPause = () => { if (canResume()) playVideo(); };
video.addEventListener('pause', onPause);
// 定时兜底:防止 pause 事件被拦截
const timer = setInterval(() => { if (video.paused && canResume()) playVideo(); }, 5000);
// 播放器 UI 观察:按钮被点击暂停时 tip 变为「播放」
const target = document.getElementsByClassName('play-btn-tip')[0];
let observer = null;
if (target) {
observer = new MutationObserver(list => {
for (const mutation of list) {
if (mutation.type === 'childList' && target.innerText === '播放' && canResume()) {
video.play();
}
}
});
observer.observe(target, { childList: true });
}
return () => {
video.removeEventListener('pause', onPause);
clearInterval(timer);
if (observer) observer.disconnect();
};
},
waitForEnd(media, timeout = 0) {
return new Promise(resolve => {
if (!media) return resolve();
if (media.ended) return resolve();
let timer;
const onEnded = () => {
clearTimeout(timer);
resolve();
};
media.addEventListener('ended', onEnded, { once: true });
if (timeout > 0) {
timer = setTimeout(() => {
media.removeEventListener('ended', onEnded);
resolve();
}, timeout);
}
});
}
};
// ---- ai-workspace 路由工具 ----
const AiWorkspace = {
normalizeText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
},
isVisibleElement(element) {
if (!element || element.nodeType !== 1) return false;
const view = element.ownerDocument?.defaultView || window;
const style = view.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
},
getRoute() {
const match = location.pathname.match(/^\/ai-workspace\/lms-graph\/([^/]+)\/([^/]+)\/([^/?#]+)/);
if (!match) return null;
const [, classroomId, type, leafId] = match;
const query = new URLSearchParams(location.search);
return {
classroomId,
type,
leafId,
nodeId: query.get('node_id') || ''
};
},
getMediaCandidates() {
return [...document.querySelectorAll('video, audio')].filter(media => {
const tag = (media && media.tagName ? media.tagName : '').toUpperCase();
if (tag !== 'VIDEO' && tag !== 'AUDIO') return false;
const rect = media.getBoundingClientRect();
const isVisible = rect.width > 0 && rect.height > 0;
return isVisible || tag === 'AUDIO';
});
},
getMedia() {
const candidates = this.getMediaCandidates();
if (!candidates.length) return document.querySelector('video') || document.querySelector('audio');
const score = media => {
const rect = media.getBoundingClientRect();
const area = rect.width * rect.height;
const playingBoost = !media.paused && !media.ended ? 1_000_000 : 0;
const currentBoost = Number(media.currentTime || 0);
return playingBoost + area + currentBoost;
};
return [...candidates].sort((a, b) => score(b) - score(a))[0];
},
isPlayerDone(media, { startTime = 0, minPlayedDelta = 0 } = {}) {
if (!media) return false;
const currentTime = Number(media?.currentTime || 0);
const duration = Number(media?.duration || 0);
const playedDelta = Math.max(0, currentTime - startTime);
if (playedDelta < minPlayedDelta) return false;
if (media?.ended) return true;
if (duration > 1 && currentTime > 0 && duration - currentTime <= 1) return true;
const display = document.querySelector('.xt_video_player_current_time_display')?.innerText?.trim() || '';
const [current, total] = display.split(' / ').map(text => text?.trim());
return Boolean(playedDelta >= minPlayedDelta && current && total && current === total);
},
keepAlive(shouldResume = () => true) {
let lastMedia = null;
const tick = () => {
if (!shouldResume()) return;
const media = this.getMedia();
if (!media) return;
if (lastMedia !== media) {
lastMedia = media;
media.addEventListener('pause', tick);
}
media.muted = true;
media.defaultMuted = true;
media.volume = 0;
media.playbackRate = Config.playbackRate;
if (media.paused && !media.ended && !Player.isNearEnd(media)) {
media.play().catch(() => { });
}
};
const timer = setInterval(tick, 500);
document.addEventListener('visibilitychange', tick);
window.addEventListener('focus', tick);
tick();
return () => {
clearInterval(timer);
if (lastMedia) lastMedia.removeEventListener('pause', tick);
document.removeEventListener('visibilitychange', tick);
window.removeEventListener('focus', tick);
};
},
getActiveLeafTitle() {
const active = document.querySelector('.leaf-item.is-active');
if (!active) return '';
const title = active.querySelector('.leaf-item-title')?.innerText?.replace(/\s+/g, ' ').trim();
return title || active.innerText?.replace(/\s+/g, ' ').trim() || '';
},
getExerciseDocument() {
const localHasExercise = document.querySelector('#app .container-body .container-problem')
|| document.querySelector('#app .container-problem')
|| document.querySelector('.container-problem');
if (localHasExercise) return document;
const frames = [...document.querySelectorAll('iframe')];
for (const frame of frames) {
try {
const doc = frame.contentDocument;
if (!doc?.body) continue;
if (
doc.querySelector('.container-problem')
|| doc.querySelector('.subject-item')
|| doc.querySelector('.item-body')
) {
return doc;
}
} catch (_) {
// ignore cross-document access failures
}
}
return null;
},
getExerciseContainer() {
const exerciseDoc = this.getExerciseDocument();
return exerciseDoc?.querySelector('#app .container-body .container-problem')
|| exerciseDoc?.querySelector('#app .container-problem')
|| exerciseDoc?.querySelector('.container-problem')
|| null;
},
getExerciseQuestionTabs(root = this.getExerciseContainer(), { visibleOnly = true } = {}) {
if (!root) return [];
const selectors = [
'.subject-item.J_order',
'.subject-item',
'.problem-index-item',
'.question-index-item',
'[class*="subject-item"]',
'[class*="problem-index"]',
'[class*="question-index"]'
].join(',');
const all = [...root.querySelectorAll(selectors)];
return all.filter((el, index, arr) => {
if (visibleOnly && !this.isVisibleElement(el)) return false;
if (arr.indexOf(el) !== index) return false;
const text = this.normalizeText(el.innerText);
return text && text.length <= 20;
});
},
getExerciseQuestionBody(root = this.getExerciseContainer()) {
if (!root) return null;
const itemType = root.querySelector('.item-type');
if (itemType?.parentElement && this.isVisibleElement(itemType.parentElement)) return itemType.parentElement;
const selectors = [
'.item-body',
'.problem-content',
'.question-content',
'.problem-main',
'.problem-body',
'.question-body',
'[class*="problem-content"]',
'[class*="question-content"]',
'[class*="problem-body"]',
'[class*="question-body"]'
];
for (const selector of selectors) {
const match = [...root.querySelectorAll(selector)].find(el => this.isVisibleElement(el));
if (match) return match;
}
return root;
},
isExerciseAnswered(root = this.getExerciseContainer()) {
if (!root) return false;
const disabledSubmit = [...root.querySelectorAll('.el-button.is-disabled, button[disabled]')]
.find(el => /提交|提交答案/.test(this.normalizeText(el.innerText || '')));
if (disabledSubmit) return true;
const statusSelectors = [
'.result',
'.status',
'.answer-status',
'[class*="result"]',
'[class*="status"]'
];
for (const selector of statusSelectors) {
const statusNode = [...root.querySelectorAll(selector)]
.find(el => this.isVisibleElement(el) && /已完成|已作答|已提交|回答正确|回答错误/.test(this.normalizeText(el.innerText)));
if (statusNode) return true;
}
return false;
},
getExerciseActionButton(root = this.getExerciseContainer(), pattern = /提交|保存|确认|确定|下一题|下一道|下一步|完成本题/) {
if (!root) return null;
const selectors = 'button, .el-button, [role="button"], [class*="button"]';
const nodes = [
...root.querySelectorAll(selectors),
...document.querySelectorAll(selectors)
];
return nodes.find(el => this.isVisibleElement(el) && pattern.test(this.normalizeText(el.innerText)));
},
getAllScourse() { // 获得ai-workspace的课程列表
const list = document?.querySelectorAll(".nav-item-leaf-box")
if (!list) panel.warn("没有发现课程资源")
return list
}
};
// ---- 防切屏 ----
function preventScreenCheck() {
const win = unsafeWindow;
const blackList = new Set(['visibilitychange', 'blur', 'pagehide']);
win._addEventListener = win.addEventListener;
win.addEventListener = (...args) => blackList.has(args[0]) ? undefined : win._addEventListener(...args);
document._addEventListener = document.addEventListener;
document.addEventListener = (...args) => blackList.has(args[0]) ? undefined : document._addEventListener(...args);
Object.defineProperties(document, {
hidden: { value: false },
visibilityState: { value: 'visible' },
hasFocus: { value: () => true },
onvisibilitychange: { get: () => undefined, set: () => { } },
onblur: { get: () => undefined, set: () => { } }
});
Object.defineProperties(win, {
onblur: { get: () => undefined, set: () => { } },
onpagehide: { get: () => undefined, set: () => { } }
});
}
// ---- OCR & AI ----
const Solver = {
visionDisabled: false,
decryptFontCss: '',
decryptFontLoading: null,
decryptFontLoaded: false,
async ensureDecryptFont(doc) {
if (this.decryptFontCss) return this.decryptFontCss;
if (this.decryptFontLoading) return this.decryptFontLoading;
this.decryptFontLoading = (async () => {
try {
let fontUrl = '';
for (const scope of [doc, document]) {
if (!scope || fontUrl) continue;
for (const style of scope.querySelectorAll('style')) {
const text = style.textContent || '';
if (!text.includes('exam-data-decrypt-font')) continue;
const m = text.match(/url\(\s*["']?([^"')]+)["']?\s*\)/);
if (m && m[1]) {
fontUrl = m[1];
try {
fontUrl = new URL(fontUrl, scope.baseURI || location.href).href;
} catch (_) { }
break;
}
}
}
if (!fontUrl) {
for (const view of [doc && doc.defaultView, window].filter(Boolean)) {
try {
const entry = view.performance.getEntriesByType('resource')
.find(e => /exam_font|fe_font\/product/i.test(e.name));
if (entry && entry.name) {
fontUrl = entry.name;
break;
}
} catch (_) { }
}
}
if (!fontUrl) return '';
let dataUrl = '';
try {
const resp = await fetch(fontUrl);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
dataUrl = `data:font/ttf;base64,${this.bufferToBase64(await resp.arrayBuffer())}`;
} catch (err) {
console.warn('fetch 解密字体失败,尝试 GM 请求:', err);
try {
const buffer = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: fontUrl,
responseType: 'arraybuffer',
timeout: 60000,
onload: res => {
if (res.status === 200 && res.response instanceof ArrayBuffer) {
resolve(res.response);
return;
}
reject(new Error(`HTTP ${res.status}`));
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('请求超时'))
});
});
dataUrl = `data:font/ttf;base64,${this.bufferToBase64(buffer)}`;
} catch (err2) {
console.warn('GM 获取解密字体失败,将直接引用原地址:', err2);
}
}
const src = dataUrl || fontUrl;
try {
const FontFaceCtor = (doc && doc.defaultView && doc.defaultView.FontFace) || FontFace;
const face = new FontFaceCtor('exam-data-decrypt-font', `url(${src})`);
const loaded = await face.load();
if (doc && doc.fonts) doc.fonts.add(loaded);
this.decryptFontLoaded = true;
} catch (_) { }
this.decryptFontCss = `@font-face { font-family: "exam-data-decrypt-font"; src: url("${src}"); }`;
panel.log(dataUrl ? '已加载题目解密字体' : '解密字体已按原地址注入');
} catch (err) {
console.error('ensureDecryptFont error:', err);
this.decryptFontLoading = null;
}
return this.decryptFontCss;
})();
return this.decryptFontLoading;
},
bufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
},
async capture(element) {
if (!element) return null;
try {
panel.log('正在生成题目图片...');
await this.ensureDecryptFont(element.ownerDocument);
const textCanvas = this.renderTextCanvas(element);
if (textCanvas) return textCanvas;
panel.log('文本渲染不可用,改用常规截图');
return await html2canvas(element, {
useCORS: true,
logging: false,
scale: 2,
backgroundColor: '#ffffff'
});
} catch (err) {
console.error('capture error:', err);
panel.log(`截图失败: ${err.message || '网络错误'}`);
return null;
}
},
collectTextSegments(element) {
const doc = element.ownerDocument;
const segs = [];
const walk = node => {
if (node.nodeType === 3) {
const text = (node.nodeValue || '').replace(/\s+/g, ' ');
if (!text.trim()) return;
const parent = node.parentElement;
const encrypted = !!(parent && parent.closest('.xuetangx-com-encrypted-font'));
const last = segs[segs.length - 1];
if (last && !last.newline && last.encrypted === encrypted) last.text += text;
else segs.push({ text, encrypted });
return;
}
if (node.nodeType !== 1) return;
const el = node;
try {
const style = doc.defaultView.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return;
} catch (_) { }
if (el.tagName === 'BR') { segs.push({ newline: true }); return; }
const isBlock = ['P', 'LI', 'DIV', 'SECTION', 'TR', 'H1', 'H2', 'H3', 'H4', 'UL', 'OL'].includes(el.tagName);
if (isBlock && segs.length && !segs[segs.length - 1].newline) segs.push({ newline: true });
for (const child of el.childNodes) walk(child);
if (isBlock && segs.length && !segs[segs.length - 1].newline) segs.push({ newline: true });
};
walk(element);
return segs;
},
renderTextCanvas(element) {
const doc = element.ownerDocument;
const segs = this.collectTextSegments(element);
if (!segs.length) return null;
const hasEncrypted = segs.some(s => s.encrypted);
if (hasEncrypted && !this.decryptFontLoaded) return null;
const scale = 2, fontSize = 22, lineHeight = 36, pad = 20, maxWidth = 880;
const canvas = doc.createElement('canvas');
const ctx = canvas.getContext('2d');
const examFont = `${fontSize}px "exam-data-decrypt-font"`;
const normalFont = `${fontSize}px "Microsoft YaHei", "PingFang SC", SimSun, sans-serif`;
const lines = [[]];
let lineWidth = 0;
for (const seg of segs) {
if (seg.newline) {
if (lines[lines.length - 1].length) { lines.push([]); lineWidth = 0; }
continue;
}
const font = (seg.encrypted && hasEncrypted) ? examFont : normalFont;
ctx.font = font;
for (const ch of seg.text) {
const w = ctx.measureText(ch).width;
if (lineWidth + w > maxWidth && lineWidth > 0) { lines.push([]); lineWidth = 0; }
const lastLine = lines[lines.length - 1];
const lastRun = lastLine[lastLine.length - 1];
if (lastRun && lastRun.font === font) lastRun.text += ch;
else lastLine.push({ text: ch, font });
lineWidth += w;
}
}
const nonEmpty = lines.filter(l => l.length);
if (!nonEmpty.length) return null;
canvas.width = (maxWidth + pad * 2) * scale;
canvas.height = (nonEmpty.length * lineHeight + pad * 2) * scale;
ctx.scale(scale, scale);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000000';
ctx.textBaseline = 'top';
nonEmpty.forEach((runs, i) => {
let x = pad;
const y = pad + i * lineHeight;
for (const run of runs) {
ctx.font = run.font;
ctx.fillText(run.text, x, y);
x += ctx.measureText(run.text).width;
}
});
return canvas;
},
async recognizeCanvas(canvas) {
if (!canvas) return '';
panel.log('正在 OCR 识别 (首轮较慢)...');
const { data: { text } } = await Tesseract.recognize(canvas, 'chi_sim', {
logger: m => {
if (m.status === 'downloading tesseract lang') {
console.log(`正在下载语言包 ${(m.progress * 100).toFixed(0)}%`);
}
}
});
return text.replace(/\s+/g, ' ').trim();
},
async recognize(element) {
if (!element) return '无元素';
try {
const canvas = await this.capture(element);
if (!canvas) return 'OCR识别出错';
return await this.recognizeCanvas(canvas);
} catch (err) {
console.error('OCR error:', err);
panel.log(`OCR 失败: ${err.message || '网络错误'}`);
return 'OCR识别出错';
}
},
getOptionElements(container) {
if (!container) return [];
let options = [...container.querySelectorAll(':scope > li, :scope > label.el-radio, :scope > label.el-checkbox, :scope > .option-item, :scope > .answer-item, :scope > [class*="option-item"], :scope > [class*="answer-item"]')];
if (!options.length) {
options = [...container.querySelectorAll('li, .option-item, .answer-item, [class*="option-item"], [class*="answer-item"]')];
}
return options;
},
async askAI(ocrText, optionCount = 0, canvas = null) {
const saved = Store.getAIConf();
const API_URL = saved.url;
const API_KEY = saved.key;
const MODEL_NAME = saved.model;
const API_FORMAT = saved.apiFormat || 'openai';
const AUTH_METHOD = saved.authMethod || 'bearer';
if (!API_KEY || API_KEY.includes('sk-xxxx')) {
const msg = '⚠️ 请在 [AI配置] 中填写有效的 API Key';
panel.log(msg);
throw new Error(msg);
}
const maxChar = String.fromCharCode(65 + optionCount - 1);
const rangeStr = optionCount ? `A-${maxChar}` : 'A-D';
const promptHead = `请解答下面的题目。规则:
1. 本题共有 ${optionCount || '若干'} 个选项,按题目中出现顺序对应 ${rangeStr}。
2. 多选题请选出所有正确选项,单选题只选一个,判断题答案为“对”或“错”。
3. 先写一句 30 字以内的判断依据,然后另起一行,严格按「正确答案:X」格式输出最终答案(X 为 A、ABD、对、错等)。`;
const systemPrompt = "你是严谨的阅卷老师,先给一句简短依据,最后输出「正确答案:X」。";
const authHeader = AUTH_METHOD === 'x-api-key'
? { 'x-api-key': API_KEY }
: { 'Authorization': `Bearer ${API_KEY}` };
let imageDataUrl = '';
if (canvas) {
try {
imageDataUrl = canvas.toDataURL('image/png');
} catch (err) {
console.error('toDataURL error:', err);
panel.log('截图导出失败,改用 OCR 文本');
}
}
const isDeepSeek = /deepseek/i.test(API_URL) || /deepseek/i.test(MODEL_NAME);
const request = (withImage, text = '') => new Promise((resolve, reject) => {
const userText = withImage
? `${promptHead}\n题目在图片中,请直接阅读图片作答。`
: `${promptHead}\n题目内容(OCR 结果,可能有错字乱码):\n${text}`;
if (API_FORMAT === 'anthropic') {
const headers = {
'Content-Type': 'application/json',
...authHeader
};
if (API_URL.includes('api.anthropic.com')) {
headers['anthropic-version'] = '2023-06-01';
}
const userContent = withImage
? [
{ type: 'text', text: userText },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: imageDataUrl.split(',')[1] } }
]
: userText;
const requestBody = {
model: MODEL_NAME,
max_tokens: 1024,
system: systemPrompt,
messages: [
{ role: 'user', content: userContent }
]
};
console.log('[AI请求] URL:', API_URL, '图片直读:', withImage);
panel.log(`请求 ${API_URL}${withImage ? '(图片直读)' : ''}...`);
GM_xmlhttpRequest({
method: 'POST',
url: API_URL,
headers,
data: JSON.stringify(requestBody),
timeout: 120000,
onload: res => {
console.log('[AI响应] Status:', res.status);
console.log('[AI响应] Response:', res.responseText);
if (res.status === 200) {
try {
const json = JSON.parse(res.responseText);
const answerText = json.content?.[0]?.text || json.choices?.[0]?.message?.content;
resolve(answerText);
} catch (e) {
reject('JSON 解析失败');
}
} else {
const err = `请求失败: HTTP ${res.status} - ${String(res.responseText || '').slice(0, 200)}`;
panel.log(err);
reject(err);
}
},
onerror: () => reject('网络错误'),
ontimeout: () => reject('请求超时')
});
} else {
const userContent = withImage
? [
{ type: 'text', text: userText },
{ type: 'image_url', image_url: { url: imageDataUrl } }
]
: userText;
GM_xmlhttpRequest({
method: 'POST',
url: API_URL,
headers: {
'Content-Type': 'application/json',
...authHeader
},
data: JSON.stringify({
model: MODEL_NAME,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent }
],
temperature: 0.1,
stream: false,
...(isDeepSeek ? { thinking: { type: 'disabled' } } : {})
}),
timeout: 120000,
onload: res => {
if (res.status === 200) {
try {
const json = JSON.parse(res.responseText);
const answerText = json.choices[0].message.content;
resolve(answerText);
} catch (e) {
reject('JSON 解析失败');
}
} else {
const err = `请求失败: HTTP ${res.status} - ${String(res.responseText || '').slice(0, 200)}`;
panel.log(err);
reject(err);
}
},
onerror: () => reject('网络错误'),
ontimeout: () => reject('请求超时')
});
}
});
if (imageDataUrl && !this.visionDisabled) {
try {
return await request(true);
} catch (err) {
this.visionDisabled = true;
panel.log(`图片直读失败(${err}),改用 OCR 文本重试`);
}
}
let text = String(ocrText || '');
if (!text && canvas) {
try {
text = await this.recognizeCanvas(canvas);
} catch (err) {
console.error('OCR error:', err);
panel.log(`OCR 失败: ${err.message || '网络错误'}`);
}
}
if (!text) throw new Error('无可用题目内容');
return request(false, text);
},
async autoSelectAndSubmit(aiResponse, itemBodyElement) {
const text = String(aiResponse || '').replace(/[*`_#]/g, '');
const matches = [...text.matchAll(/(?:正确|最终|参考)?答案[是为::\s]*([A-F][A-F、,,和\s]*|[对错]|正确|错误)/gi)];
let match = matches[matches.length - 1];
if (!match) {
const fallback = [...text.matchAll(/(?:应选|选择|选)\s*[::]?\s*([A-F][A-F、,,和\s]*)/gi)];
match = fallback[fallback.length - 1];
}
if (!match) {
match = text.trim().match(/^([A-F]{1,6}|对|错|正确|错误)[。.!!~~\s]*$/i);
}
if (!match) {
panel.log(`⚠️ 未提取到有效答案(AI 原话:${text.slice(0, 60)})`);
return false;
}
const raw = match[1].trim();
const isJudge = /^(对|错|正确|错误)$/.test(raw);
const letters = raw.toUpperCase().replace(/[^A-F]/g, '');
const map = { 'A': 0, 'B': 1, 'C': 2, 'D': 3, 'E': 4, 'F': 5 };
let targetIndices = [];
if (isJudge) {
targetIndices = (raw === '对' || raw === '正确') ? [0] : [1];
} else {
for (const char of letters) {
if (map[char] !== undefined) targetIndices.push(map[char]);
}
}
if (!targetIndices.length) {
panel.log(`⚠️ 答案「${raw}」无法匹配选项,请人工检查`);
return false;
}
panel.log(`✅ AI 建议选:${isJudge ? raw : letters}`);
const listContainer = itemBodyElement.querySelector('.list-inline.list-unstyled-radio') ||
itemBodyElement.querySelector('.list-unstyled.list-unstyled-radio') ||
itemBodyElement.querySelector('.list-unstyled') ||
itemBodyElement.querySelector('ul.list') ||
itemBodyElement.querySelector('[class*="option-list"]') ||
itemBodyElement.querySelector('[class*="answer-list"]') ||
itemBodyElement.querySelector('.el-radio-group') ||
itemBodyElement.querySelector('.el-checkbox-group') ||
itemBodyElement.querySelector('ul') ||
itemBodyElement.querySelector('[role="radiogroup"]');
if (!listContainer) {
panel.log('⚠️ 未找到选项容器');
return false;
}
const options = this.getOptionElements(listContainer);
for (const idx of targetIndices) {
if (!options[idx]) continue;
const clickable = options[idx].querySelector('label.el-radio') ||
options[idx].querySelector('label.el-checkbox') ||
options[idx].querySelector('.el-radio__label') ||
options[idx].querySelector('.el-checkbox__label') ||
options[idx].querySelector('[role="radio"]') ||
options[idx].querySelector('[role="checkbox"]') ||
options[idx].querySelector('input') ||
options[idx];
clickable.click();
await Utils.sleep(150);
}
const submitBtn = (() => {
const ownerDocument = itemBodyElement.ownerDocument || document;
const roots = [itemBodyElement.parentElement, itemBodyElement, ownerDocument].filter(Boolean);
const matchText = text => /提交|保存|确认|确定|提交答案/.test(text);
for (const root of roots) {
const local = root.querySelectorAll('button, .el-button, [role="button"]');
for (const btn of local) {
if (btn.offsetParent !== null && matchText(btn.innerText || '')) return btn;
}
}
const global = ownerDocument.querySelectorAll('.el-button.el-button--primary.el-button--medium');
for (const btn of global) {
if (matchText(btn.innerText || '') && btn.offsetParent !== null) return btn;
}
return null;
})();
if (submitBtn) {
panel.log('正在提交...');
submitBtn.click();
return true;
}
panel.log('⚠️ 未找到提交按钮,请人工检查该题');
return false;
}
};
// ---- v2 逻辑 ----
class V2Runner {
constructor(panel) {
this.panel = panel;
this.baseUrl = location.href;
const { current } = Store.getProgress(this.baseUrl);
this.outside = current.outside;
this.inside = current.inside;
this.shouldStop = false;
}
updateProgress(outside, inside = 0) {
this.outside = outside;
this.inside = inside;
Store.setProgress(this.baseUrl, outside, inside);
}
async waitForExternalHandoff(timeout = 1200) {
await Utils.sleep(timeout);
if (document.visibilityState === 'hidden' || !document.hasFocus()) {
this.shouldStop = true;
this.panel.log('已交给新页面继续,返回目录页后会自动续跑');
return true;
}
return false;
}
checkCompletionStatus(statusBox, statusText) {
// 1. 检查明确的完成状态文本
if (statusText.includes('已完成') || statusText.includes('已读')) {
return true;
}
// 2. 检查明确的未完成状态文本
if (statusText.includes('未开始') || statusText.includes('未读') || statusText.includes('进行中')) {
return false;
}
// 3. 检查学习进度数字比例
const progressMatch = statusText.match(/(\d+)\/(\d+)/);
if (progressMatch) {
const [, current, total] = progressMatch;
const currentNum = parseInt(current, 10);
const totalNum = parseInt(total, 10);
// 根据数字进度判断:相等且大于0表示已完成
return currentNum === totalNum && totalNum > 0;
}
// 默认返回false(未完成)
return false;
}
async run() {
this.panel.log(`检测到已播放到第 ${this.outside} 集,继续刷课...`);
// 在课件页恢复时直接续播当前内容,不重新走列表流程
if (location.pathname.includes('/studentCards/')) {
const videoBox = document.querySelector('.video-box');
const boxText = videoBox?.innerText || '';
if ((videoBox || document.querySelector('video')) && !boxText.includes('已完成')) {
this.panel.log('检测到当前课件页,直接续播当前内容');
await this.waitCoursewareVideo();
history.back();
await Utils.sleep(1000);
}
}
while (true) {
await this.autoSlide();
const list = document.querySelector('.logs-list')?.childNodes;
if (!list || !list.length) {
// 可能停留在课件页:跳回目录页继续,避免无限重试
const pending = Store.getPendingAutoStart();
const returnUrl = pending?.returnUrl
|| (pending?.classroomId ? `/v2/web/studentLog/${pending.classroomId}` : '');
if (returnUrl && !location.pathname.includes('/studentLog/')) {
this.panel.log('当前页面无课程列表,返回目录页继续');
location.href = returnUrl;
return;
}
this.panel.log('未找到课程列表,稍后重试');
await Utils.sleep(2000);
continue;
}
console.log(`当前集数:${this.outside}/全部集数${list.length}`);
if (this.outside >= list.length) {
this.panel.log('课程刷完啦 🎉');
this.panel.resetStartButton('刷完啦~');
Store.removeProgress(this.baseUrl);
Store.clearPendingAutoStart();
break;
}
const course = list[this.outside]?.querySelector('.content-box')?.querySelector('section');
if (!course) {
this.panel.log('未找到当前课程节点,跳过');
this.updateProgress(this.outside + 1, 0);
continue;
}
const type = course.querySelector('.tag')?.querySelector('use')?.getAttribute('xlink:href') || 'piliang';
const typeKey = String(type).replace('#icon-', '').replace('#icon--', '');
const typeLabel = TypeLabel[typeKey] || typeKey || '未知';
const title = course.querySelector('h2')?.innerText?.trim() || `第${this.outside + 1}项`;
// 预检查完成状态
const statusBox = course.querySelector('.statistics-box .aside');
const statusText = statusBox?.innerText || '';
// 判断是否已完成
let isCompleted = this.checkCompletionStatus(statusBox, statusText);
if (isCompleted) {
this.panel.log(`✅ ${title} 已完成,跳过`);
this.updateProgress(this.outside + 1, 0);
continue;
}
this.panel.log(`刷课状态:第 ${this.outside + 1}/${list.length} 个,类型「${typeLabel}」,标题:${title}`);
if (type.includes('shipin')) {
await this.handleVideo(course);
} else if (type.includes('piliang')) {
await this.handleBatch(course, list);
} else if (type.includes('ketang')) {
await this.handleClassroom(course);
} else if (type.includes('kejian')) {
await this.handleCourseware(course);
} else if (type.includes('kaoshi')) {
this.panel.log('考试区域脚本会被屏蔽,已跳过');
this.updateProgress(this.outside + 1, 0);
} else {
this.panel.log('非视频/批量/课件/考试,已跳过');
this.updateProgress(this.outside + 1, 0);
}
if (this.shouldStop) return;
}
}
async autoSlide() {
const frequency = Math.floor((this.outside + 1) / 20) + 1;
for (let i = 0; i < frequency; i++) {
Utils.scrollToBottom('.viewContainer');
await Utils.sleep(800);
}
}
async handleVideo(course) {
course.click();
if (await this.waitForExternalHandoff(1500)) return;
await Utils.sleep(3000);
const progressNode = document.querySelector('.progress-wrap')?.querySelector('.text');
const title = document.querySelector('.title')?.innerText || '视频';
const isDeadline = document.querySelector('.box')?.innerText.includes('已过考核截止时间');
if (isDeadline) this.panel.log(`${title} 已过截止,进度不再增加,将直接跳过`);
Player.applySpeed();
Player.mute();
const stopObserve = Player.observePause(document.querySelector('video'));
await Utils.poll(() => {
Utils.dismissPopups();
return isDeadline || Utils.isProgressDone(progressNode?.innerHTML);
}, { interval: 5000, timeout: await Utils.getDDL() });
stopObserve();
this.updateProgress(this.outside + 1, 0);
history.back();
await Utils.sleep(1200);
}
async handleBatch(course, list) {
const expandBtn = course.querySelector('.sub-info')?.querySelector('.gray')?.querySelector('span');
if (!expandBtn) {
this.panel.log('未找到批量展开按钮,跳过');
this.updateProgress(this.outside + 1, 0);
return;
}
expandBtn.click();
await Utils.sleep(1200);
const activities = list[this.outside]?.querySelector('.leaf_list__wrap')?.querySelectorAll('.activity__wrap') || [];
let idx = this.inside;
this.panel.log(`进入批量区,内部进度 ${idx}/${activities.length}`);
while (idx < activities.length) {
const item = activities[idx];
if (!item) break;
const tagText = item.querySelector('.tag')?.innerText || '';
const tagHref = item.querySelector('.tag')?.querySelector('use')?.getAttribute('xlink:href') || '';
const title = item.querySelector('h2')?.innerText || `第${idx + 1}项`;
// 检查当前项目的完成状态
const statusBox = item.querySelector('.statistics-box .aside');
const statusText = statusBox?.innerText || '';
const isCompleted = this.checkCompletionStatus(statusBox, statusText);
if (isCompleted) {
this.panel.log(`✅ ${title} 已完成,跳过`);
idx++;
this.updateProgress(this.outside, idx);
continue;
}
if (tagText === '音频') {
idx = await this.playAudioItem(item, title, idx);
} else if (tagHref.includes('shipin')) {
idx = await this.playVideoItem(item, title, idx);
} else if (tagHref.includes('tuwen') || tagHref.includes('taolun')) {
idx = await this.autoCommentItem(item, tagHref.includes('tuwen') ? '图文' : '讨论', idx);
} else if (tagHref.includes('zuoye')) {
idx = await this.handleHomework(item, idx);
} else {
this.panel.log(`类型未知,已跳过:${title}`);
idx++;
this.updateProgress(this.outside, idx);
}
if (this.shouldStop) return;
}
this.updateProgress(this.outside + 1, 0);
await Utils.sleep(1000);
}
async playAudioItem(item, title, idx) {
this.panel.log(`开始播放音频:${title}`);
item.click();
if (await this.waitForExternalHandoff()) return idx;
await Utils.sleep(2500);
Player.applyMediaDefault(document.querySelector('audio'));
const progressNode = document.querySelector('.progress-wrap')?.querySelector('.text');
await Utils.poll(() => {
Utils.dismissPopups();
return Utils.isProgressDone(progressNode?.innerHTML);
}, { interval: 3000, timeout: await Utils.getDDL() });
this.panel.log(`${title} 播放完成`);
idx++;
this.updateProgress(this.outside, idx);
history.back();
await Utils.sleep(1500);
return idx;
}
async playVideoItem(item, title, idx) {
this.panel.log(`开始播放视频:${title}`);
item.click();
if (await this.waitForExternalHandoff()) return idx;
await Utils.sleep(2500);
Player.applySpeed();
Player.mute();
const stopObserve = Player.observePause(document.querySelector('video'));
const progressNode = document.querySelector('.progress-wrap')?.querySelector('.text');
await Utils.poll(() => {
Utils.dismissPopups();
return Utils.isProgressDone(progressNode?.innerHTML);
}, { interval: 3000, timeout: await Utils.getDDL() });
stopObserve();
this.panel.log(`${title} 播放完成`);
idx++;
this.updateProgress(this.outside, idx);
history.back();
await Utils.sleep(1500);
return idx;
}
async autoCommentItem(item, typeText, idx) {
const title = item.querySelector('h2')?.innerText || '';
this.panel.log(`开始处理${typeText}:${title}`);
item.click();
// 若新开标签进入学习空间,本页失焦则交棒,由学习空间逻辑接手
if (await this.waitForExternalHandoff(1500)) return idx;
await Utils.sleep(1200);
const featureFlags = Store.getFeatureConf();
if (!featureFlags.autoComment) {
this.panel.log(`${typeText}「${title}」已点开;未开启自动回复,不发帖,直接下一项`);
idx++;
this.updateProgress(this.outside, idx);
history.back();
await Utils.sleep(1000);
return idx;
}
// 开启了自动评论功能,执行评论逻辑
window.scrollTo(0, document.body.scrollHeight);
await Utils.sleep(800);
window.scrollTo(0, 0);
const commentSelectors = ['#new_discuss .new_discuss_list .cont_detail', '.new_discuss_list dd .cont_detail', '.cont_detail.word-break'];
let firstComment = '';
for (let retry = 0; retry < 30 && !firstComment; retry++) {
for (const sel of commentSelectors) {
const list = document.querySelectorAll(sel);
for (const node of list) {
if (node?.innerText?.trim()) {
firstComment = node.innerText.trim();
break;
}
}
if (firstComment) break;
}
if (!firstComment) await Utils.sleep(500);
}
if (!firstComment) {
this.panel.log('未找到评论内容,跳过该项');
} else {
const input = document.querySelector('.el-textarea__inner');
if (input) {
input.value = firstComment;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
await Utils.sleep(800);
const sendBtn = document.querySelector('.el-button.submitComment') ||
document.querySelector('.publish_discuss .postBtn button') ||
document.querySelector('.el-button--primary');
if (sendBtn && !sendBtn.disabled && !sendBtn.classList.contains('is-disabled')) {
sendBtn.click();
this.panel.log(`已在${typeText}区发表评论`);
} else {
this.panel.log('发送按钮不可用或不存在');
}
} else {
this.panel.log('未找到评论输入框,跳过');
}
}
idx++;
this.updateProgress(this.outside, idx);
history.back();
await Utils.sleep(1000);
return idx;
}
async handleHomework(item, idx) {
const featureFlags = Store.getFeatureConf();
if (!featureFlags.autoAI) {
this.panel.log('已关闭AI自动答题,跳过该项');
idx++;
this.updateProgress(this.outside, idx);
return idx;
}
this.panel.log('进入作业,启动 AI 作答');
item.click();
await Utils.sleep(1500);
let i = 0;
const maxRetry = 3; // 最大重试次数
while (true) {
const items = document.querySelectorAll('.subject-item.J_order');
if (i >= items.length) {
this.panel.log(`所有题目处理完毕,共 ${items.length} 题,准备交卷`);
break;
}
const listItem = items[i];
listItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
listItem.click();
await Utils.sleep(1800);
const disabled = document.querySelectorAll('.el-button.el-button--info.is-disabled.is-plain');
if (disabled.length > 0) {
this.panel.log(`第 ${i + 1} 题已完成,跳过...`);
i++;
continue;
}
const targetEl = document.querySelector('.item-type')?.parentElement || document.querySelector('.item-body');
let optionCount = 0;
const listContainer = targetEl?.querySelector('.list-inline.list-unstyled-radio') ||
targetEl?.querySelector('.list-unstyled.list-unstyled-radio') ||
targetEl?.querySelector('.el-radio-group') ||
targetEl?.querySelector('.el-checkbox-group') ||
targetEl?.querySelector('ul.list');
if (listContainer) optionCount = Solver.getOptionElements(listContainer).length;
const questionCanvas = await Solver.capture(targetEl);
if (questionCanvas) {
let retryCount = 0;
let success = false;
while (retryCount < maxRetry && !success) {
try {
if (retryCount > 0) {
this.panel.log(`🔄 第 ${i + 1} 题重试 ${retryCount}/${maxRetry}...`);
}
panel.log('🤖 请求 AI 获取答案...');
const aiText = await Solver.askAI('', optionCount, questionCanvas);
const ok = await Solver.autoSelectAndSubmit(aiText, targetEl);
if (!ok) throw new Error('自动作答未完成');
success = true;
} catch (err) {
retryCount++;
this.panel.log(`AI 答题失败:${err}`);
if (retryCount < maxRetry) {
this.panel.log(`等待 5 秒后重试...`);
await Utils.sleep(5000);
} else {
this.panel.log(`⚠️ 第 ${i + 1} 题重试 ${maxRetry} 次后仍失败,跳过`);
}
}
}
}
await Utils.sleep(1500);
i++;
}
idx++;
this.updateProgress(this.outside, idx);
history.back();
await Utils.sleep(1200);
return idx;
}
async handleClassroom(course) {
this.panel.log('进入课堂模式...');
course.click();
await Utils.sleep(5000);
const iframe = document.querySelector('iframe.lesson-report-mobile');
if (!iframe || !iframe.contentDocument) {
this.panel.log('未找到课堂 iframe,跳过');
this.updateProgress(this.outside + 1, 0);
return;
}
const video = iframe.contentDocument.querySelector('video');
const audio = iframe.contentDocument.querySelector('audio');
if (video) {
Player.applyMediaDefault(video);
await Player.waitForEnd(video);
}
if (audio) {
Player.applyMediaDefault(audio);
await Player.waitForEnd(audio);
}
this.updateProgress(this.outside + 1, 0);
history.go(-1);
await Utils.sleep(1200);
}
// 等待课件视频播放完毕;播放器被关闭(弹窗关闭/元素销毁)时自动重新打开
async waitCoursewareVideo() {
const deadline = await Utils.getDDL();
const start = Date.now();
let boundVideo = null;
let stopObserve = () => { };
let reopenAttempts = 0;
try {
while (Date.now() - start < deadline) {
Utils.dismissPopups();
const video = document.querySelector('video');
const display = document.querySelector('.xt_video_player_current_time_display');
if (!video) {
// 播放器被关闭或视频元素被销毁,重新打开视频框
const videoBox = document.querySelector('.video-box');
if (videoBox && !videoBox.innerText.includes('已完成')) {
this.panel.log('播放器被关闭,正在重新打开');
videoBox.click();
boundVideo = null;
}
reopenAttempts++;
if (reopenAttempts >= 4) {
this.panel.log('播放器恢复失败,刷新页面重试');
location.reload();
return false;
}
await Utils.sleep(2000);
continue;
}
reopenAttempts = 0;
if (!display) {
// 播放器加载中,等待渲染
await Utils.sleep(800);
continue;
}
if (video !== boundVideo) {
stopObserve();
Player.applySpeed();
Player.mute();
boundVideo = video;
stopObserve = Player.observePause(video);
}
const times = display.innerText || '';
const [nowTime, totalTime] = times.split(' / ');
if (nowTime && totalTime && nowTime === totalTime) return true;
await Utils.sleep(800);
}
return false;
} finally {
stopObserve();
}
}
async handleCourseware(course) {
const tableData = course.parentNode?.parentNode?.parentNode?.__vue__?.tableData;
const deadlinePassed = (tableData?.deadline || tableData?.end) ? (tableData.deadline < Date.now() || tableData.end < Date.now()) : false;
if (deadlinePassed) {
this.panel.log(`${course.querySelector('h2')?.innerText || '课件'} 已结课,跳过`);
this.updateProgress(this.outside + 1, 0);
return;
}
course.click();
await Utils.sleep(3000);
// 检测"查看课件"按钮(课件概况页专用)
const checkBtn = document.querySelector('.ppt_img_box .check') || document.querySelector('p.check');
if (checkBtn && checkBtn.innerText?.trim() === '查看课件') {
this.panel.log('检测到"查看课件"按钮,正在点击...');
checkBtn.click();
await Utils.sleep(2000);
}
const classType = document.querySelector('.el-card__header')?.innerText || '';
const className = document.querySelector('.dialog-header')?.firstElementChild?.innerText || '课件';
if (classType.includes('PPT')) {
const slides = document.querySelector('.swiper-wrapper')?.children || [];
this.panel.log(`开始播放 PPT:${className}`);
for (let i = 0; i < slides.length; i++) {
slides[i].click();
this.panel.log(`${className}:第 ${i + 1} 张`);
await Utils.sleep(Config.pptInterval);
}
await Utils.sleep(Config.pptInterval);
const videoBoxes = document.querySelectorAll('.video-box');
if (videoBoxes?.length) {
this.panel.log('PPT 中有视频,继续播放');
for (let i = 0; i < videoBoxes.length; i++) {
if (videoBoxes[i].innerText === '已完成') {
this.panel.log(`第 ${i + 1} 个视频已完成,跳过`);
continue;
}
videoBoxes[i].click();
await Utils.sleep(2000);
await this.waitCoursewareVideo();
}
}
this.panel.log(`${className} 已播放完毕`);
} else {
const videoBox = document.querySelector('.video-box');
if (videoBox) {
videoBox.click();
await Utils.sleep(1800);
await this.waitCoursewareVideo();
this.panel.log(`${className} 视频播放完毕`);
}
}
this.updateProgress(this.outside + 1, 0);
history.back();
await Utils.sleep(1000);
}
}
// ---- pro/lms 旧版(仅做转发) ----
class ProOldRunner {
constructor(panel) {
this.panel = panel;
}
run() {
this.panel.log('准备打开新标签页...');
const leafDetail = document.querySelectorAll('.leaf-detail');
let classCount = Store.getProClassCount() - 1;
while (leafDetail[classCount] && !leafDetail[classCount].firstChild.querySelector('i').className.includes('shipin')) {
classCount++;
Store.setProClassCount(classCount + 1);
this.panel.log('课程不属于视频,已跳过');
}
leafDetail[classCount]?.click();
}
}
// ---- pro/lms 新版(主要逻辑) ----
class ProNewRunner {
constructor(panel) {
this.panel = panel;
}
async run() {
preventScreenCheck();
let classCount = Store.getProClassCount();
while (true) {
this.panel.log(`准备播放第 ${classCount} 集...`);
await Utils.sleep(2000);
const className = document.querySelector('.header-bar')?.firstElementChild?.innerText || '';
const classType = document.querySelector('.header-bar')?.firstElementChild?.firstElementChild?.getAttribute('class') || '';
const classStatus = document.querySelector('#app > div.app_index-wrapper > div.wrap > div.viewContainer.heightAbsolutely > div > div > div > div > section.title')?.lastElementChild?.innerText || '';
if (classType.includes('tuwen') && !classStatus.includes('已读')) {
this.panel.log(`正在阅读:${className}`);
await Utils.sleep(2000);
} else if (classType.includes('taolun')) {
this.panel.log(`讨论区暂不自动发帖,${className}`);
await Utils.sleep(2000);
} else if (classType.includes('shipin') && !classStatus.includes('100%')) {
this.panel.log(`2s 后开始播放:${className}`);
await Utils.sleep(2000);
let statusTimer;
let videoTimer;
try {
statusTimer = setInterval(() => {
const status = document.querySelector('#app > div.app_index-wrapper > div.wrap > div.viewContainer.heightAbsolutely > div > div > div > div > section.title')?.lastElementChild?.innerText || '';
if (status.includes('100%') || status.includes('99%') || status.includes('98%') || status.includes('已完成')) {
this.panel.log(`${className} 播放完毕`);
clearInterval(statusTimer);
statusTimer = null;
}
}, 200);
const videoWaitStart = Date.now();
videoTimer = setInterval(() => {
const video = document.querySelector('video');
if (video) {
setTimeout(() => {
Player.applySpeed();
Player.mute();
Player.observePause(video);
}, 2000);
clearInterval(videoTimer);
videoTimer = null;
} else if (Date.now() - videoWaitStart > 20000) {
location.reload();
}
}, 5000);
await Utils.sleep(8000);
await Utils.poll(() => {
const status = document.querySelector('#app > div.app_index-wrapper > div.wrap > div.viewContainer.heightAbsolutely > div > div > div > div > section.title')?.lastElementChild?.innerText || '';
return status.includes('100%') || status.includes('99%') || status.includes('98%') || status.includes('已完成');
}, { interval: 1000, timeout: await Utils.getDDL() });
} finally {
if (statusTimer) clearInterval(statusTimer);
if (videoTimer) clearInterval(videoTimer);
}
} else if (classType.includes('zuoye')) {
this.panel.log(`进入作业:${className}(暂无自动答题)`);
await Utils.sleep(2000);
} else if (classType.includes('kaoshi')) {
this.panel.log(`进入考试:${className}(不会自动答题)`);
await Utils.sleep(2000);
} else if (classType.includes('ketang')) {
this.panel.log(`进入课堂:${className}(暂无自动功能)`);
await Utils.sleep(2000);
} else {
this.panel.log(`已看过:${className}`);
await Utils.sleep(2000);
}
this.panel.log(`第 ${classCount} 集播放完毕`);
classCount++;
Store.setProClassCount(classCount);
const nextBtn = document.querySelector('.btn-next');
if (nextBtn) {
const event1 = new Event('mousemove', { bubbles: true });
event1.clientX = 9999;
event1.clientY = 9999;
nextBtn.dispatchEvent(event1);
nextBtn.dispatchEvent(new Event('click'));
} else {
localStorage.removeItem(Config.storageKeys.proClassCount);
this.panel.log('课程播放完毕 🎉');
Store.clearPendingAutoStart();
this.panel.resetStartButton('开始刷课');
break;
}
}
}
}
// ---- ai-workspace 新版学习空间 ----
class AiWorkspaceRunner {
constructor(panel) {
this.panel = panel;
}
getExerciseQuestionLabel(root) {
const tabs = AiWorkspace.getExerciseQuestionTabs(root);
const active = tabs.find(tab => /active|current|selected|is-active/.test(tab.className));
return AiWorkspace.normalizeText(active?.innerText || '');
}
// 获取要跳转回去的目标地址
getReturnUrl() {
const pending = Store.getPendingAutoStart();
const route = AiWorkspace.getRoute();
if (!pending || !route) return '';
if (pending.classroomId !== route.classroomId) return '';
console.log(`returnUrl:${pending.returnUrl}`)
return pending.returnUrl || '';
}
async autoSelect() {
// 进入学习空间的方式有两种:可以处理两种不同的逻辑,增加兼容性
const returnUrl = this.getReturnUrl()
// 1. 从学习目录(v2/pro/lms)新开标签页进入后,处理完跳回目录
if (returnUrl) {
await this.returnToSource(returnUrl)
} else {
// 2. 直接在学习空间页面点击「开始刷课」:按左侧目录找当前项,再点下一项
this.panel.log('检测到是从学习空间页面点击开始刷课,继续下一学习单元');
this.source = AiWorkspace.getAllScourse();
const boxes = Array.from(this.source || []);
if (!boxes.length) {
this.panel.warn('未找到左侧学习目录,停止');
return;
}
let activateIndex = boxes.findIndex(el => {
const leaf = el.querySelector('.leaf-item') || el.firstElementChild || el;
return leaf.classList.contains('is-active')
|| !!el.querySelector('.leaf-item.is-active')
|| !!el.querySelector('.is-active');
});
if (activateIndex < 0) {
activateIndex = boxes.findIndex(el => (el.innerText || '').includes(AiWorkspace.getActiveLeafTitle()));
}
this.activateIndex = activateIndex;
const nextIndex = activateIndex < 0 ? 0 : activateIndex + 1;
this.panel.log(`当前目录序号:${activateIndex < 0 ? '未识别' : activateIndex + 1}/${boxes.length},准备进入第 ${nextIndex + 1} 项`);
await this.handleNext(nextIndex)
}
}
// 获取父窗口对象 window.opener
getSourceWindow() {
try {
if (!window.opener || window.opener.closed) return null;
if (window.opener.location.origin !== location.origin) return null;
return window.opener;
} catch (_) {
return null;
}
}
async returnToSource(returnUrl) {
this.panel.log('媒体播放完成,返回课程目录页继续匹配');
await Utils.sleep(1200);
const sourceWindow = this.getSourceWindow();
console.log(sourceWindow);
if (sourceWindow) {
try {
sourceWindow.location.href = returnUrl;
sourceWindow.focus();
window.close();
return true;
} catch (e) {
console.error("跳转父窗口异常", e);
}
}
// if (location.href !== returnUrl) {
// location.href = returnUrl;
// } else {
// history.back();
// }
// return true;
}
async handleMedia(route) {
const title = AiWorkspace.getActiveLeafTitle() || `${route.type} ${route.leafId}`;
this.panel.log(`开始播放:${title}`);
const ready = await Utils.poll(() => Boolean(AiWorkspace.getMedia()), { interval: 500, timeout: 20000 });
let media = AiWorkspace.getMedia();
if (!ready || !media) {
this.panel.log('未找到视频/音频元素,停止当前轮次');
return false;
}
const playbackState = { completed: false };
const shouldResume = () => !playbackState.completed;
let stopObserve = () => { };
if (media.tagName.toLowerCase() === 'video') {
Player.applySpeed();
Player.mute();
stopObserve = Player.observePause(media, shouldResume);
} else {
Player.applyMediaDefault(media);
}
const stopKeepAlive = AiWorkspace.keepAlive(shouldResume);
this.panel.log(`已接管播放器:${media.tagName.toLowerCase()},目标倍速 ${Config.playbackRate}x,静音开启`);
try {
let startTime = Number(media.currentTime || 0);
const started = await Utils.poll(() => {
const currentMedia = AiWorkspace.getMedia();
if (currentMedia) media = currentMedia;
if (!media) return false;
const currentTime = Number(media.currentTime || 0);
return currentTime > startTime + 0.5 || (!media.paused && media.readyState >= 2 && currentTime > startTime + 0.2);
}, { interval: 500, timeout: 15000 });
if (!started) {
this.panel.log('未确认到视频实际开始播放,停止当前轮次');
return false;
}
startTime = Number(media.currentTime || 0);
let resolveEnded;
const endedPromise = new Promise(resolve => {
resolveEnded = resolve;
});
const onEnded = () => {
playbackState.completed = true;
resolveEnded(true);
};
media.addEventListener('ended', onEnded);
const done = await Promise.race([
endedPromise,
Utils.poll(() => {
if (playbackState.completed) return true;
const currentMedia = AiWorkspace.getMedia();
if (currentMedia) media = currentMedia;
if (AiWorkspace.isPlayerDone(media, { startTime, minPlayedDelta: 3 })) {
playbackState.completed = true;
return true;
}
return false;
}, { interval: 1000, timeout: await Utils.getDDL() })
]);
media.removeEventListener('ended', onEnded);
playbackState.completed = true;
if (!done) {
this.panel.log('等待播放完成超时,停止当前轮次');
return false;
}
} finally {
stopObserve();
stopKeepAlive();
}
this.panel.log(`${title} 播放完成`);
return true;
}
async solveExerciseQuestion(root, label = '') {
const questionRoot = AiWorkspace.getExerciseQuestionBody(root);
if (!questionRoot) {
this.panel.log('未找到题目容器,停止当前轮次');
return false;
}
if (AiWorkspace.isExerciseAnswered(questionRoot)) {
this.panel.log(`${label || '当前题目'} 已完成,跳过`);
return true;
}
const listContainer = questionRoot.querySelector('.list-inline.list-unstyled-radio')
|| questionRoot.querySelector('.list-unstyled.list-unstyled-radio')
|| questionRoot.querySelector('.list-unstyled')
|| questionRoot.querySelector('ul.list')
|| questionRoot.querySelector('[class*="option-list"]')
|| questionRoot.querySelector('[class*="answer-list"]')
|| questionRoot.querySelector('.el-radio-group')
|| questionRoot.querySelector('.el-checkbox-group')
|| questionRoot.querySelector('ul')
|| questionRoot.querySelector('[role="radiogroup"]');
const optionCount = Solver.getOptionElements(listContainer).length;
if (!optionCount) {
this.panel.log(`${label || '当前题目'} 未找到选项,跳过`);
return false;
}
const questionCanvas = await Solver.capture(questionRoot);
if (!questionCanvas) {
this.panel.log(`${label || '当前题目'} 截图失败,跳过`);
return false;
}
const maxRetry = 3;
for (let retryCount = 0; retryCount < maxRetry; retryCount++) {
try {
if (retryCount > 0) this.panel.log(`${label || '当前题目'} 重试 ${retryCount}/${maxRetry - 1}`);
this.panel.log('🤖 请求 AI 获取答案...');
const aiText = await Solver.askAI('', optionCount, questionCanvas);
const ok = await Solver.autoSelectAndSubmit(aiText, questionRoot);
if (!ok) throw new Error('自动作答未完成');
await Utils.sleep(1200);
return true;
} catch (err) {
this.panel.log(`AI 答题失败:${err}`);
if (retryCount < maxRetry - 1) await Utils.sleep(5000);
}
}
return false;
}
async advanceExerciseQuestion(root, previousFingerprint = '') {
const currentRoot = AiWorkspace.getExerciseContainer() || root;
const currentQuestion = AiWorkspace.getExerciseQuestionBody(currentRoot);
const currentFingerprint = AiWorkspace.normalizeText(currentQuestion?.innerText || '').slice(0, 120);
if (currentFingerprint && currentFingerprint !== previousFingerprint) {
this.panel.log('页面已自动进入下一题,不再点击下一题');
return true;
}
const nextBtn = AiWorkspace.getExerciseActionButton(currentRoot, /下一题|下一道|下一步/);
if (!nextBtn) return false;
nextBtn.click();
return Utils.poll(() => {
const latestRoot = AiWorkspace.getExerciseContainer() || currentRoot;
const questionRoot = AiWorkspace.getExerciseQuestionBody(latestRoot);
const fingerprint = AiWorkspace.normalizeText(questionRoot?.innerText || '').slice(0, 120);
return fingerprint && fingerprint !== previousFingerprint;
}, { interval: 500, timeout: 5000 });
}
async handleExercise(route) {
const featureFlags = Store.getFeatureConf();
if (!featureFlags.autoAI) {
this.panel.log('已关闭 AI 自动答题,作业将直接跳过');
return true;
}
const ready = await Utils.poll(() => Boolean(AiWorkspace.getExerciseContainer()), { interval: 500, timeout: 20000 });
const root = AiWorkspace.getExerciseContainer();
if (!ready || !root) {
this.panel.log('未找到作业容器,停止当前轮次');
return false;
}
this.panel.log(`开始处理作业:${AiWorkspace.getActiveLeafTitle() || route.leafId}`);
const tabs = AiWorkspace.getExerciseQuestionTabs(root, { visibleOnly: false });
if (tabs.length) {
this.panel.log(`检测到题目索引 ${tabs.length} 个,按题号顺序作答`);
for (let i = 0; i < tabs.length; i++) {
const currentRoot = AiWorkspace.getExerciseContainer() || root;
const currentTabs = AiWorkspace.getExerciseQuestionTabs(currentRoot, { visibleOnly: false });
const currentTab = currentTabs[i];
if (!currentTab) {
this.panel.log(`第 ${i + 1} 题入口不存在,跳过`);
continue;
}
currentTab.scrollIntoView({ block: 'nearest' });
await Utils.sleep(400);
currentTab.click();
await Utils.sleep(1200);
await this.solveExerciseQuestion(AiWorkspace.getExerciseContainer() || currentRoot, `第 ${i + 1} 题`);
}
return true;
}
this.panel.log('未找到题号列表,尝试只处理当前题并按下一题推进');
let previousFingerprint = '';
for (let i = 0; i < 20; i++) {
const currentRoot = AiWorkspace.getExerciseContainer() || root;
const questionRoot = AiWorkspace.getExerciseQuestionBody(currentRoot);
const fingerprint = AiWorkspace.normalizeText(questionRoot?.innerText || '').slice(0, 120);
if (!fingerprint) break;
if (i > 0 && fingerprint === previousFingerprint) break;
await this.solveExerciseQuestion(currentRoot, this.getExerciseQuestionLabel(currentRoot) || `第 ${i + 1} 题`);
previousFingerprint = fingerprint;
const moved = await this.advanceExerciseQuestion(currentRoot, fingerprint);
if (!moved) break;
}
return true;
}
// 讨论 / forum 页:点开后视为已学习,再进入下一单元
async handleForum(route) {
const title = AiWorkspace.getActiveLeafTitle() || TypeLabel[route.type] || '讨论';
this.panel.log(`进入讨论区:${title}`);
await Utils.sleep(1500);
try {
window.scrollTo(0, document.body.scrollHeight);
await Utils.sleep(600);
window.scrollTo(0, 0);
} catch (_) { }
this.panel.log(`讨论「${title}」已浏览,进入下一学习单元(不自动发言)`);
await Utils.sleep(800);
return true;
}
// 直接在ai-workspace页面处理课程的逻辑
async handleNext(count) {
const boxes = Array.from(this.source || AiWorkspace.getAllScourse() || []);
if (!boxes.length) {
this.panel.warn('课程列表为空');
return;
}
if (count >= boxes.length) {
this.panel.log('课程刷完啦 🎉');
this.panel.resetStartButton('刷完啦~');
Store.clearPendingAutoStart();
return;
}
const next = boxes[count];
const title = (next.querySelector('.leaf-item-title') || next).innerText?.replace(/\s+/g, ' ').trim() || `第${count + 1}项`;
this.panel.log(`进入下一单元:${title}`);
const clickable = next.querySelector('.leaf-item') || next.firstElementChild || next;
clickable.click();
await Utils.sleep(2000);
await this.run(false)
}
async run(preventScreenCheckSwitch = true) {
// 仅开启一次防切屏
if (preventScreenCheckSwitch) preventScreenCheck();
const route = AiWorkspace.getRoute();
if (!route) {
this.panel.log('当前页面已离开学习空间');
return;
}
if (!route.leafId) {
this.panel.log('未能识别当前知识点');
return;
}
let ok = false;
const rawType = String(route.type || '').toLowerCase();
const isDiscussLike = rawType.includes('discuss')
|| rawType.includes('forum')
|| rawType.includes('taolun')
|| rawType.includes('topic');
if (route.type === 'video' || route.type === 'audio') {
ok = await this.handleMedia(route);
} else if (route.type === 'exercise') {
ok = await this.handleExercise(route);
} else if (isDiscussLike) {
ok = await this.handleForum(route);
} else {
const typeLabel = TypeLabel[route.type] || route.type;
this.panel.log(`当前类型为「${typeLabel}」,暂不自动处理,已跳过`);
await Utils.sleep(2000);
ok = true;
}
if (!ok) this.panel.warn("(该视频可能已经刷完了),即将跳过开始下一个");
// 继续下一个
await this.autoSelect()
}
}
// ---- 路由 ----
function start() {
// ---- ai-workspace获取课程根目录信息并保存(处理完一个课程重定向到根目录) ----
const classroomId = Utils.getCurrentClassroomId();
const returnUrl = Utils.returnUrl()
Store.setPendingAutoStart(classroomId, returnUrl);
const aiRoute = AiWorkspace.getRoute();
if (aiRoute) {
const typeLabel = TypeLabel[aiRoute.type] || aiRoute.type;
panel.log(`正在匹配处理逻辑:学习空间/${typeLabel}`);
new AiWorkspaceRunner(panel).run();
return;
}
// ---- ai-workspace end
const url = location.host;
const path = location.pathname.split('/');
const matchURL = `${url}${path[0]}/${path[1]}/${path[2]}`;
panel.log(`正在匹配处理逻辑:${matchURL}`);
if (matchURL.includes('yuketang.cn/v2/web') || matchURL.includes('gdufemooc.cn/v2/web')) {
new V2Runner(panel).run();
} else if (matchURL.includes('yuketang.cn/pro/lms') || matchURL.includes('gdufemooc.cn/pro/lms')) {
if (document.querySelector('.btn-next')) {
new ProNewRunner(panel).run();
} else {
new ProOldRunner(panel).run();
}
} else {
panel.resetStartButton('开始刷课');
panel.log('当前页面非刷课页面,应匹配:学习目录(/v2/web)、旧版学习页(/pro/lms)或学习空间(/ai-workspace/lms-graph)');
}
}
// ---- 启动 ----
async function boot() {
if (Utils.inIframe()) return;
await Utils.waitForMountTarget();
try {
Config.playbackRate = Store.getPlaybackRate();
panel = createPanel();
panel.log(`雨课堂刷课助手 v${Config.version} 已加载`);
panel.log(`当前默认倍速:${Config.playbackRate === 1 ? '单倍' : '双倍'}(${Config.playbackRate}x)`);
panel.setStartHandler(start);
const pendingAutoStart = Store.getPendingAutoStart();
const currentClassroomId = Utils.getCurrentClassroomId();
if (
pendingAutoStart
&& Utils.isSupportedLearningPage()
&& currentClassroomId
&& pendingAutoStart.classroomId === currentClassroomId
) {
panel.log(`检测到跨页面跳转,自动恢复刷课:课堂 ${currentClassroomId}`);
setTimeout(() => panel.start(), 1200);
}
} catch (err) {
console.error('面板初始化失败:', err);
}
}
boot();
})();