// ==UserScript== // @name 高校考试平台 · 学时秒刷助手 // @namespace gaoxiaokaoshi-auto // @version 1.4.0 // @description 带参数面板:记录工厂秒刷 / 后台多视频并行播放 / 自动参加练习(题库自动采集答案);每课刷到「需求+多余量」即停,全部达标后自动停止并提示。 // @author - // @match *://www.gaoxiaokaoshi.com/* // @match *://gaoxiaokaoshi.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_notification // @grant unsafeWindow // @noframes // @run-at document-idle // ==/UserScript== /* ============================================================================ * 平台后台机制(实测逆向结论,2026-10-15) * ---------------------------------------------------------------------------- * 1. 课程列表 /Study/LibraryStudyList.aspx * showframe('课程名', 课程ID) -> 点击后在学习页 iframe 里打开 * 2. 学习页 /Study/LibraryStudy.aspx?Id=<课程ID>&PlanId=<计划ID> * 每次打开学习页,服务端都会【新建一条学习记录】,其 ID 在隐藏域 hidNewId * 其它隐藏域:hidRefId(课程ID) / hidStudentId(学号) / hidStudyTime(学时快照) * / hidSessionID(会话ID) / hidPassLine(固定 1,即 Mins) * 3. 官方计时 播放器 play 事件 -> startclock() 每秒累计,满 60 秒 JSONP 上报一次: * /Study/updateTime.ashx?Id=<记录ID>&pTime=<累计秒>&Mins=1&refId=<课程ID> * &StudentId=<学号>&StyDyTime=<已有分钟>&SessionId=<会话ID> * 4. 【关键】服务端入账规则(逆出来的阈值) * · updateTime.ashx 无签名/无时间戳/无 nonce,任何请求都返回 200 * · 但「记录创建后经过的真实时间」必须 >= 上报的秒数(pTime),否则这次上报被丢弃 * · 满足条件后,每条记录上报 pTime=60 就能给该课程入账 1~2 分钟 * · 多门课程可以同时入账(实测 3 门并行全部成功) * 5. 因此秒刷 = 「记录工厂」 * 批量新建 N 条记录 -> 等 60 秒(养够年龄)-> 逐条上报 pTime=60 -> 约入账 N~2N 分钟 * 真实耗时 ≈ 60 秒/轮,与课程门数无关(因为并行) * 6. 【分页坑】课程列表有 4 页,翻页控件是 * 下一页 * 只发 __EVENTTARGET 会被服务端忽略(永远返回第 1 页); * 必须在 POST 请求体里带 PageSplit1$BtnNext 字段才会真正翻页。 * * 已排除的错误方向:同一记录高频连报、不等时间立即上报、伪造进度 Cookie、 * 直接跳学习页再报 —— 全部无效。 * * 7. 【自动练习】课程列表每行「参加练习」= showExam(ExamID) * chkExam.aspx?ExamID=&SiteType=2&IsContinue=0 * -> ExamMain.aspx(frameset:topFrame=ExamHeader.aspx、 * mainFrame=about:blank、postFrame=about:blank) * -> ExamHeader 里的 JS 再把 mainFrame 设成 ExamDo.aspx?..&Rnd=<随机数> * 【关键】纯 fetch 拿不到题:ExamDo 依赖 ExamMain 建立的考试会话, * 直接 GET 只会返回空白壳(nSerial=0、无 tmdefendian)。 * 所以必须让浏览器真实加载。 * 【又一个大坑】交卷后 postFrame 里的脚本执行的是 top.location 跳转, * 若把试卷放进本页的隐藏 iframe,交卷会把【本页整个标签页】跳走 * (脚本连同面板一起消失)。实测确认。 * 因此改为「复用一个独立弹窗窗口」承载试卷:弹窗自己才是 top, * 跳转只发生在弹窗里,本页与脚本始终不动。 * (弹窗必须在用户点击的手势里同步打开,否则会被拦截;已存在同名 * 窗口时后续可直接复用,无需手势。) * 8. 答题页 ExamDo.aspx 每道题 index=N(N 从 1 开始)隐藏域: * index_N / ksid_N(试题ID,稳定主键) / tmtx_N(题型) / tmfs_N(分值) * tmdefendian_N(选项文本,';' 分隔) / tmjo_N(标准答案,练习模式恒为 'z'=隐藏) * 选项控件 name="tmda_N",value=a/b/c/d;判断题 value=0/1; * 填空/问答是 name="tmda_N" 的 textarea / text。 * 9. 交卷:mainFrame.btnSumbit_onclick()(无 confirm,可直接调用) * -> 整窗跳到 ExamMessage.aspx?...&Clerk_kscj_ID=<记录ID>&Message=交卷成功! * 10.【题库来源】ExamMessage 页里那个「查看答卷」链接是 HTML 转义的 * (&Clerk_kscj_ID=...),直接拼会 404; * 真实地址 = /ExamList/ExamPage/viewExam.aspx?ExamID=&Clerk_kscj_ID=<记录ID> * 该页有全局 JS 数组 tm_arr: * tm_arr[i][0]=试题ID [1]=题型ID [2]=题干 [3]=分值 * [4]=选项个数 [5]=选项文本(';'分隔) [9]=解析 [10]=标准答案 * [12]=考生答案 * 【得分也在这页】「总得分 0.0 正确率 0%」,交卷成功页只有文字没有分数。 * 实测 tm_arr[i][5] 的选项顺序与 ExamDo 的 tmdefendian_N 一致, * 标准答案字母按同一顺序,因此可直接按字母勾选同名的 tmda_N。 * 11. 练习可无限重考(无次数限制),因此策略是「两遍过」: * 第一遍(题库缺题时)随便交一次卷 -> 从 viewExam 采集答案入库 * 第二遍按题库作答交卷 -> 满分;已入库的题只需一遍。 * ========================================================================= */ (function () { 'use strict'; const BASE = location.origin; const LIST_URL = BASE + '/Study/LibraryStudyList.aspx?rnd=100'; const STUDY_URL = BASE + '/Study/LibraryStudy.aspx'; const EXAM_CHK = BASE + '/ExamList/chkExam.aspx'; const EXAM_VIEW = BASE + '/ExamList/ExamPage/viewExam.aspx'; /* ------------------------------ 默认参数 ------------------------------ */ const DEFAULTS = { enabled: true, mode: 'factory', // factory=记录工厂秒刷 ; play=后台真实播放 autoRun: false, // 打开页面自动开始 /* 账号相关(自动识别失败时用下面的兜底值) */ autoPlan: true, // 自动识别 PlanId / StudentId planId: 31, // 军事理论(2025) studentId: 3315812, /* 通用 */ maxRounds: 10, // 最大轮次(课程多时一轮刷不完,需要多轮) verifyWait: 18, // 上报后等待核对列表页的秒数 pollSec: 30, // 播放模式的轮询间隔 /* —— 记录工厂参数(秒刷核心) —— */ reportSec: 60, // 每条记录上报的秒数 pTime(>60 极易过刷,别动) waitSec: 62, // 建记录后等待秒数,必须 >= reportSec recFactor: 1.0, // 建记录条数的微调系数(1.0 = 按预估产出刚好打到上限) maxRecPerCourse: 60, // 单课单轮建记录上限 makeDelayMs: 120, // 建记录之间的间隔 parallelCourses: true, // true=多门课同一批(同步多课);false=逐课串行 batchCourses: 6, // 并行时每轮最多处理几门课(控制一轮的记录总量) reportDelayMs: 60, // 上报之间的间隔 overMin: 2, // 【达标即停】每门课刷到 需求 + 该值 就立即停止刷这门课 notifyDone: true, // 全部刷完后弹窗 / 系统通知提示用户 /* —— 后台播放参数(同步多个视频) —— */ playSlots: 3, // 同时播放几门课(1~4) playMute: true, // 静音播放 seekNearEnd: false, // 播放时跳到临近结尾(快速触发进度) slotRefreshSec: 20, // 播放模式多久检查一次是否该换课 /* —— 自动练习 —— */ autoExam: false, // 刷完课后自动接着参加练习 examSkipPassed: true, // 跳过已满分的练习 examWaitSec: 45, // 每份试卷等待加载的最长秒数 examDelaySec: 2 // 每门练习之间的间隔(秒) }; /* ------------------------------ 参数持久化 ------------------------------ */ const store = { get(k, d) { try { if (typeof GM_getValue === 'function') return GM_getValue(k, d); const v = localStorage.getItem('gxks_' + k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } }, set(k, v) { try { if (typeof GM_setValue === 'function') return GM_setValue(k, v); localStorage.setItem('gxks_' + k, JSON.stringify(v)); } catch (e) { /* ignore */ } } }; let params = Object.assign({}, DEFAULTS); for (const k in DEFAULTS) params[k] = store.get(k, DEFAULTS[k]); function saveParams() { for (const k in DEFAULTS) store.set(k, params[k]); } let running = false; let stopped = false; const finishedSet = new Set(); // 已达标的课程 ID(本次会话内硬跳过,防止陈旧数据反复重建记录) /* ------------------------------ 工具函数 ------------------------------ */ const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const stripTags = (s) => (s || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim(); const toMin = (s) => { const m = /(\d+)/.exec(s || ''); return m ? parseInt(m[1], 10) : 0; }; function field(html, key) { let m = new RegExp('id="' + key + '"[^>]*value="([^"]*)"').exec(html) || new RegExp('value="([^"]*)"[^>]*id="' + key + '"').exec(html); return m ? m[1] : ''; } async function httpGet(url) { const r = await fetch(url, { credentials: 'include' }); return await r.text(); } async function httpPost(url, obj) { const r = await fetch(url, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(obj).toString() }); return await r.text(); } function formFields(html) { const f = {}; const tags = html.match(/]+>/gi) || []; for (const tag of tags) { const n = /name="([^"]+)"/i.exec(tag); if (!n) continue; const v = /value="([^"]*)"/i.exec(tag); f[n[1]] = v ? v[1] : ''; } return f; } function parsePlan(html) { const sel = /]*id="ddlClass"[^>]*>([\s\S]*?)<\/select>/i.exec(html); if (!sel) return ''; const opt = /]*selected[^>]*value="(\d+)"/i.exec(sel[1]) || /]*value="(\d+)"[^>]*selected/i.exec(sel[1]); return opt ? opt[1] : ''; } function parseRows(html) { const out = []; const rows = html.match(/]*>[\s\S]*?<\/tr>/gi) || []; for (const tr of rows) { const m = /showframe\('([^']+)',(\d+)\)/.exec(tr); if (!m) continue; const em = /showExam\(\s*'?(\d+)'?/.exec(tr); // 「参加练习」入口,无练习时是 showExam(0) const tds = (tr.match(/]*>[\s\S]*?<\/td>/gi) || []).map(stripTags); if (tds.length < 5) continue; out.push({ name: m[1], id: m[2], examId: em ? em[1] : '0', req: toMin(tds[2]), done: toMin(tds[3]), status: tds[4] }); } return out; } /* ------------------------------ 列表 / 课程 ------------------------------ */ // 页码文本,形如
  • 1/4
  • function pageInfo(html) { const m = /
  • \s*(\d+)\/(\d+)\s*<\/li>/.exec(html); return m ? { cur: parseInt(m[1], 10), total: parseInt(m[2], 10) } : { cur: 1, total: 1 }; } /* 遍历课程列表的所有页。 * 【重要】本平台翻页控件是 链接, * 服务端只认请求体里的 PageSplit1$BtnNext 字段; * 只发 __EVENTTARGET 会被忽略 —— 永远返回第 1 页(这就是"第二页识别不了"的根因)。 */ async function walkListPages(onPage) { let html = await httpGet(LIST_URL); let prevIds = null; let page = 1; while (page <= 20) { const info = pageInfo(html); const rows = parseRows(html); const ids = rows.map(r => r.id).join(','); if (onPage) onPage(html, rows, info); if (prevIds === ids) break; // 页面内容没变 -> 翻页失效,停止防死循环 prevIds = ids; if (info.cur >= info.total) break; // 已到最后一页 const f = formFields(html); f.__EVENTTARGET = 'PageSplit1$BtnNext'; f.__EVENTARGUMENT = ''; f['PageSplit1$BtnNext'] = '下一页'; // ← 必须带上,否则服务端不翻页 html = await httpPost(LIST_URL, f); page++; } return page; } async function fetchCourses() { const seen = new Set(); const uniq = []; let planId = ''; let pages = 1; let pageCount = 0; await walkListPages((html, rows, info) => { if (!planId) planId = parsePlan(html); pages = info.total; pageCount++; for (const c of rows) { if (seen.has(c.id)) continue; seen.add(c.id); uniq.push(c); } }); return { courses: uniq, planId: planId, pages: pages, scanned: pageCount }; } /* 读取所有页的"已完成分钟"。 * 【关键】用 ledgerMax 记住"历史见过的最高值": * 服务端入账有延迟,下一次读可能比上一次低,若直接当缺口用就会 * 把已经刷进去的时间当成没刷,于是每轮重复建记录 -> 每轮多刷一大截。 * 学时只增不减,因此取历史最大值是安全的。 */ const ledgerMax = {}; async function listDoneMap() { const done = {}; await walkListPages((html, rows) => { for (const c of rows) done[c.id] = c.done; }); for (const id in done) { ledgerMax[id] = Math.max(ledgerMax[id] || 0, done[id]); done[id] = ledgerMax[id]; } return done; } /* 本地台账:本轮已经"成功上报"了多少条记录。 * 实测每条成功上报的记录最终至少会给该课入账 1 分钟, * 所以用它当【下限】可以防止"服务端入账慢、列表还是旧值"时下一轮 * 把已经刷进去的时间当成没刷、再重复建一整批 —— 那正是每轮多刷一大截的根源。 */ const floorCredit = {}; /* 单门课的权威进度(分钟),且只增不减 */ async function courseDone(cid) { const m = await listDoneMap(); return Math.max( m[cid] !== undefined ? m[cid] : 0, floorCredit[cid] || 0 ); } /* ------------------------------ 记录工厂 ------------------------------ */ async function makeRecord(cid) { const url = STUDY_URL + '?Id=' + cid + '&PlanId=' + params.planId + '&_t=' + Date.now(); const h = await httpGet(url); const rec = { newId: field(h, 'hidNewId'), passLine: field(h, 'hidPassLine') || '1', refId: field(h, 'hidRefId') || String(cid), studentId: field(h, 'hidStudentId') || String(params.studentId), studyTime: field(h, 'hidStudyTime') || '0', sessionId: field(h, 'hidSessionID') }; if (rec.studentId) params.studentId = rec.studentId; return rec.newId ? rec : null; } async function report(rec, pTime) { const u = BASE + '/Study/updateTime.ashx?Id=' + rec.newId + '&pTime=' + pTime + '&Mins=' + rec.passLine + '&refId=' + rec.refId + '&StudentId=' + rec.studentId + '&StyDyTime=' + rec.studyTime + '&SessionId=' + rec.sessionId; try { const r = await fetch(u, { credentials: 'include' }); return r.status === 200; } catch (e) { return false; } } /* 每条记录预估能入账的分钟数(取实测上限)。 * 实测 pTime=60 时每条记录入账 1~2 分钟,这里按【上限 2】估算, * 于是"建 floor(余量/2) 条"即使条条都按上限入账也不会越过上限, * 只会从下方逼近 —— 天然满足"比要求多 overMin 分钟就停"。 */ function expectedPerRecord() { return Math.max(1, params.reportSec / 60) * 2; } function markFinished(c, cur) { if (finishedSet.has(c.id)) return; finishedSet.add(c.id); log(' ✔ [' + c.name + '] ' + cur + '/' + c.req + ' 分钟 已达标' + (cur > c.req ? '(已超要求 ' + (cur - c.req) + ' 分钟)' : '') + ',停止刷这门课'); } async function buildBatch(courses, doneMap) { const groups = []; const exp = expectedPerRecord(); for (const c of courses) { if (!running) break; if (finishedSet.has(c.id)) continue; const cur = Math.max(doneMap[c.id] !== undefined ? doneMap[c.id] : 0, c.done, floorCredit[c.id] || 0); const gap = c.req - cur; // 距离"达标"还差多少 const room = (c.req + params.overMin) - cur; // 距离"硬上限"还剩多少 if (gap <= 0) { markFinished(c, cur); continue; } // 只用 floor:exp 是单条记录的"最多入账分钟数",floor(room/exp) 条 // 即使条条都按上限入账,总和也不可能超过 room,从数学上封死过刷 let n = Math.floor(room / exp * Math.min(1, params.recFactor)); n = Math.max(1, Math.min(params.maxRecPerCourse, n)); log(' [' + c.name + '] 当前 ' + cur + '/' + c.req + ' 分钟,缺口 ' + gap + ',到上限还剩 ' + room + ' -> 建 ' + n + ' 条记录'); const recs = []; for (let i = 0; i < n; i++) { if (!running) break; const rec = await makeRecord(c.id); if (rec) { rec.cid = c.id; rec.cname = c.name; recs.push(rec); } await sleep(params.makeDelayMs); } if (recs.length) groups.push({ c: c, recs: recs }); } return groups; } /* 逐课上报。【核心保证:多出 overMin 分钟就立刻停】 * 之前是「按估算一次性全报完」,中途那个 finishedSet 检查是死代码 * (上报过程中没人更新它),所以只要估算偏一点就整批刷超 —— 这就是 * "还是多刷好多 min"的根因。现在改成边报边核: * · curReal = 服务端真实进度(列表页,且只增不减) * · curPess = 本地最坏估计(每条按最多 exp 分钟记),一旦够要求就停报 * 只要真实进度到要求,立刻 markFinished 并丢弃该课剩余记录; * 接近上限时每条都用真实进度校准,把越界上界压到「单条记录 ≈ 2 分钟」。 */ async function flushBatch(groups) { let total = 0; for (const g of groups) total += g.recs.length; if (!total) return 0; log('养记录:等待 ' + params.waitSec + ' 秒(每条记录年龄需 >= ' + params.reportSec + ' 秒)...'); await sleep(params.waitSec * 1000); const exp = expectedPerRecord(); // 单条最多入账分钟数(pTime=60 时为 2) const NEAR = exp * 2; // 距上限不足这个值 -> 逐条用真实进度校准 let ok = 0; for (const g of groups) { if (!running) break; const cap = g.c.req + params.overMin; if (finishedSet.has(g.c.id)) { log(' [' + g.c.name + '] 已达标,丢弃 ' + g.recs.length + ' 条剩余记录'); continue; } let curReal = await courseDone(g.c.id); if (curReal >= g.c.req) { markFinished(g.c, curReal); continue; } let curPess = curReal; let acc = 0; log(' [' + g.c.name + '] 真实 ' + curReal + '/' + g.c.req + ' 分钟,待报 ' + g.recs.length + ' 条 (pTime=' + params.reportSec + '),硬上限 ' + cap + ' 分钟'); for (const rec of g.recs) { if (!running) break; if (curReal >= g.c.req) { markFinished(g.c, curReal); break; } if (curPess >= g.c.req) { log(' [' + g.c.name + '] 已报够(最坏估计 ' + curPess + ' 分钟 ≥ 要求 ' + g.c.req + '),停止剩余上报以防过刷'); break; } if (await report(rec, params.reportSec)) { ok++; acc++; } curPess += exp; await sleep(params.reportDelayMs); if (cap - curPess < NEAR) { // 接近上限 -> 用真实值校准 await sleep(2500); const t = await courseDone(g.c.id); if (t > curReal) curReal = t; if (curReal > curPess) curPess = curReal; } } if (acc) floorCredit[g.c.id] = (floorCredit[g.c.id] || 0) + acc; // 记账防下轮重刷 if (curReal >= g.c.req) markFinished(g.c, curReal); } log('上报完成 ' + ok + '/' + total + ' 条'); return ok; } async function printProgress(tag) { const done = await listDoneMap(); const lines = []; for (const c of lastCourses) { const d = done[c.id] !== undefined ? done[c.id] : 0; lines.push(' ' + (d >= c.req ? '✔' : '·') + ' ' + c.name + ' ' + d + '/' + c.req + ' 分钟'); } log(tag + '\n' + lines.join('\n')); return done; } let lastCourses = []; async function factoryRun() { const r = await fetchCourses(); lastCourses = r.courses; if (params.autoPlan && r.planId) params.planId = r.planId; if (!lastCourses.length) { log('未解析到课程,请确认已登录'); return; } log('识别到 ' + lastCourses.length + ' 门课(' + r.scanned + '/' + r.pages + ' 页),PlanId=' + params.planId + ',学号=' + params.studentId + '\n达标规则:每门课刷到 需求+' + params.overMin + ' 分钟即停;每条记录预估入账 ' + expectedPerRecord() + ' 分钟'); if (params.reportSec > 60) { log('⚠ 上报秒数=' + params.reportSec + ' 大于 60:单条记录可能一次入账 ' + (params.reportSec / 60) + ' 分钟,就是它把课刷到几百分钟的,建议改回 60'); } // 一进来先把已经达标的课登记掉 let done = await listDoneMap(); for (const c of lastCourses) { const d = done[c.id] !== undefined ? done[c.id] : c.done; if (d >= c.req) finishedSet.add(c.id); } await printProgress('当前进度:'); let noProgress = 0; for (let round = 1; round <= params.maxRounds && running; round++) { const pending = lastCourses.filter(c => !finishedSet.has(c.id)); if (!pending.length) { finishAll(); return; } const limit = Math.max(1, parseInt(params.batchCourses, 10) || 1); const todo = params.parallelCourses ? pending.slice(0, limit) : pending; log('===== 第 ' + round + ' 轮:共 ' + pending.length + ' 门未达标,本轮处理 ' + todo.length + ' 门 ====='); const before = {}; for (const c of todo) before[c.id] = done[c.id] !== undefined ? done[c.id] : 0; if (params.parallelCourses) { const groups = await buildBatch(todo, done); if (!groups.length) { log('本轮未建成记录(可能都已达标)'); break; } await flushBatch(groups); await sleep(params.verifyWait * 1000); done = await printProgress('第 ' + round + ' 轮结果:'); } else { for (const c of todo) { if (!running) break; done = await listDoneMap(); if ((done[c.id] || 0) >= c.req) { markFinished(c, done[c.id] || 0); continue; } const groups = await buildBatch([c], done); if (!groups.length) continue; await flushBatch(groups); await sleep(params.verifyWait * 1000); done = await printProgress('[' + c.name + '] 结果:'); } } // 达标登记 + 无进展判定 let grew = false; for (const c of todo) { const now = done[c.id] !== undefined ? done[c.id] : 0; if (now >= c.req) markFinished(c, now); if (now > (before[c.id] || 0)) grew = true; } if (!grew) { noProgress++; log('⚠ 本轮学时没有任何增长(第 ' + noProgress + ' 次)'); if (noProgress >= 2) { log('连续 2 轮无增长,可能被节流或 Cookie 已失效,自动停止'); break; } } else { noProgress = 0; } } // 收尾核对,决定是否提示"刷完了" done = await listDoneMap(); for (const c of lastCourses) { const d = done[c.id] !== undefined ? done[c.id] : 0; if (d >= c.req) finishedSet.add(c.id); } const rest = lastCourses.filter(c => !finishedSet.has(c.id)); if (!rest.length) finishAll(); else { log('任务结束:仍有 ' + rest.length + ' 门未达标,可再次点「开始刷课」继续:\n' + rest.map(c => ' · ' + c.name + ' ' + (done[c.id] !== undefined ? done[c.id] : 0) + '/' + c.req).join('\n')); } } /* 全部刷完:停脚本 + 醒目提示 */ function finishAll() { running = false; log('🎉🎉 全部课程已刷完(需求全部达标),脚本已自动停止 🎉🎉'); showDoneBanner('✅ 全部课程已刷完,脚本已自动停止'); notifyUser('全部课程已刷完!'); if (params.autoExam) { log('已启用「刷完课自动练习」,稍后自动开始参加练习...'); setTimeout(() => { if (!running) startExamOnly(); }, 2000); } } /* ------------------------------ 后台播放模式 ------------------------------ */ const playState = { slots: [], wrap: null, courses: [], idx: 0, done: {} }; const playSkip = new Set(); // 本次播放运行中"已分配"或"已跳过"的课程,防止重复分配/死循环 function buildPlayArea() { if (playState.wrap) return; const wrap = document.createElement('div'); wrap.id = 'gxks-play-wrap'; wrap.style.cssText = 'position:fixed;right:8px;bottom:8px;z-index:99998;display:flex;gap:6px;' + 'background:rgba(20,22,30,.82);padding:6px;border-radius:10px;max-width:46vw;flex-wrap:wrap;'; document.body.appendChild(wrap); playState.wrap = wrap; } async function loadSlot(slot, course) { slot.cid = course.id; slot.cname = course.name; slot.req = course.req; // 记住要求,达标就换下一门 slot.lastDone = playState.done[course.id] || 0; slot.stall = 0; log('[播放] 载入 ' + course.name + '(需求 ' + course.req + ' 分钟)'); const iframe = document.createElement('iframe'); iframe.style.cssText = 'width:280px;height:170px;border:1px solid #444;border-radius:6px;background:#000;'; iframe.src = STUDY_URL + '?Id=' + course.id + '&PlanId=' + params.planId + '&_t=' + Date.now(); const box = document.createElement('div'); box.style.cssText = 'display:flex;flex-direction:column;gap:2px;'; const label = document.createElement('div'); label.style.cssText = 'color:#9fe;font-size:11px;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; label.textContent = course.name; box.appendChild(iframe); box.appendChild(label); slot.box = box; playState.wrap.appendChild(box); slot.iframe = iframe; iframe.addEventListener('load', () => { setTimeout(() => { try { const w = iframe.contentWindow; const pl = w.player || w.aliplayer || w.aliPlayer; if (pl) { if (params.playMute && pl.mute) pl.mute(); if (params.seekNearEnd) { try { pl.seek((pl.getDuration ? pl.getDuration() : 999999) - 5); } catch (e) {} } if (pl.play) pl.play(); } else { const v = w.document.querySelector('video'); if (v) { v.muted = !!params.playMute; const p = v.play(); if (p && p.catch) p.catch(() => log('[播放] 浏览器拦截自动播放,请在本页点一下任意处')); } } } catch (e) { log('[播放] 启动失败:' + e.message); } }, 2500); }); } function freeSlot(slot) { if (slot.box && slot.box.parentNode) slot.box.parentNode.removeChild(slot.box); slot.iframe = null; slot.box = null; slot.cid = null; slot.cname = null; slot.req = null; slot.lastDone = 0; slot.stall = 0; } function nextPending() { while (playState.idx < playState.courses.length) { const c = playState.courses[playState.idx++]; if (finishedSet.has(c.id) || playSkip.has(c.id)) continue; const d = playState.done[c.id] !== undefined ? playState.done[c.id] : c.done; if (d < c.req) { playSkip.add(c.id); return c; } // 一旦交给槽位就不再重复分配 } return null; } async function playRun() { const r = await fetchCourses(); if (params.autoPlan && r.planId) params.planId = r.planId; playState.courses = r.courses; playState.idx = 0; playState.done = await listDoneMap(); if (!r.courses.length) { log('未解析到课程,请确认已登录'); return; } params.playSlots = Math.max(1, Math.min(4, parseInt(params.playSlots, 10) || 1)); log('后台播放模式:并发 ' + params.playSlots + ' 路,自动切换课程'); buildPlayArea(); playState.slots = []; for (let i = 0; i < params.playSlots; i++) { const s = { iframe: null, box: null, cid: null, cname: null }; playState.slots.push(s); const c = nextPending(); if (c) await loadSlot(s, c); } while (running) { await sleep(params.slotRefreshSec * 1000); if (!running) break; playState.done = await listDoneMap(); for (const s of playState.slots) { if (s.cid) { const d = playState.done[s.cid] !== undefined ? playState.done[s.cid] : 0; if (d >= s.req) { // 达标即停:立刻释放槽位,换下一门 finishedSet.add(s.cid); log('[播放] ✔ ' + s.cname + ' 已达标(' + d + '/' + s.req + ' 分钟),自动切换下一门'); freeSlot(s); } else if (d > s.lastDone) { s.lastDone = d; s.stall = 0; } else if (++s.stall >= 3) { // 连续 3 次无增长 -> 播放受阻 log('[播放] ⚠ ' + s.cname + ' 进度无增长,可能被浏览器拦截自动播放,已跳过该课'); playSkip.add(s.cid); freeSlot(s); } } if (!s.cid) { const c = nextPending(); if (c) await loadSlot(s, c); } } if (playState.slots.every(s => !s.cid)) { // 所有槽位都空了 -> 收尾判定 playState.done = await listDoneMap(); const rest = playState.courses.filter(c => (playState.done[c.id] !== undefined ? playState.done[c.id] : 0) < c.req); if (!rest.length) { finishAll(); } else { log('播放模式结束:仍有 ' + rest.length + ' 门未达标(多半是浏览器拦截了自动播放,' + '请在本页点一下任意处再重试)'); } break; } } for (const s of playState.slots) freeSlot(s); } /* ============================== 自动练习 ============================== * 详细机制见文件头部注释第 7~11 条。 * 核心:用屏幕外的隐藏 iframe 真实加载试卷(纯 fetch 拿不到题), * 每题按「题库(ksid -> 标准答案)」作答后交卷;题库缺题时先交一次 * 采集答案(viewExam 的 tm_arr),再带着答案重考一次拿满分。 * ==================================================================== */ let examBank = store.get('examBank', {}) || {}; // ksid -> { ans, opts[], tx } let examPassed = store.get('examPassed', {}) || {}; // ExamID -> 1(已满分) const saveExamBank = () => store.set('examBank', examBank); const saveExamPassed = () => store.set('examPassed', examPassed); function notifyUser(text) { if (!params.notifyDone) return; try { if (typeof GM_notification === 'function') { GM_notification({ title: '学时秒刷助手', text: text, timeout: 8000 }); } else { alert(text); } } catch (e) { /* ignore */ } } let examWin = null; // 承载试卷的独立弹窗(它自己是 top,交卷只在它内部跳转) /* 打开/复用弹窗。必须在「用户点击」的手势里同步调用,否则会被浏览器拦截。 * 同名窗口已存在时会直接返回它(不需要手势)。 */ function openExamWin() { if (examWin && !examWin.closed) return examWin; const feat = 'width=1120,height=780,left=60,top=30,menubar=no,toolbar=no,location=yes,resizable=yes,scrollbars=yes'; let w = null; try { const uw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; w = uw.open('about:blank', 'gxks_exam', feat); } catch (e) { try { w = window.open('about:blank', 'gxks_exam', feat); } catch (e2) { w = null; } } if (w) examWin = w; return w; } function examNav(w, url) { try { w.location.href = url; return true; } catch (e) { return false; } } /* 等弹窗地址变成目标页(跨页导航只能靠轮询,不能在一次同步流程里等) */ async function examWait(w, re, ms) { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (w.closed) return null; try { if (re.test(w.location.href)) return w.location.href; } catch (e) { /* ignore */ } await sleep(350); } return null; } /* 试卷是否就绪:frameset 里的 mainFrame 出现 ksid_1 才算数 */ function examMainFrame(w) { try { const mf = w.frames['mainFrame']; if (mf && mf.document && mf.document.getElementById('ksid_1')) return mf; } catch (e) { /* ignore */ } return null; } async function examWaitPaper(w, ms) { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (w.closed) return null; const mf = examMainFrame(w); if (mf) return mf; await sleep(400); } return null; } function readPaper(mf) { const doc = mf.document; const getv = (id) => { const e = doc.getElementById(id); return e ? e.value : ''; }; const out = []; for (let n = 1; n <= 300; n++) { const ks = doc.getElementById('ksid_' + n); if (!ks) break; out.push({ n: n, ksid: String(ks.value), tx: parseInt(getv('tmtx_' + n), 10) || 0, fs: getv('tmfs_' + n), defendian: getv('tmdefendian_' + n), opts: Array.prototype.slice.call(doc.querySelectorAll('input[name="tmda_' + n + '"]')), text: doc.querySelector('textarea[name="tmda_' + n + '"]') || doc.querySelector('input[type="text"][name="tmda_' + n + '"]') }); } return out; } /* 按题库答案勾选/填写;返回是否成功作答 */ function applyBankAnswer(q, bank) { const ans = String(bank.ans == null ? '' : bank.ans).trim(); if (!q.opts.length) { // 填空 / 问答 if (q.text) { q.text.value = ans; return true; } return false; } const texts = String(q.defendian || '').split(';'); const bankOpts = bank.opts || []; let toks = ans.match(/[a-zA-Z0-9]+/g) || []; if (toks.length === 1 && toks[0].length > 1) { // "abc" -> ["a","b","c"] const chars = toks[0].split(''); if (chars.every(c => q.opts.some(o => String(o.value).toLowerCase() === c.toLowerCase()))) { toks = chars; } } let hit = 0; for (const t of toks) { const tc = t.toLowerCase(); let el = q.opts.filter(o => String(o.value).toLowerCase() === tc)[0]; if (!el && /^[a-z]$/.test(tc) && bankOpts.length) { // 选项被乱序时按选项文本对齐 const want = String(bankOpts[tc.charCodeAt(0) - 97] || '').trim(); if (want) el = q.opts.filter((o, i) => String(texts[i] || '').trim() === want)[0]; } if (!el) { // 判断题 0/1 与 对/错 互认 const alt = { '0': ['错', '错误', 'false'], '1': ['对', '正确', 'true'] }[tc]; if (alt) { el = q.opts.filter((o, i) => alt.indexOf(String(texts[i] || '').trim()) >= 0 || alt.indexOf(String(o.value).toLowerCase()) >= 0)[0]; } } if (el) { el.checked = true; hit++; } } return hit > 0; } function winText(w) { try { const d = w.document; if (!d) return ''; return (d.body && (d.body.innerText || d.body.textContent)) || (d.documentElement && d.documentElement.textContent) || ''; } catch (e) { return ''; } } function readScore(t) { if (!t) return null; const m = /总得分[::\s]*([\d.]+)/.exec(t) || /总分[::\s]*([\d.]+)/.exec(t) || /得分[::\s]*([\d.]+)/.exec(t); return m ? parseFloat(m[1]) : null; } /* 拼「查看答卷」地址(实测的正确地址)。 * 交卷成功页里的链接是 HTML 转义的(&Clerk_kscj_ID=..),直接拼会 404, * 所以优先用 Clerk_Kscj_Id 自己拼;拼不出再从页面 HTML 解码后取。 */ function findViewUrl(w, examId, rid) { if (rid) return EXAM_VIEW + '?ExamID=' + examId + '&Clerk_kscj_ID=' + rid; try { const d = w.document; if (d) { const html = (d.documentElement ? d.documentElement.innerHTML : '') .split('&').join('&').split('"').join('"'); const m = /viewExam\.aspx\?[^"'<>\s\\)]+/i.exec(html); if (m) return new URL(m[0], w.location.href).href; } } catch (e) { /* ignore */ } return ''; } async function waitTmArr(w, ms) { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (w.closed) return null; try { const a = w.tm_arr; if (a && a.length) return a; } catch (e) { /* ignore */ } await sleep(300); } return null; } /* 跑一个练习,返回 { ok, score, reason } */ async function runOneExam(w, e) { const examId = e.examId; for (let attempt = 1; attempt <= 3; attempt++) { if (!running) return { ok: false, reason: '已停止' }; if (w.closed) return { ok: false, reason: '练习弹窗被关闭了' }; if (!examNav(w, EXAM_CHK + '?ExamID=' + examId + '&SiteType=2&IsContinue=0&_t=' + Date.now())) { return { ok: false, reason: '无法跳转练习弹窗' }; } const mf = await examWaitPaper(w, params.examWaitSec * 1000); if (!mf) return { ok: false, reason: '试卷加载超时(未登录 / 该练习无题)' }; const qs = readPaper(mf); if (!qs.length) return { ok: false, reason: '未解析到题目' }; for (const q of qs) { const b = examBank[q.ksid]; if (b) applyBankAnswer(q, b); } const missing = qs.filter(q => !examBank[q.ksid]).length; log(' [' + e.name + '] 第 ' + attempt + ' 次:共 ' + qs.length + ' 题,题库命中 ' + (qs.length - missing) + ' 题' + (missing ? '(缺 ' + missing + ' 题 → 本次先采集答案)' : '')); if (typeof mf.btnSumbit_onclick !== 'function') return { ok: false, reason: '未找到交卷函数' }; try { mf.btnSumbit_onclick(); } catch (err) { return { ok: false, reason: '交卷异常:' + err.message }; } const msgUrl = await examWait(w, /ExamMessage\.aspx/i, 30000); if (!msgUrl) return { ok: false, reason: '交卷后未跳转(可能被拦截)' }; await sleep(800); const ridM = /Clerk_Kscj_ID=(\d+)/i.exec(decodeURIComponent(msgUrl)); const rid = ridM ? ridM[1] : ''; // 交卷成功页只有文字没有分数 —— 分数与标准答案都在「查看答卷」页 const vurl = findViewUrl(w, examId, rid); let score = null; if (vurl) { examNav(w, vurl); const arr = await waitTmArr(w, 15000); score = readScore(winText(w)); if (arr) { let added = 0; for (let i = 0; i < arr.length; i++) { const row = arr[i] || []; const ksid = String(row[0] == null ? '' : row[0]); const ans = String(row[10] == null ? '' : row[10]).trim(); if (!ksid || !ans) continue; if (!examBank[ksid]) added++; examBank[ksid] = { ans: ans, opts: String(row[5] == null ? '' : row[5]).split(';'), tx: row[1] }; } saveExamBank(); if (added) log(' → 题库新增 ' + added + ' 道题的答案(累计 ' + Object.keys(examBank).length + ' 道)'); } else { log(' → 未读到答卷(viewExam 没加载出 tm_arr),本次无法采集答案'); } } else { log(' → 没拿到答卷地址,无法读分/采集答案'); } if (!missing) { // 本次是「整卷按题库作答」-> 这就是最终成绩 log(' [' + e.name + '] 得分:' + (score == null ? '未知' : score)); if (score != null && score >= 60) { examPassed[examId] = 1; saveExamPassed(); } return { ok: true, score: score, reason: '' }; } await sleep(params.examDelaySec * 1000); // 带着刚采集到的答案再来一遍 } return { ok: false, reason: '重试 3 次仍未完成' }; } async function examRun(w) { const r = await fetchCourses(); const exams = []; const seen = new Set(); for (const c of r.courses) { const eid = String(c.examId || ''); if (!eid || eid === '0' || seen.has(eid)) continue; seen.add(eid); exams.push({ name: c.name, examId: eid }); } if (!exams.length) { log('课程列表里没有「参加练习」入口(该计划可能没有练习)'); return; } const todo = params.examSkipPassed ? exams.filter(e => !examPassed[e.examId]) : exams; log('共 ' + exams.length + ' 个练习,已满分跳过 ' + (exams.length - todo.length) + ' 个,本次处理 ' + todo.length + ' 个(题库已存 ' + Object.keys(examBank).length + ' 道题)'); if (!todo.length) { log('所有练习都已满分,无需再刷'); return; } let ok = 0, bad = 0; for (let i = 0; i < todo.length && running; i++) { const e = todo[i]; log('---- [' + (i + 1) + '/' + todo.length + '] 练习:' + e.name + '(ExamID=' + e.examId + ')----'); let res; try { res = await runOneExam(w, e); } catch (err) { res = { ok: false, reason: err.message }; } if (res.ok) { ok++; log(' ✔ ' + e.name + ' 完成' + (res.score != null ? ',得分 ' + res.score : '')); } else { bad++; log(' ✘ ' + e.name + ' 失败:' + res.reason); } await sleep(params.examDelaySec * 1000); } if (!running) { log('练习已停止'); return; } log('练习结束:成功 ' + ok + ' 个,失败 ' + bad + ' 个'); if (bad) { showDoneBanner('⚠ 练习完成,' + bad + ' 个失败'); } else { showDoneBanner('✅ 全部练习已完成'); notifyUser('全部练习已完成!'); } } async function startExamOnly() { if (running) { log('任务已在运行中'); return; } if (!params.enabled) { log('请先勾选「启用」'); return; } const w = openExamWin(); // 必须在点击手势里同步打开,之后复用不需要手势 if (!w) { log('⚠ 浏览器拦截了练习弹窗。请把本页设为「允许弹出窗口」后重试。'); return; } running = true; stopped = false; clearDoneBanner(); log('==================== 开始自动练习 ===================='); log('练习在独立弹窗里进行(弹窗别关,可最小化)。'); try { await examRun(w); } catch (e) { log('练习出错:' + e.message + '\n' + (e.stack || '')); } finally { running = false; if (stopped) log('已停止'); } } /* ------------------------------ 日志 / 面板 ------------------------------ */ let logEl = null; function log(msg) { const t = new Date().toTimeString().slice(0, 8); const line = '[' + t + '] ' + msg; if (logEl) { logEl.textContent += (logEl.textContent ? '\n' : '') + line; logEl.scrollTop = logEl.scrollHeight; } console.log('[秒刷] ' + msg); } /* 面板顶部绿色横幅 + 网页标题提示 */ function showDoneBanner(text) { document.title = '✅ ' + text; const p = document.getElementById('gxks-panel'); if (!p) return; let b = document.getElementById('gxks-banner'); if (!b) { b = document.createElement('div'); b.id = 'gxks-banner'; b.style.cssText = 'margin:6px 8px 0;padding:6px 8px;border-radius:6px;' + 'background:#1f7a4d;color:#fff;font-weight:700;text-align:center;font-size:12px;'; p.insertBefore(b, p.querySelector('.bd')); } b.textContent = text; } function clearDoneBanner() { document.title = document.title.replace(/^✅\s*/, ''); const b = document.getElementById('gxks-banner'); if (b && b.parentNode) b.parentNode.removeChild(b); } const STYLE = ` #gxks-panel{position:fixed;top:70px;right:14px;width:320px;z-index:99999; font:12px/1.5 "Microsoft YaHei",Consolas,monospace;color:#e8eaf0; background:rgba(24,26,34,.95);border:1px solid #3a3f52;border-radius:10px; box-shadow:0 8px 26px rgba(0,0,0,.45);user-select:none;backdrop-filter:blur(4px);} #gxks-panel .hd{display:flex;justify-content:space-between;align-items:center; padding:8px 10px;background:linear-gradient(90deg,#2b3a67,#3b2b67);border-radius:10px 10px 0 0; cursor:move;font-weight:700;letter-spacing:1px;} #gxks-panel .hd .mini{cursor:pointer;padding:0 6px;color:#9fb3ff;} #gxks-panel .bd{padding:10px;max-height:74vh;overflow:auto;} #gxks-panel .row{display:flex;justify-content:space-between;align-items:center;margin:5px 0;gap:8px;} #gxks-panel .row label{color:#9aa4bd;flex:0 0 118px;} #gxks-panel .row input,#gxks-panel .row select{flex:1;min-width:0; background:#12141c;color:#e8eaf0;border:1px solid #39405a;border-radius:5px;padding:3px 6px;font-size:12px;} #gxks-panel .grp{margin:8px 0 4px;color:#7f8bb0;border-bottom:1px dashed #333a4f;padding-bottom:3px;} #gxks-panel .acts{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap;} #gxks-panel button{flex:1;padding:6px 8px;border:0;border-radius:6px;cursor:pointer; background:#3b5bdb;color:#fff;font-size:12px;} #gxks-panel button.gray{background:#3a3f52;} #gxks-panel button.red{background:#b3352f;} #gxks-log{margin-top:9px;height:190px;overflow:auto;background:#0e1016;border:1px solid #2a2f40; border-radius:6px;padding:6px;white-space:pre-wrap;font-size:11px;color:#9fe6b0;user-select:text;} #gxks-panel.hide .bd{display:none;} `; function buildPanel() { const st = document.createElement('style'); st.textContent = STYLE; document.head.appendChild(st); const p = document.createElement('div'); p.id = 'gxks-panel'; p.innerHTML = `
    ⚡ 学时秒刷助手—
    — 账号 —
    — 记录工厂参数 —
    — 后台播放参数 —
    — 通用 —
    — 自动练习 —
    `; document.body.appendChild(p); logEl = p.querySelector('#gxks-log'); // 绑定参数 const bind = (id, key, type) => { const el = p.querySelector('#' + id); el.addEventListener('change', () => { params[key] = (type === 'bool') ? el.checked : (type === 'int') ? (parseInt(el.value, 10) || 0) : (type === 'float') ? (parseFloat(el.value) || 0) : el.value; saveParams(); if (key === 'mode') syncMode(); }); }; bind('p-enabled', 'enabled', 'bool'); bind('p-mode', 'mode'); bind('p-autoPlan', 'autoPlan', 'bool'); bind('p-planId', 'planId', 'int'); bind('p-studentId', 'studentId', 'int'); bind('p-reportSec', 'reportSec', 'int'); bind('p-waitSec', 'waitSec', 'int'); bind('p-recFactor', 'recFactor', 'float'); bind('p-maxRecPerCourse', 'maxRecPerCourse', 'int'); bind('p-makeDelayMs', 'makeDelayMs', 'int'); bind('p-parallelCourses', 'parallelCourses', 'bool'); bind('p-batchCourses', 'batchCourses', 'int'); bind('p-playSlots', 'playSlots', 'int'); bind('p-playMute', 'playMute', 'bool'); bind('p-seekNearEnd', 'seekNearEnd', 'bool'); bind('p-slotRefreshSec', 'slotRefreshSec', 'int'); bind('p-maxRounds', 'maxRounds', 'int'); bind('p-verifyWait', 'verifyWait', 'int'); bind('p-overMin', 'overMin', 'int'); bind('p-notifyDone', 'notifyDone', 'bool'); bind('p-autoExam', 'autoExam', 'bool'); bind('p-examSkipPassed', 'examSkipPassed', 'bool'); bind('p-examWaitSec', 'examWaitSec', 'int'); bind('p-examDelaySec', 'examDelaySec', 'int'); p.querySelector('#gxks-start').addEventListener('click', start); p.querySelector('#gxks-exam').addEventListener('click', startExamOnly); p.querySelector('#gxks-scan').addEventListener('click', scan); p.querySelector('#gxks-stop').addEventListener('click', stop); p.querySelector('#gxks-reset').addEventListener('click', () => { params = Object.assign({}, DEFAULTS); saveParams(); fillForm(); log('已恢复默认参数'); }); p.querySelector('#gxks-mini').addEventListener('click', () => p.classList.toggle('hide')); // 拖动 const hd = p.querySelector('.hd'); let drag = null; hd.addEventListener('mousedown', (e) => { drag = { x: e.clientX - p.offsetLeft, y: e.clientY - p.offsetTop }; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!drag) return; p.style.left = (e.clientX - drag.x) + 'px'; p.style.top = (e.clientY - drag.y) + 'px'; p.style.right = 'auto'; }); document.addEventListener('mouseup', () => { drag = null; }); fillForm(); log('脚本已加载。默认:记录工厂模式 / pTime=60 / 等待 62 秒 / 多课同步刷。' + '「开始练习」可自动参加练习(首次遇到新题会先交一次卷采集答案)。'); } function fillForm() { const p = document.getElementById('gxks-panel'); if (!p) return; const set = (id, val, type) => { const el = p.querySelector('#' + id); if (!el) return; if (type === 'bool') el.checked = !!val; else el.value = val; }; set('p-enabled', params.enabled, 'bool'); set('p-mode', params.mode); set('p-autoPlan', params.autoPlan, 'bool'); set('p-planId', params.planId); set('p-studentId', params.studentId); set('p-reportSec', params.reportSec); set('p-waitSec', params.waitSec); set('p-recFactor', params.recFactor); set('p-maxRecPerCourse', params.maxRecPerCourse); set('p-makeDelayMs', params.makeDelayMs); set('p-parallelCourses', params.parallelCourses, 'bool'); set('p-batchCourses', params.batchCourses); set('p-playSlots', params.playSlots); set('p-playMute', params.playMute, 'bool'); set('p-seekNearEnd', params.seekNearEnd, 'bool'); set('p-slotRefreshSec', params.slotRefreshSec); set('p-maxRounds', params.maxRounds); set('p-verifyWait', params.verifyWait); set('p-overMin', params.overMin); set('p-notifyDone', params.notifyDone, 'bool'); set('p-autoExam', params.autoExam, 'bool'); set('p-examSkipPassed', params.examSkipPassed, 'bool'); set('p-examWaitSec', params.examWaitSec); set('p-examDelaySec', params.examDelaySec); syncMode(); } function syncMode() { const p = document.getElementById('gxks-panel'); if (!p) return; const factory = params.mode === 'factory'; const ids = [['g-factory-title'], ['p-reportSec'], ['p-waitSec'], ['p-recFactor'], ['p-maxRecPerCourse'], ['p-makeDelayMs'], ['p-parallelCourses'], ['p-batchCourses'], ['p-overMin'], ['g-play-title'], ['p-playSlots'], ['p-playMute'], ['p-seekNearEnd'], ['p-slotRefreshSec']]; for (const [id] of ids) { const el = p.querySelector('#' + id); if (!el) continue; const row = el.classList && el.classList.contains('row') ? el : el.parentNode; const isFactoryRow = ['p-reportSec', 'p-waitSec', 'p-recFactor', 'p-maxRecPerCourse', 'p-makeDelayMs', 'p-parallelCourses', 'p-batchCourses', 'p-overMin'].indexOf(id) >= 0; const isPlayRow = ['p-playSlots', 'p-playMute', 'p-seekNearEnd', 'p-slotRefreshSec'].indexOf(id) >= 0; let show = true; if (isFactoryRow || id === 'g-factory-title') show = factory; if (isPlayRow || id === 'g-play-title') show = !factory; const target = (id.indexOf('g-') === 0) ? el : row; if (target) target.style.display = show ? '' : 'none'; } } /* ------------------------------ 控制 ------------------------------ */ async function scan() { try { log('扫描课程列表...'); const r = await fetchCourses(); lastCourses = r.courses; if (params.autoPlan && r.planId) { params.planId = r.planId; } const done = await listDoneMap(); const lines = []; let todoCount = 0; let examCount = 0; for (const c of r.courses) { const d = done[c.id] !== undefined ? done[c.id] : c.done; if (d < c.req) todoCount++; const hasExam = c.examId && c.examId !== '0'; if (hasExam) examCount++; lines.push(' ' + (d >= c.req ? '✔' : '✗') + ' ' + c.name + ' ' + d + '/' + c.req + ' 分钟 [' + c.status + ']' + (hasExam ? ' 练习ID=' + c.examId : '')); } log('共 ' + r.courses.length + ' 门课(已扫描 ' + r.scanned + '/' + r.pages + ' 页),未完成 ' + todoCount + ' 门,含练习 ' + examCount + ' 门,PlanId=' + params.planId + '\n' + lines.join('\n')); fillForm(); } catch (e) { log('扫描失败:' + e.message); } } async function start() { if (running) { log('任务已在运行中'); return; } if (!params.enabled) { log('请先勾选「启用」'); return; } // 数值合法性兜底 const fix = (k, v, why) => { if (params[k] !== v) { log('参数修正:' + why + ',已设为 ' + v); params[k] = v; fillForm(); } }; if (!(parseInt(params.overMin, 10) >= 0)) fix('overMin', DEFAULTS.overMin, '「超出要求即停」必须是非负数'); if (params.recFactor > 1) fix('recFactor', 1, '记录倍率大于 1 会导致过刷'); if (!(parseInt(params.batchCourses, 10) >= 1)) fix('batchCourses', DEFAULTS.batchCourses, '每轮课程数非法'); if (params.reportSec > 60) { // 单条记录一旦上报超过 60 秒,入账会成倍放大 -> 过刷元凶 log('参数修正:上报秒数 ' + params.reportSec + ' 过大会导致每门课被刷到几百分钟,已强制改回 60'); params.reportSec = 60; fillForm(); } if (params.waitSec < params.reportSec) { params.waitSec = params.reportSec + 2; log('参数修正:养记录等待不能小于上报秒数,已设为 ' + params.waitSec + ' 秒'); fillForm(); } if (!(parseInt(params.examWaitSec, 10) >= 10)) fix('examWaitSec', DEFAULTS.examWaitSec, '试卷加载等待太小'); if (!(parseInt(params.examDelaySec, 10) >= 0)) fix('examDelaySec', DEFAULTS.examDelaySec, '练习间隔必须是 0 或正数'); saveParams(); running = true; stopped = false; finishedSet.clear(); playSkip.clear(); for (const k in ledgerMax) delete ledgerMax[k]; // 清掉历史台账,避免换账号后误判 for (const k in floorCredit) delete floorCredit[k]; clearDoneBanner(); // 若勾了「刷完课自动练习」,趁这次点击的手势先把练习弹窗开出来 // (之后刷完课再复用同一个窗口就不需要手势了,避免被弹窗拦截) if (params.autoExam) openExamWin(); log('==================== 开始 ===================='); try { if (params.mode === 'factory') await factoryRun(); else await playRun(); } catch (e) { log('运行出错:' + e.message + '\n' + (e.stack || '')); } finally { running = false; if (stopped) log('已停止'); } } function stop() { if (!running) { log('当前没有运行中的任务'); return; } running = false; stopped = true; log('正在停止...'); if (playState.slots) for (const s of playState.slots) freeSlot(s); } /* ------------------------------ 启动 ------------------------------ */ function boot() { if (document.getElementById('gxks-panel')) return; buildPanel(); if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('显示/隐藏 学时秒刷面板', () => { const p = document.getElementById('gxks-panel'); if (p) p.classList.toggle('hide'); }); GM_registerMenuCommand('开始自动练习', () => startExamOnly()); } if (params.autoRun && params.enabled) setTimeout(start, 1500); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();