// ==UserScript==
// @name 雨课堂刷课助手
// @namespace http://tampermonkey.net/
// @version 3.0.4
// @description 针对雨课堂视频进行自动播放,配置AI自动答题
// @author 风之子
// @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==
(() => {
'use strict';
let panel; // UI 面板实例后置初始化
// ---- 脚本配置,用户可修改 ----
const Config = {
version: '3.0.4', // 版本号
playbackRate: 2, // 视频播放倍速
pptInterval: 3000, // ppt翻页间隔
storageKeys: { // 使用者勿动
progress: '[雨课堂脚本]刷课进度信息',
ai: 'ykt_ai_conf',
proClassCount: 'pro_lms_classCount',
feature: 'ykt_feature_conf', // 是否开启AI作答/自动评论
pendingAutoStart: 'ykt_pending_auto_start'
}
};
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]
|| '';
},
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-chat",
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));
},
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 = '340px';
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(`
展开
- ⭐ 脚本支持:雨课堂所有版本
- 🤖 支持模型:DeepSeek、Kimi(Moonshot)、通义千问、OpenAI、Claude(Anthropic)
- 📢 使用必读:自动答题需先点击[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')
};
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('作者:niuwh.cn(重构版 by Codex)');
});
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 defaultAI = { url: 'https://api.deepseek.com/chat/completions', key: 'sk-xxxxxxx', model: 'deepseek-chat', 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();
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,
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 speedBtn = document.querySelector('xt-speedlist xt-button') || document.getElementsByTagName('xt-speedlist')[0]?.firstElementChild?.firstElementChild;
const speedWrap = document.getElementsByTagName('xt-speedbutton')[0];
if (speedBtn && speedWrap) {
speedBtn.setAttribute('data-speed', rate);
speedBtn.setAttribute('keyt', `${rate}.00`);
speedBtn.innerText = `${rate}.00X`;
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);
speedBtn.click();
} else if (document.querySelector('video')) {
document.querySelector('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 => {
if (!(media instanceof HTMLMediaElement)) return false;
const rect = media.getBoundingClientRect();
const isVisible = rect.width > 0 && rect.height > 0;
return isVisible || media.tagName.toLowerCase() === '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() {
return document.querySelector('.leaf-item.is-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()) {
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 (!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 disabled = root.querySelector('.el-button.el-button--info.is-disabled.is-plain')
|| root.querySelector('button[disabled]');
if (disabled) 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)));
}
};
// ---- 防切屏 ----
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 = {
async recognize(element) {
if (!element) return '无元素';
try {
panel.log('正在截图...');
const canvas = await html2canvas(element, {
useCORS: true,
logging: false,
scale: 2,
backgroundColor: '#ffffff'
});
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();
} catch (err) {
console.error('OCR error:', err);
panel.log(`OCR 失败: ${err.message || '网络错误'}`);
return 'OCR识别出错';
}
},
async askAI(ocrText, optionCount = 0) {
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';
return new Promise((resolve, reject) => {
if (!API_KEY || API_KEY.includes('sk-xxxx')) {
const msg = '⚠️ 请在 [AI配置] 中填写有效的 API Key';
panel.log(msg);
reject(msg);
return;
}
const maxChar = String.fromCharCode(65 + optionCount - 1);
const rangeStr = optionCount ? `A-${maxChar}` : 'A-D';
const prompt = `
你是专业做题助手,请分析 OCR 文本,判断题型后给出答案。
强约束:
1) 本题只有 ${optionCount || '若干'} 个选项,范围 ${rangeStr}
2) 忽略 OCR 错误的选项字母,按出现顺序映射 A/B/C/D...
3) 输出格式必须包含“正确答案:”前缀,例如 正确答案:A 或 正确答案:ABD 或 正确答案:对/错
题目内容:
${ocrText}
`;
const systemPrompt = "你是一个只输出答案的助手。判断题输出'对'或'错',选择题输出字母。";
// 构建认证 header
const authHeader = AUTH_METHOD === 'x-api-key'
? { 'x-api-key': API_KEY }
: { 'Authorization': `Bearer ${API_KEY}` };
if (API_FORMAT === 'anthropic') {
// Anthropic API 格式
const headers = {
'Content-Type': 'application/json',
...authHeader
};
// 只有原生 Anthropic API 才需要 anthropic-version,代理通常不需要
if (API_URL.includes('api.anthropic.com')) {
headers['anthropic-version'] = '2023-06-01';
}
const requestBody = {
model: MODEL_NAME,
max_tokens: 1024,
system: systemPrompt,
messages: [
{ role: 'user', content: prompt }
]
};
// 调试日志
console.log('[AI请求] URL:', API_URL);
console.log('[AI请求] Headers:', headers);
console.log('[AI请求] Body:', requestBody);
panel.log(`请求 ${API_URL}...`);
GM_xmlhttpRequest({
method: 'POST',
url: API_URL,
headers,
data: JSON.stringify(requestBody),
data: JSON.stringify({
model: MODEL_NAME,
max_tokens: 1024,
system: systemPrompt,
messages: [
{ role: 'user', content: prompt }
]
}),
timeout: 120000, // 120秒,思考模型需要更长响应时间
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);
// Anthropic 返回格式: content[0].text
const answerText = json.content?.[0]?.text || json.choices?.[0]?.message?.content;
resolve(answerText);
} catch (e) {
reject('JSON 解析失败');
}
} else {
const err = `请求失败: HTTP ${res.status} - ${res.responseText}`;
panel.log(err);
reject(err);
}
},
onerror: () => reject('网络错误'),
ontimeout: () => reject('请求超时')
});
} else {
// OpenAI API 格式(默认)
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: prompt }
],
temperature: 0.1
}),
timeout: 120000, // 120秒,思考模型需要更长响应时间
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}`;
panel.log(err);
reject(err);
}
},
onerror: () => reject('网络错误'),
ontimeout: () => reject('请求超时')
});
}
});
},
async autoSelectAndSubmit(aiResponse, itemBodyElement) {
const match = aiResponse.match(/(?:正确)?答案[::]?\s*([A-F]+(?:[,,][A-F]+)*|[对错]|正确|错误)/i);
if (!match) {
panel.log('⚠️ 未提取到有效选项,请人工检查');
return;
}
let answerRaw = match[1].replace(/[,,]/g, '').trim();
const map = { 'A': 0, 'B': 1, 'C': 2, 'D': 3, 'E': 4, 'F': 5 };
let targetIndices = [];
if (answerRaw === '对' || answerRaw === '正确') {
targetIndices = [0];
} else if (answerRaw === '错' || answerRaw === '错误') {
targetIndices = [1];
} else {
for (const char of answerRaw.toUpperCase()) {
if (map[char] !== undefined) targetIndices.push(map[char]);
}
}
if (!targetIndices.length) return;
panel.log(`✅ AI 建议选:${answerRaw}`);
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('ul') ||
itemBodyElement.querySelector('[role="radiogroup"]');
if (!listContainer) {
panel.log('⚠️ 未找到选项容器');
return;
}
const options = listContainer.querySelectorAll('li, .option-item, .answer-item, [class*="option-item"], [class*="answer-item"]');
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();
} else {
panel.log('⚠️ 未找到提交按钮,请手动提交');
}
}
};
// ---- 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 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} 个,类型 ${type},标题:${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) {
this.panel.log(`开始处理${typeText}:${item.querySelector('h2')?.innerText || ''}`);
item.click();
await Utils.sleep(1200);
// 检查是否开启自动评论功能
const featureFlags = Store.getFeatureConf();
if (!featureFlags.autoComment) {
this.panel.log(`${typeText}已查看,但未开启自动回复功能`);
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('进入作业,启动 OCR + 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('ul.list');
if (listContainer) optionCount = listContainer.querySelectorAll('li').length;
const ocrResult = await Solver.recognize(targetEl);
if (ocrResult && ocrResult.length > 5) {
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(ocrResult, optionCount);
await Solver.autoSelectAndSubmit(aiText, targetEl);
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 '';
return pending.returnUrl || '';
}
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() {
const returnUrl = this.getReturnUrl();
if (!returnUrl) return false;
this.panel.log('媒体播放完成,返回课程目录页继续匹配');
await Utils.sleep(1200);
const sourceWindow = this.getSourceWindow();
if (sourceWindow) {
try {
sourceWindow.location.href = returnUrl;
sourceWindow.focus();
window.close();
return true;
} catch (_) {
// ignore and fallback
}
}
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('ul')
|| questionRoot.querySelector('[role="radiogroup"]');
const optionCount = listContainer
? listContainer.querySelectorAll('li, .option-item, .answer-item, [class*="option-item"], [class*="answer-item"]').length
: 0;
if (!optionCount) {
this.panel.log(`${label || '当前题目'} 未找到选项,跳过`);
return false;
}
const ocrResult = await Solver.recognize(questionRoot);
if (!ocrResult || ocrResult.length <= 5) {
this.panel.log(`${label || '当前题目'} OCR 结果过短,跳过`);
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(ocrResult, optionCount);
await Solver.autoSelectAndSubmit(aiText, questionRoot);
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 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);
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);
const currentTab = currentTabs[i];
if (!currentTab) break;
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;
}
async run() {
preventScreenCheck();
const route = AiWorkspace.getRoute();
if (!route) {
this.panel.log('当前页面已离开 ai-workspace/lms-graph');
return;
}
if (!route.leafId) {
this.panel.log('未能识别当前知识点');
return;
}
let ok = false;
if (route.type === 'video' || route.type === 'audio') {
ok = await this.handleMedia(route);
} else if (route.type === 'exercise') {
ok = await this.handleExercise(route);
} else {
this.panel.log(`当前类型为 ${route.type},最小方案暂不自动处理`);
return;
}
if (!ok) return;
await this.returnToSource();
}
}
// ---- 路由 ----
function start() {
const classroomId = Utils.getCurrentClassroomId();
const returnUrl = location.pathname.includes('/v2/web/studentLog/') ? location.href : '';
Store.setPendingAutoStart(classroomId, returnUrl);
const aiRoute = AiWorkspace.getRoute();
if (aiRoute) {
panel.log(`正在匹配处理逻辑:ai-workspace/lms-graph/${aiRoute.type}`);
new AiWorkspaceRunner(panel).run();
return;
}
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 {
panel = createPanel();
panel.log(`雨课堂刷课助手 v${Config.version} 已加载`);
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();
})();