]*>[\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();
}
})();
|