// ==UserScript== // @name 山东省教师教育网继续教育自动学习助手 // @namespace qlt-auto-study // @version 0.8.8 // @description 山东省教师教育网(yxjc.qlteacher.com)继续教育自动挂机:解除暂停、自动切换章节、随机答题提交、课程间自动流转。兼容 Tampermonkey 与 ScriptCat。仅供个人学习交流,请遵守平台规则。 // @author WorkBuddy // @match *://yxjc.qlteacher.com/project/* // @match *://player.qlteacher.com/learning/* // @run-at document-start // @grant GM_getValue // @grant GM_setValue // @noframes // @license MIT // ==/UserScript== (function () { 'use strict'; /* ============================================================ * 0. 常量与选择器表(平台改版时优先维护此处) * ==========================================================*/ var LEARN_PATH_RE = /\/project\/([^/]+)\/lesson\/learn/; var PLAYER_URL_RE = /player\.qlteacher\.com\/learning\/([^/?#]+)\/([^/?#]+)/; var CATALOG_URL_RE = /yxjc\.qlteacher\.com\/project\/([^/]+)/; var SEL = { // 播放页 countdown: ['.count-down.ng-star-inserted', '.count-down'], video: ['video'], // 弹题 radio: ['.ant-radio-input', 'input[type="radio"].ant-radio-input'], checkbox: ['.ant-checkbox', 'input[type="checkbox"]'], btnPrimary: ['button.ant-btn-primary', '.ant-btn.ant-btn-primary', 'button.ant-btn.radius-4'], // 纯测试题/考试页 courseTag: ['.segmented-text-ellipsis.mr-16'], // 课程进度(目录页/learning 页):进度文案与进度条 progressText: ['.ant-progress-text', '[class*="progress"] [class*="text"]', '[class*="percent"]', '[class*="rate"]'], progressBar: ['.ant-progress-bg', '[class*="progress-inner"]', '[class*="progress"] [class*="bar"]', '[class*="percent"] [class*="in"]'], // 章节条目(用于"全部章节都已勾完成"的兜底判定) chapterItem: ['li.ant-list-item', '.ant-list-item', '[class*="chapter-item"]', '[class*="chapter"]', '[class*="lesson-item"]'], quizItem: ['.mb-16.ng-star-inserted'], // 题目容器候选(策略1,按 class 特征找含成组选项的容器) quizContainer: ['.mb-16.ng-star-inserted', '[class*="question"]', '[class*="quiz-item"]', '[class*="exam-item"]', '.ant-list-item', 'li'], // 答题入口按钮文本(测试页可能需先点击进入才渲染题目) entryTexts: ['开始答题', '立即答题', '进入答题', '开始考试', '进入考试', '继续答题', '继续考试', '开始测试'], // 目录页/课程页 anyButton: ['button', 'a.ant-btn', '.ant-btn'], // 通用确认弹窗按钮(按文本匹配) confirmTexts: ['确定', '确认', '提交', '知道了'], startTexts: ['继续学习', '开始学习'], startTextsFallback: ['温故知新'], doneTexts: ['已完成学习', '已完成', '100%'], // 答题相关按钮文本 answerTexts: ['查看答案', '查看解析', '答案解析', '查看正确答案', '参考答案', '显示答案'], redoTexts: ['重新作答', '再答一次', '再做一次', '重新答题', '再次作答', '重新考试', '重新测试', '再考一次', '重做'] }; /* ============================================================ * 1. Environment —— GM_* 能力探测与降级 * ==========================================================*/ var Env = (function () { var hasGM = typeof GM_getValue === 'function' && typeof GM_setValue === 'function'; var LS_PREFIX = 'qlt_auto_'; function gmGet(key, defVal) { if (hasGM) { var v = GM_getValue(key, undefined); return v === undefined ? defVal : v; } try { var raw = localStorage.getItem(LS_PREFIX + key); return raw === null ? defVal : JSON.parse(raw); } catch (e) { return defVal; } } function gmSet(key, val) { if (hasGM) { GM_setValue(key, val); return; } try { localStorage.setItem(LS_PREFIX + key, JSON.stringify(val)); } catch (e) {} } return { hasGM: hasGM, get: gmGet, set: gmSet }; })(); /* ============================================================ * 2. ConfigStore —— 配置与运行状态持久化 * ==========================================================*/ var DEFAULTS = { running: false, // 总开关 pollMs: 1500, // 主循环/轮询间隔(1.5s,兼顾响应速度与后台节流) watchdogTimeout: 120, // 看门狗超时(秒) maxRetry: 3, // 单节点重试上限 skipExam: false, // true 时考试页暂停转人工 examSmart: true, // true 时考试按"随机→读正确答案→重答"循环直到通过 debug: false, // 调试日志 aiQuiz: { enabled: false, endpoint: '', apiKey: '' } // AI 答题占位 }; var DEFAULT_RUNSTATE = { lastHeartbeat: 0, retryCount: 0, skipList: [], // 跳过节点 key 列表 catalogReload: null, // {url, time} 目录页/learn页强制刷新记录 lastDoneKey: null, // 最近完成的视频 key(循环守卫用) lastDoneTime: 0, loopCount: 0, currentProject: null, // 当前项目代号(cz2026 等),用于播放页兜底回跳 currentCourseId: null, currentVideoId: null, lockOwner: null, // 多标签锁 lockTime: 0, navLock: null, // {by,time,text,path} 点击跳转锁:点击"继续学习/开始学习"后本页长挂起,下游存活期间绝不再点 catalogMiss: 0, // 目录页连续未找到未完成章节的次数(≥2 判定本课程完成) catalogCourse: null, // 当前目录页对应的课程编码(变化时重置计数) learnMiss: 0, // learn 页连续未找到未完成课程的次数 learnReturns: [], // 最近"课程完成→回退 learn 页"的时间戳(循环守卫:学习页仍显示未完成时防打转) stats: { doneVideos: 0, doneCourses: 0, answeredQuizzes: 0 } }; var Config = { get: function (k) { return Env.get('cfg_' + k, DEFAULTS[k]); }, set: function (k, v) { Env.set('cfg_' + k, v); }, all: function () { var o = {}; for (var k in DEFAULTS) o[k] = this.get(k); return o; } }; var RunState = { get: function () { var s = Env.get('runState', {}); var out = {}; for (var k in DEFAULT_RUNSTATE) out[k] = s[k] !== undefined ? s[k] : DEFAULT_RUNSTATE[k]; if (!out.stats) out.stats = { doneVideos: 0, doneCourses: 0, answeredQuizzes: 0 }; return out; }, set: function (patch) { var s = this.get(); for (var k in patch) s[k] = patch[k]; Env.set('runState', s); return s; } }; /* ============================================================ * 3. 工具函数 * ==========================================================*/ function qs(selList, root) { root = root || document; var list = Array.isArray(selList) ? selList : [selList]; for (var i = 0; i < list.length; i++) { try { var el = root.querySelector(list[i]); if (el) return el; } catch (e) {} } return null; } function qsa(selList, root) { root = root || document; var list = Array.isArray(selList) ? selList : [selList]; var out = []; var seen = typeof Set !== 'undefined' ? new Set() : null; for (var i = 0; i < list.length; i++) { try { var nodes = root.querySelectorAll(list[i]); for (var j = 0; j < nodes.length; j++) { if (seen) { if (!seen.has(nodes[j])) { seen.add(nodes[j]); out.push(nodes[j]); } } else if (out.indexOf(nodes[j]) < 0) out.push(nodes[j]); } } catch (e) {} } return out; } function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } function jitter(range) { return Math.floor(Math.random() * range); } // 条件轮询:条件一满足立即返回 true,超时返回 false。 // 用"短步长 + 早走"替代固定 sleep,是提速的关键——内容快就早走,内容慢才等到上限 async function waitFor(cond, timeoutMs, stepMs) { var step = stepMs || 250; var deadline = Date.now() + (timeoutMs || 3000); while (Date.now() < deadline) { try { if (cond()) return true; } catch (e) {} await sleep(step); } try { return !!cond(); } catch (e) { return false; } } function randItem(arr) { return arr[Math.floor(Math.random() * arr.length)]; } function textOf(el) { return el ? (el.innerText || el.textContent || '').trim() : ''; } function realClick(el) { if (!el) return false; try { el.scrollIntoView({ block: 'center', behavior: 'instant' }); } catch (e) {} try { ['mousedown', 'mouseup', 'click'].forEach(function (type) { el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })); }); } catch (e) {} try { el.click(); } catch (e) {} return true; } function clickByText(root, selList, texts) { var btns = qsa(selList, root); for (var i = 0; i < btns.length; i++) { var t = textOf(btns[i]); for (var j = 0; j < texts.length; j++) { if (t.indexOf(texts[j]) >= 0 && !btns[i].disabled) { realClick(btns[i]); return btns[i]; } } } return null; } function nowTime() { var d = new Date(); function p(n) { return n < 10 ? '0' + n : '' + n; } return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); } // 视频元素是否真实可播(测试题页有时会残留一个空