// ==UserScript==
// @name 新国开/国家开放大学/一网一免费自动刷课
// @namespace 一心向善
// @description 支持自动访问线上链接、查看资料附件、观看视频、自动查看页面、自动参与发帖回帖。调用内部接口实现!
// @version 2.2.3
// @author 一心向善
// @match *://lms.ouchn.cn/course/*
// @original-author 一心向善
// @original-license GPL-1.0
// @license GPL-1.0
// @grant none
// ==/UserScript==
(() => {
'use strict';
// ============ 配置 ============
const CONFIG = {
videoSpeed: 2,
randomDelayMin: 5000,
randomDelayMax: 8000,
autoPlayVideo: true,
muteVideo: true,
autoNext: false,
nextDelay: 3000,
enablePanel: true,
statChunk: 300, // 观看时长统计分片:每 300 秒一片(降低请求数避免限流)
statInterval: 3000 // 统计请求间隔(毫秒)
};
const notificationTypes = {
material: '参考资料', web_link: '线上链接', online_video: '音视频教材', slide: '微课',
lesson: '录播教材', homework: '作业', forum: '讨论区', chatroom: 'iSlide 直播',
questionnaire: '调查问卷', page: '页面', course_invite: '課程邀請', scorm: 'SCORM'
};
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
const sleep = async (min = CONFIG.randomDelayMin, max = CONFIG.randomDelayMax) =>
delay(Math.floor(Math.random() * (max - min)) + min);
// ============ API ============
const API = {
getGlobalData() {
const g = window.globalData || {};
return {
course: g.course || {},
user: g.user || {},
dept: g.dept || {},
courseRoles: g.courseRoles || ['student']
};
},
// 统计上报:与官方前端完全一致的 text/plain + 同源 Cookie
// 429/404/5xx/网络错误 统一重试(平台偶发路由到旧节点会返回 404,重试即可成功)
postStatistics(url, body, attempt = 1) {
const fullUrl = url.startsWith('http') ? url : `${location.origin}${url}`;
const MAX_ATTEMPTS = 4;
return fetch(fullUrl, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
body
}).then(async (res) => {
if (res.ok) return { status: res.status };
// 429/404/408/5xx 可重试
const retryable = res.status === 429 || res.status === 404 || res.status === 408 || res.status >= 500;
if (retryable && attempt < MAX_ATTEMPTS) {
await delay(1500 * attempt); // 1.5s, 3s, 4.5s
return API.postStatistics(url, body, attempt + 1);
}
let msg = '';
try { msg = (await res.text()).slice(0, 100); } catch (e) {}
throw new Error(`请求失败 [HTTP ${res.status}]${msg ? ' ' + msg : ''}`);
}).catch(async (err) => {
// 网络层错误(Failed to fetch 等)同样重试
if (attempt < MAX_ATTEMPTS) {
await delay(1500 * attempt);
return API.postStatistics(url, body, attempt + 1);
}
throw err;
});
},
// 学习访问时长 user-visits(字段与官方逐一对齐)
addLearningBehavior(activityId, activityType = 'online_video', visitDuration = 60) {
const { course, user, dept } = this.getGlobalData();
const masterCourseId = course.masterCourseId || course.id;
const body = JSON.stringify({
user_id: String(user.id),
org_id: user.orgId,
course_id: course.id,
activity_id: activityId,
visit_duration: visitDuration,
is_teacher: false,
browser: 'chrome',
user_agent: navigator.userAgent,
master_course_id: masterCourseId,
activity_type: activityType,
auto_interval: true,
org_name: user.orgName,
org_code: user.orgCode,
user_no: user.userNo,
user_name: user.name,
course_code: course.courseCode,
course_name: course.name,
dep_id: dept.id,
dep_name: dept.name,
dep_code: dept.code
});
return this.postStatistics('/statistics/api/user-visits', body);
},
// 视频观看时长 online-videos(字段与官方逐一对齐)
addVideoLearningRecords({ start_at, end_at, duration, activity_id, upload_id, module_id }) {
const { course, user, dept } = this.getGlobalData();
const masterCourseId = course.masterCourseId || course.id;
const body = JSON.stringify({
user_id: user.id,
org_id: user.orgId,
course_id: course.id,
module_id: module_id ?? null,
is_final: null,
is_formative: null,
activity_id: activity_id,
upload_id: upload_id,
reply_id: null,
comment_id: null,
forum_type: '',
action_type: 'play',
is_teacher: false,
is_student: true,
ts: Date.now(),
user_agent: navigator.userAgent,
meeting_type: 'online_video',
start_at: start_at,
end_at: end_at,
duration: duration ?? (end_at - start_at),
master_course_id: masterCourseId,
org_name: user.orgName,
org_code: user.orgCode,
user_no: user.userNo,
user_name: user.name,
course_code: course.courseCode,
course_name: course.name,
dep_id: dept.id,
dep_name: dept.name,
dep_code: dept.code
});
return this.postStatistics('/statistics/api/online-videos', body);
},
// 学习活动行为
postLearningActiVities(activityId, activityType, isOpen, activityName = null) {
const { course, user, dept, courseRoles } = this.getGlobalData();
const payload = JSON.stringify({
org_id: user.orgId,
user_id: user.id,
course_id: course.id,
enrollment_role: courseRoles[0],
is_teacher: false,
activity_id: activityId,
activity_type: activityType,
activity_name: activityName,
module: null,
action: isOpen ? 'open' : 'close',
ts: Date.now(),
user_agent: navigator.userAgent,
mode: 'normal',
channel: 'web',
target_info: {},
master_course_id: course.id,
org_name: user.orgName,
org_code: user.orgCode,
user_no: user.userNo,
user_name: user.name,
course_code: course.courseCode,
course_name: course.name,
dep_id: dept.id,
dep_name: dept.name,
dep_code: dept.code
});
return new Promise((resolve) => {
$.ajax({
url: `${location.origin}/statistics/api/learning-activity`,
data: payload,
type: 'POST',
contentType: 'application/json',
dataType: 'JSON',
success: resolve,
error: (xhr, st, et) => resolve(xhr) // 失败也不阻断
});
});
},
// 活动完成上报(决定"已完成"的关键接口)
postActivitiesRead(activityId, extraData = {}) {
return new Promise((resolve, reject) => {
$.ajax({
type: 'POST',
url: `${location.origin}/api/course/activities-read/${activityId}`,
contentType: 'application/json',
dataType: 'JSON',
data: JSON.stringify(extraData),
success: resolve,
error: (xhr) => reject(new Error(`活动完成上报失败 [HTTP ${xhr.status}]`))
});
});
},
getCategoryId(activityId) {
return new Promise((resolve) => {
$.get(`${location.origin}/api/forum/${activityId}/category?fields=id`, {}, resolve);
});
},
postForum(categoryId, { title, content } = {}) {
const t = title || `好好学习${Date.now()}`;
const c = content || `
好好学习,天天向上。${Date.now()}
`;
return new Promise((resolve, reject) => {
$.ajax({
type: 'POST',
url: `${location.origin}/api/topics`,
contentType: 'application/json',
dataType: 'JSON',
data: JSON.stringify({ title: t, content: c, category_id: categoryId, uploads: [] }),
success: resolve,
error: (xhr) => reject(new Error(`发帖失败 [HTTP ${xhr.status}]`))
});
});
}
};
// ============ 视频播放 ============
const VideoPlayer = {
async waitForVideo(timeout = 15000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const v = this.findVideo();
if (v) return v;
await delay(500);
}
return null;
},
findVideo() {
let v = document.querySelector('video');
if (v) return v;
// 同源 iframe 内查找
const frames = document.querySelectorAll('iframe');
for (const f of frames) {
try {
const doc = f.contentDocument;
if (doc) {
const iv = doc.querySelector('video');
if (iv) return iv;
}
} catch (e) {}
}
return null;
},
async playUntilComplete(video, onProgress) {
video.playbackRate = CONFIG.videoSpeed;
video.muted = CONFIG.muteVideo;
try { await video.play(); } catch (e) {}
const total = video.duration || 0;
const maxWaitMs = Math.min(180000, Math.max((total * 1000) / CONFIG.videoSpeed, 15000));
const startTime = Date.now();
let last = -1;
while (Date.now() - startTime < maxWaitMs) {
if (video.ended || video.currentTime >= total * 0.95) {
return { ok: true, duration: total || video.currentTime };
}
const progress = total > 0 ? Math.round((video.currentTime / total) * 100) : 0;
if (progress >= last + 20) {
onProgress && onProgress(progress, video.currentTime, total);
last = progress;
}
await delay(Math.max(1000, 3000 / CONFIG.videoSpeed));
if (video.paused && !video.ended && video.currentTime < total * 0.95) {
try { await video.play(); } catch (e) {}
}
}
return { ok: false, duration: video.currentTime || total };
}
};
// ============ 控制面板 ============
class LogPanel {
constructor() {
this.el = null;
this.onStartCb = null;
}
mount() {
if (document.getElementById('guokaiPanel')) return;
const wrapper = document.querySelector('.wrapper') || document.body;
wrapper.insertAdjacentHTML('beforeend', this.html());
this.el = document.getElementById('guokaiPanel');
this.bindEvents();
this.makeDraggable();
}
html() {
return `
`;
}
bindEvents() {
const $el = this.el;
$el.querySelector('#gkClose').onclick = () => $el.remove();
$el.querySelector('#gkMin').onclick = () => {
const body = $el.querySelector('#guokaiBody');
const footer = $el.querySelector('#gkFooter');
const hidden = body.style.display === 'none';
body.style.display = hidden ? 'flex' : 'none';
footer.style.display = hidden ? 'block' : 'none';
};
$el.querySelectorAll('.speed-btn').forEach((btn) => {
btn.onclick = () => {
$el.querySelectorAll('.speed-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
CONFIG.videoSpeed = parseFloat(btn.dataset.speed);
const v = VideoPlayer.findVideo();
if (v) v.playbackRate = CONFIG.videoSpeed;
this.log(`倍速已切换为 ${CONFIG.videoSpeed}x`, 'info');
};
});
$el.querySelector('#gkAutoPlay').onchange = (e) => {
CONFIG.autoPlayVideo = e.target.checked;
this.log(`自动播放: ${CONFIG.autoPlayVideo ? '开启' : '关闭'}`, 'info');
};
$el.querySelector('#gkMute').onchange = (e) => {
CONFIG.muteVideo = e.target.checked;
const v = VideoPlayer.findVideo();
if (v) v.muted = CONFIG.muteVideo;
this.log(`静音播放: ${CONFIG.muteVideo ? '开启' : '关闭'}`, 'info');
};
$el.querySelector('#gkClear').onclick = () => {
$el.querySelector('#gkLog').innerHTML = '';
};
$el.querySelector('#gkStartBtn').onclick = () => {
if (this.onStartCb) this.onStartCb();
};
}
makeDraggable() {
const header = this.el.querySelector('#guokaiHeader');
let sx = 0, sy = 0, ox = 0, oy = 0, dragging = false;
header.addEventListener('mousedown', (e) => {
dragging = true;
sx = e.clientX; sy = e.clientY;
const r = this.el.getBoundingClientRect();
ox = r.left; oy = r.top;
this.el.style.right = 'auto';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
this.el.style.left = `${ox + e.clientX - sx}px`;
this.el.style.top = `${oy + e.clientY - sy}px`;
});
document.addEventListener('mouseup', () => { dragging = false; });
}
onStart(cb) { this.onStartCb = cb; }
setStartBtnDisabled(disabled) {
const btn = this.el && this.el.querySelector('#gkStartBtn');
if (btn) {
btn.disabled = disabled;
btn.textContent = disabled ? '⏳ 刷课中...' : '🚀 开始刷课';
}
}
setProgress(pct) {
const fill = this.el && this.el.querySelector('#gkProgressFill');
if (fill) fill.style.width = `${Math.max(0, Math.min(100, pct))}%`;
}
log(msg, type = 'info') {
console.log('[国开助手]', msg);
if (!this.el) return;
const box = this.el.querySelector('#gkLog');
if (!box) return;
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false });
const div = document.createElement('div');
div.className = `log-item ${type}`;
div.innerHTML = `${now}${msg}`;
box.appendChild(div);
box.scrollTop = box.scrollHeight;
while (box.children.length > 300) box.removeChild(box.firstChild);
}
}
// ============ 自动刷课 ============
class CourseAutoLearn {
constructor(courseId) {
this.courseId = courseId;
this.panel = new LogPanel();
this.panel.mount();
this.running = false;
}
start() {
if (this.running) {
this.panel.log('正在运行中,请勿重复点击', 'warning');
return;
}
this.running = true;
this.panel.setStartBtnDisabled(true);
this.run().catch((e) => {
this.panel.log(`❌ 运行异常: ${e.message}`, 'error');
console.error(e);
}).finally(() => {
this.running = false;
this.panel.setStartBtnDisabled(false);
});
}
async run() {
this.panel.log('===== 开始刷课 =====', 'info');
const before = await this.getCompleteness();
const completedIds = (before.completed_result?.completed?.learning_activity || []).map(Number);
this.panel.log(`当前课程进度: ${before.study_completeness ?? 0}%`, 'info');
const modules = await this.getModules();
for (const module of modules) {
this.panel.log(`📚 模块: ${module.name} (${module.id})`, 'info');
let activities = [];
try {
activities = await this.getAllActivities(module.id);
} catch (e) {
this.panel.log(` 模块活动获取失败: ${e.message}`, 'warning');
continue;
}
for (const act of activities) {
if (completedIds.includes(Number(act.id))) {
this.panel.log(` ✓ 已完成跳过: ${act.title}`, 'success');
continue;
}
try {
await this.handleActivity(act, module);
this.panel.log(` ✅ 完成: ${act.title}`, 'success');
} catch (e) {
this.panel.log(` ❌ 失败: ${act.title} - ${e.message}`, 'error');
}
await sleep();
}
}
const after = await this.getCompleteness();
this.panel.log(`🎉 全部处理完毕,课程进度: ${before.study_completeness ?? 0}% → ${after.study_completeness ?? 0}%`, 'success');
this.panel.setProgress(after.study_completeness ?? 100);
}
async getCompleteness() {
return new Promise((resolve) => {
$.get(`${location.origin}/api/course/${this.courseId}/my-completeness`, (data) => resolve(data || {}));
});
}
async getModules() {
return new Promise((resolve) => {
$.get(`${location.origin}/api/courses/${this.courseId}/modules`, (data) => {
resolve((data && data.modules) || []);
});
});
}
async getAllActivities(moduleId) {
return new Promise((resolve, reject) => {
$.get(`${location.origin}/api/course/${this.courseId}/all-activities?module_ids=[${moduleId}]&activity_types=learning_activities,exams,classrooms`,
(data) => resolve((data && data.learning_activities) || []))
.fail((xhr) => reject(new Error(`HTTP ${xhr.status}`)));
});
}
async handleActivity(activity, module) {
const { id, title, type, is_open, uploads, syllabus_id } = activity;
try { await API.postLearningActiVities(id, type, is_open, title); } catch (e) {}
switch (type) {
case 'page':
case 'web_link':
await API.postActivitiesRead(id);
break;
case 'online_video':
await this.handleVideoActivity(id, title, uploads, syllabus_id, module && module.id);
break;
case 'material':
for (const u of (uploads || [])) {
try { await API.postActivitiesRead(id, { upload_id: u.id }); } catch (e) {}
}
break;
case 'forum': {
const cat = await API.getCategoryId(id);
const catId = cat && cat.topic_category && cat.topic_category.id;
if (catId) await API.postForum(catId);
break;
}
default:
this.panel.log(` ⚠️ 暂不支持的类型: ${notificationTypes[type] || type}`, 'warning');
}
}
async handleVideoActivity(activityId, title, uploads, syllabusId, moduleId) {
const panel = this.panel;
panel.log(` 📋 视频: ${title}`, 'info');
let actualDuration = 0;
try { await API.postActivitiesRead(activityId); } catch (e) {}
// 1. 真实播放视频(若当前页面有播放器)
if (CONFIG.autoPlayVideo) {
const video = await VideoPlayer.waitForVideo(8000);
if (video) {
try {
const res = await VideoPlayer.playUntilComplete(video, (p, cur, total) => {
panel.log(` 播放进度: ${p}% (${Math.round(cur)}/${Math.round(total)}秒)`, 'info');
});
actualDuration = Math.round(res.duration || video.duration || 0);
panel.log(res.ok ? ` 🎬 视频播放完成 (${actualDuration}秒)` : ` ⏱️ 播放超时,已播放 ${actualDuration}秒`, res.ok ? 'success' : 'warning');
} catch (e) {
panel.log(` 📺 播放异常: ${e.message}`, 'error');
actualDuration = Math.round(video.duration || 0);
}
} else {
// 无播放器(课程列表页)→ 走纯接口上报
console.log('[国开助手] 当前页面无播放器,使用接口上报视角时长');
}
}
// 2. 观看时长统计(分片心跳,限速+失败冷却)
const uploadList = Array.isArray(uploads) ? uploads : [];
let videoOk = 0, videoFail = 0, visitOk = 0, visitFail = 0, lastErr = '';
let reported = 0;
for (const upload of uploadList) {
for (const vinfo of (upload.videos || [])) {
const duration = Math.round(vinfo.duration || actualDuration || 0);
if (duration <= 0) continue;
const CHUNK = CONFIG.statChunk;
for (let start = 0; start < duration; start += CHUNK) {
const end = Math.min(start + CHUNK, duration);
try {
await API.addVideoLearningRecords({
activity_id: activityId, upload_id: upload.id, module_id: moduleId,
start_at: start, end_at: end, duration: end - start
});
videoOk++;
} catch (e) {
videoFail++; lastErr = e.message;
console.warn('[国开助手] online-videos 上报失败:', e.message);
await delay(10000); // 失败冷却,避免连续触发限流
}
try {
await API.addLearningBehavior(activityId, 'online_video', end - start);
visitOk++;
} catch (e) {
visitFail++; lastErr = e.message;
console.warn('[国开助手] user-visits 上报失败:', e.message);
}
await delay(CONFIG.statInterval);
}
// 3. 完成上报(决定"已完成")
try {
await API.postActivitiesRead(activityId, { start: 0, end: duration });
reported++;
} catch (e) {
console.warn('[国开助手] 完成上报失败:', e.message);
}
}
}
if (videoFail === 0 && visitFail === 0) {
panel.log(` 📈 观看时长统计已上报 (视频${videoOk}片/访问${visitOk}片)`, 'success');
} else {
panel.log(` 📈 观看时长统计: 视频 ${videoOk}成功/${videoFail}失败, 访问 ${visitOk}成功/${visitFail}失败${lastErr ? ' (' + lastErr + ')' : ''}`, 'info');
}
if (reported > 0) {
panel.log(` 📊 已报告观看进度 0-${actualDuration || '?'}秒`, 'success');
}
}
}
// ============ 初始化 & SPA 监听 ============
let currentCourseId = null;
let currentLearn = null;
function getCourseId() {
const input = document.querySelector('#courseId');
if (input && input.value) return input.value;
const m = location.pathname.match(/\/course\/(\d+)/);
return m ? m[1] : null;
}
function initPanel() {
const courseId = getCourseId();
if (!courseId) return;
if (!CONFIG.enablePanel) return;
if (currentLearn && currentCourseId === courseId && document.getElementById('guokaiPanel')) return;
currentCourseId = courseId;
currentLearn = new CourseAutoLearn(courseId);
currentLearn.panel.log(`🎓 课程ID: ${courseId}`, 'info');
currentLearn.panel.log('💡 视频播放页面点击"开始刷课"按钮开始自动学习才有效果', 'info');
currentLearn.panel.onStart(() => currentLearn.start());
}
function boot() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(initPanel, 500));
} else {
setTimeout(initPanel, 500);
}
// SPA 路由变化监听
let lastUrl = location.href;
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(initPanel, 800);
}
if (!document.getElementById('guokaiPanel')) {
currentLearn = null;
initPanel();
}
}, 1500);
}
boot();
})();