,
// 甚至是一整块可点区域。只查 button 会「找不到重新答题/交卷」,
// 于是这一节被判成状态不明、回课程页重来,反复几轮就被跳过了。
function testBtn(root, re) {
const tags = 'button, a, [role=button], .ant-btn, span, div';
const hits = [...root.querySelectorAll(tags)].filter(el => {
if (!visible(el)) return false;
const t = (el.innerText || '').replace(/\s+/g, '');
return t && t.length <= 10 && re.test(t);
});
if (!hits.length) return null;
// 取最内层那个(文字直接落在它身上),避免点到整块容器
const inner = hits.filter(el => ![...el.children].some(c => hits.indexOf(c) >= 0));
return inner[0] || hits[hits.length - 1];
}
// 交卷后可能弹「确定要交卷吗?」的确认层。注意只认确认层里的键:
// 标准化测试本身就是弹窗,如果一上来就点「弹层里的主按钮」,会把卷子自己的
// 提交键重复点一遍。
function clickConfirm() {
const zones = [...document.querySelectorAll(
'.ant-modal-confirm, .ant-modal-confirm-btns, .ant-modal-footer, [class*=confirm]'
)].filter(visible);
for (const z of zones) {
const btns = [...z.querySelectorAll('button, [role=button]')].filter(visible);
const b = btns.find(x => /^(确定|确认|是|继续|交卷|提交|好的)$/.test(textOf(x))) ||
btns.find(x => x.classList.contains('ant-btn-primary'));
if (b) { fireClick(b); return true; }
}
// 兜底:文字明确是「确定 / 确认」的键(不猜主按钮,避免重复交卷)
return clickByText(/^(确定|确认|是)$/);
}
function clickByText(re) {
const b = [...document.querySelectorAll('button, [role=button]')]
.filter(visible)
.find(x => re.test(textOf(x)));
if (b) { fireClick(b); return true; }
return false;
}
/* 提交键:整卷只有一颗「提交」,所以只找、只点这一颗。
* 页面上偶尔会叠两层容器(按钮外面还包着一个 div 写着同样的字),
* 用 contains 去重,别把同一颗按钮当成两颗。 */
const SUBMIT_RE = /^(提交|交卷|提交答案|提交作答|提交试卷|提交卷子|确认提交|交卷并提交|提交并交卷)$/;
function submitButtons(root) {
const scope = root || findTestRoot() || document;
const hits = [...scope.querySelectorAll('button, a, [role=button], .ant-btn, span, div')]
.filter(visible)
.filter(el => {
const t = (el.innerText || '').replace(/\s+/g, '');
return t && t.length <= 6 && SUBMIT_RE.test(t);
})
// 只要最内层,别点包住按钮的空壳
.filter(el => ![...el.children].some(c => SUBMIT_RE.test((c.innerText || '').replace(/\s+/g, ''))));
const uniq = [];
hits.forEach(b => {
if (!uniq.some(u => u === b || u.contains(b) || b.contains(u))) uniq.push(b);
});
return uniq;
}
function btnDisabled(b) {
if (!b) return true;
try {
if (b.disabled === true) return true;
if (b.getAttribute('aria-disabled') === 'true') return true;
if (/disabled/i.test(String(b.className || ''))) return true;
if (b.closest && b.closest('.ant-btn-disabled, [disabled]')) return true;
} catch (e) {}
return false;
}
/* 提交键不一定在卷子容器里:有的在弹层 footer,有的在题目区最底部、
* 要滚到底才渲染出来。按「卷子 → 弹层 → 整页」逐级放宽,别一上来就放弃。 */
function findSubmitAny(root) {
const scopes = [];
const r = findTestRoot() || root;
if (r) {
try { const m = r.closest(MODAL_SEL); if (m) scopes.push(m); } catch (e) {}
scopes.push(r);
}
try {
const m = document.querySelector('.ant-modal-wrap, .ant-modal, [class*=dialog], [class*=modal]');
if (m) scopes.push(m);
} catch (e) {}
scopes.push(document);
for (const sc of scopes) {
const list = submitButtons(sc);
if (list.length) return list;
}
return [];
}
/* 把提交键弄出来:先看现有 DOM,没有就滚到底再一屏一屏往下翻着等它渲染 */
async function revealSubmit(root) {
let list = findSubmitAny(root);
if (list.length) return list;
log('这一眼没看到提交键,滚到底部去找');
const r0 = findTestRoot() || root;
scrollToBottom(r0);
for (let i = 0; i < 12; i++) {
await sleep(400);
list = findSubmitAny(root);
if (list.length) return list;
const r = findTestRoot() || root;
scrollBoxes(r).forEach(box => {
try { box.scrollBy ? box.scrollBy(0, 260) : (box.scrollTop += 260); } catch (e) {}
});
}
return [];
}
/* 交卷:整卷就一颗「提交」,所有题目都选好之后点**一次**。
* 以前这里把页面上所有「提交」挨个点一遍(想着兼容逐题提交的卷子),
* 在整卷一颗键的卷子上等于反复交卷,平台直接判无效或重置。
* 现在:滚到底把按钮露出来 → 点一次 → 等确认框;只有点了完全没反应才补点一次。 */
async function submitPaper(root) {
await sleep(rand(600, 1400));
let btns = await revealSubmit(root);
if (!btns.length) {
log('滚到底也没找到「提交」键,这一遍先不交');
return false;
}
let btn = btns[btns.length - 1];
try { btn.scrollIntoView({ block: 'center' }); } catch (e) {}
await sleep(rand(500, 1000));
btn = findSubmitAny(root).slice(-1)[0] || btn; // 滚动后节点可能换了
for (let wait = 0; wait < 4 && btnDisabled(btn); wait++) {
log('提交键还是灰的,等它变亮(', wait + 1, '/4)');
await sleep(1200);
btn = findSubmitAny(root).slice(-1)[0] || btn;
}
if (btnDisabled(btn)) {
log('提交键一直是灰的(平台认为还有题没答完),这一遍先不交');
return false;
}
await sleep(rand(1800, 3200)); // 像人一样再检查一遍整卷
/* 整卷只点一次「提交」。以前点了没反应会马上补点一次,在平台没给任何反馈的
* 时候就是连着交两遍同一份卷子 —— 既容易被判成机器刷题,也可能把卷面重置。
* 现在改成:点一次,给它 3 秒;没反应就交给外面的 10 秒判定 ——
* 10 秒还没结果,整个页面刷新、换一组答案重新交。 */
{
const cur = findSubmitAny(root);
const target = cur[cur.length - 1];
if (!target) { log('提交键没了,说明已经交上去了'); return true; }
for (let wait = 0; wait < 3 && btnDisabled(target); wait++) {
log('提交前它又灰了,等它变亮(', wait + 1, '/3)');
await sleep(1000);
}
try { target.scrollIntoView({ block: 'center' }); } catch (e) {}
await sleep(rand(300, 700));
fireClick(target);
TEST_SUBMITS++;
TEST_PROGRESS = Date.now();
SUBMIT_HINT = false;
log('点了整卷提交(第', TEST_SUBMITS, '次点击,整卷只交一次)');
// 可能弹「确定要交卷吗?」;也可能飘一条「还有题未作答」
for (let i = 0; i < 10; i++) {
await sleep(300);
if (unansweredHint()) { SUBMIT_HINT = true; break; }
if (clickConfirm()) { log('已确认交卷'); return true; }
}
await sleep(1200);
if (!findSubmitAny(root).length) return true; // 提交键没了 = 交上去了
const r = findTestRoot();
if (!r || testBtn(r, /重新答题/)) return true; // 卷子变成看答案了
log('点了提交页面没反应,等 10 秒判定,不再重复点');
}
return true;
}
// 返回 true=处理过一轮,'done'=已完成,'stuck'=搞不定需要人工
async function testTick() {
let root = findTestRoot();
if (!root) return false;
if (TEST_RUNNING) return true;
TEST_RUNNING = true;
try {
root = findTestRoot();
if (!root) return false;
TEST_IN_MODAL = inModal(root);
// 弹窗测试的地址栏里没有节点 id(URL 还是课程页),只用 cid/actId 记账会让
// 同一页里第二份卷子直接吃到第一份的 TEST_PASSED,所以用卷面指纹区分。
const key = TEST_IN_MODAL ? ('panel/' + firstQuestionSig(root)) : (courseIdOf() + '/' + actIdOf());
if (key !== TEST_KEY) {
TEST_KEY = key; TEST_SUBMITS = 0; TEST_KEY_TRIES = 0; TEST_KEY_SUBS = 0;
TEST_PASSED = false; TEST_RETRY_CLICKS = 0; TEST_INCOMPLETE = 0;
CLICKED_OPTS.clear(); // 换了一份卷子,点选记录重新开始
}
root = findTestRoot();
if (!root) return false;
scrollThrough(root);
const qs0 = testQuestions(root);
if (!qs0.length) {
// 一道题都没识别出来:不要假装答完了,回课程页核对
log('测试区里没识别到题目(选择器可能对不上),回课程页');
return 'unknown';
}
// 已经按答案满分交过卷,或页面自己打了「已完成」→ 收工,别再反复重答
if (TEST_PASSED) return 'done';
if (testMarkedDone(root)) { log('页面已标记这一节完成'); return 'done'; }
let submitBtn = testBtn(root, /提交|交卷/);
const retryBtn = testBtn(root, /重新答题/);
// 既没有提交也没有重答:页面状态不明,交给 testRoutine 回课程页核对,
// 不能像旧版那样直接当「已完成」放行
if (!submitBtn && !retryBtn) return 'unknown';
// 先把页面上公布的答案抄进记忆(趁「看答案」状态还在的时候抄最稳)
let revealed = qs0.map(revealedAnswer);
mergeKey(qs0, revealed);
// 处于「看答案」状态:点「重新答题」退回作答态
if (!submitBtn && retryBtn) {
TEST_RETRY_CLICKS++;
log('重新作答(答案已记住),第', TEST_RETRY_CLICKS, '次');
fireClick(retryBtn);
// 等「提交」真的出现,才说明卷子回到可作答状态;
// 旧代码只等 1 秒就 return,页面一变慢就会反复点「重新答题」空转。
const ready = await waitFor(() => testBtn(findTestRoot() || root, /提交|交卷/), 12000, 400);
if (!ready) {
dumpTestDebug(root);
if (TEST_RETRY_CLICKS >= 3) return giveUpTest('点了 3 次「重新答题」页面都没回到作答状态');
return 'unknown';
}
TEST_RETRY_CLICKS = 0; // 真的回到作答态了,计数归零
root = findTestRoot() || root;
scrollThrough(root);
CLICKED_OPTS.clear(); // 卷子回到作答态,是一次全新的作答
}
const qs = testQuestions(root);
revealed = qs.map(revealedAnswer);
const backFilled = mergeKey(qs, revealed);
if (backFilled) log('从记忆里补上', backFilled, '题的答案');
const hasKey = revealed.some(a => a && a.length);
if (hasKey) log('这遍用的答案:', JSON.stringify(revealed));
else log('平台还没公布答案,先按真人节奏盲答一遍;交完抄下答案再答第二遍');
// 答案按题干指纹存:题序变了、题数变了都对得上
const keyMap = {};
qs.forEach((li, i) => {
const sig = qKey(li);
if (revealed[i] && revealed[i].length) keyMap[sig] = revealed[i];
else if (STATE.answerKey[sig]) keyMap[sig] = STATE.answerKey[sig];
});
/* 本轮作答计划:有答案的照答案,没答案的当场随机一组(单选随机一项、
* 多选随机勾 2~n-1 项),整轮固定下来不再改。
* 以前没答案时永远挑「文字最长的那一项」,每题都落在同一个位置 —— 既像
* 机器乱点,又因为每轮都一样,平台判错一次就永远过不去。 */
ATTEMPT_PLAN = Object.create(null);
qs.forEach(li => {
const sig = qKey(li);
const labels = optLabels(li);
if (!labels.length) return;
if (keyMap[sig] && keyMap[sig].length) { ATTEMPT_PLAN[sig] = keyMap[sig]; return; }
const all = labels.map((l, k) => optLetter(l, k));
const pick = pickBlind(sig, all, qType(li));
ATTEMPT_PLAN[sig] = pick;
keyMap[sig] = pick; // 复核(auditPaper)也按这一组算
noteBlindPick(sig, pick);
});
/* 一、把整卷答完:每一轮重新扫一遍当前 DOM,补上没答的题,直到一道不剩。
* 不写死题数 —— 2 题、3 题、十几题都走同一条路。 */
const res = await fillWholePaper(root, keyMap);
log('作答结果:共', res.total, '题,已答', res.done, '题,扫了', res.rounds, '轮',
res.leftQ.length ? (',仍漏第 ' + res.leftQ.join('、') + ' 题') : '');
/* 二、提交前整卷复核:一个选项都没勾的题不能交(平台会判无效);
* 只是少勾几个字母的照交 —— 那种多半是我这边答案解析不全,
* 让平台判分并公布答案,下一遍照抄就行,别死等在这里。 */
let au = auditPaper(root, keyMap);
if (au.empty > 0) {
panelApi && panelApi.set('测试题:第 ' + au.bad.map(b => b.i).join('、') + ' 题还没选好,正在补选');
log('还有', au.empty, '题一个选项都没勾上,硬补一轮');
await forceAnswerAll(root, keyMap);
au = auditPaper(root, keyMap);
}
for (let round = 0; round < 2 && au.empty > 0; round++) {
log('还有', au.empty, '题一个选项都没勾上,补答第', round + 1, '轮');
panelApi && panelApi.set('测试题:第 ' + au.bad.map(b => b.i).join('、') + ' 题还没选好,正在补选');
const again = await fillWholePaper(root, keyMap);
log('补答后:共', again.total, '题,已答', again.done, '题');
if (!again.leftQ.length) break;
await forceAnswerAll(root, keyMap);
au = auditPaper(root, keyMap);
}
if (au.empty > 0) {
TEST_INCOMPLETE++;
dumpTestDebug(root);
log('还有题一个选项都没勾上:', JSON.stringify(au.bad), '(第', TEST_INCOMPLETE, '轮)');
if (TEST_INCOMPLETE >= 3) {
return giveUpTest('第 ' + au.bad.map(b => b.i).join('、') + ' 题怎么点都选不上,页面读不到勾选状态');
}
panelApi && panelApi.set('测试题:第 ' + au.bad.map(b => b.i).join('、') + ' 题还没选好,稍后重试');
await sleep(2000);
return true;
}
if (au.missing) log(au.missing, '题少勾了字母(答案可能没解析全),照样交卷让平台判');
TEST_INCOMPLETE = 0;
log('整卷', au.total, '题都选上了,可以交卷');
if (hasKey ? TEST_KEY_SUBS >= 8 : TEST_SUBMITS >= 8) {
dumpTestDebug(root);
return giveUpTest(hasKey
? ('按页面公布的答案交了 ' + TEST_KEY_SUBS + ' 次,平台仍没判完成')
: ('平台没公开答案,盲答 ' + TEST_SUBMITS + ' 次仍未通过'));
}
scrollThrough(root); // 再整卷过一遍,确认题目都挂载出来
scrollToBottom(findTestRoot() || root); // 停在最底部:提交键就在这里,别滚回第一题
await sleep(rand(700, 1300));
panelApi && panelApi.set('测试题:' + au.total + ' 题都选好了,现在整体交卷');
log('整卷', au.total, '题一起提交(只点一次提交键)');
if (!(await submitPaper(root))) {
// 提交键是灰的:平台认为还有题没答完,别硬交
TEST_INCOMPLETE++;
log('提交键点不动(灰的),这一遍先不交,回去复查卷面');
if (TEST_INCOMPLETE >= 3) return giveUpTest('提交键一直是灰的,平台认为还有题目没答完');
await sleep(1500);
return true;
}
if (hasKey) TEST_KEY_SUBS++;
// 平台弹「还有题未作答」:说明我这边没扫全(题可能是滚出来的),回头重扫补答
if (SUBMIT_HINT) {
SUBMIT_HINT = false;
TEST_HINT_TRIES++;
log('平台提示还有题目没作答(第', TEST_HINT_TRIES, '次),重新扫全卷补答');
if (TEST_HINT_TRIES >= 3) {
return giveUpTest('平台一直提示还有题没答,页面里的题目数认不全');
}
const qs3 = testQuestions(findTestRoot() || root);
log('这次扫到', qs3.length, '题(上一轮是', au.total, '题)');
scrollThrough(findTestRoot() || root);
await sleep(1000);
return true;
}
TEST_HINT_TRIES = 0;
await sleep(1200);
/* 交完卷最多等 10 秒(用户定的尺度):10 秒还没变成「已完成」,就说明这一遍
* 没做成 —— 刷新页面,下一轮换一组选项再交一次。选项组合不会重复
* (pickBlind 记着这一题前 8 轮用过什么),所以每轮确实是新答案。 */
const st = await waitTestResult(10000);
log('交卷后状态:', st);
if (st === 'done' || testMarkedDone(findTestRoot() || root)) {
TEST_PASSED = true; // 平台确认完成,收工
delete STATE.attempts['Qrd/' + key];
saveState();
return 'done';
}
if (st === 'graded') {
// 平台说还有错的:把这次新公布的答案抄下来,下一轮点「重新答题」照着改
const r2 = findTestRoot() || root;
const qs2 = testQuestions(r2);
const filled = mergeKey(qs2, qs2.map(revealedAnswer));
log('平台判定仍有错题,已抄下新答案(补上', filled, '题),准备重答');
CLICKED_OPTS.clear();
return true;
}
// 超时:页面还是一动不动的老样子,当作这一遍没做完,刷新换答案重来
const rkQ = 'Qrd/' + key;
const n = (STATE.attempts[rkQ] || 0) + 1;
STATE.attempts[rkQ] = n;
saveState();
if (n > 12) {
delete STATE.attempts[rkQ];
return giveUpTest('这份卷子刷新重试 ' + n + ' 次仍没有结果');
}
panelApi && panelApi.set('交卷后 10 秒没有结果,刷新页面换一组答案重试(第 ' + n + ' 次)');
log('交卷 10 秒页面没有变化,刷新换答案重试(第', n, '次)');
await sleep(800);
NAV_PENDING = true;
location.reload();
return true;
} catch (e) {
log('测试题处理出错:', e && e.message);
return false;
} finally {
TEST_RUNNING = false;
}
}
// 这一节点实在做不下去了:不弹窗打断,直接回课程页重新读章节状态,
// 让主流程按「最近学习」优先接着找没做完的地方(重试轮数有上限,不会无限转)。
async function giveUpTest(reason) {
log('这一节自动处理不了,先退回课程页:', reason);
panelApi && panelApi.set('这一节先放一放,回课程页接着找没做完的');
await sleep(800);
gotoCoursePage();
return 'stuck';
}
async function testRoutine(token) {
blockVerification();
fakeVisible();
panelApi && panelApi.set('本节点是标准化测试,正在自动作答…');
const t0 = Date.now();
let stallAt = Date.now(); // 卷面最后一次有动静的时间
while (token === CURRENT_TOKEN) {
if (Date.now() - t0 > 30 * 60e3) {
// 超时也不弹窗:回课程页重新挑节点,别让脚本停在原地
return giveUpTest('这一节自动作答超过 30 分钟仍未完成');
}
const r = await testTick();
if (r === 'stuck') return 'stuck';
if (r === 'done') {
log('测试已通过(平台已确认完成)');
panelApi && panelApi.set('测试已完成 ✅');
await sleep(1500);
if (token !== CURRENT_TOKEN) return 'done';
// 弹窗式测试:关掉弹窗,交回课程页接着点下一个节点,不必整页刷新
// 弹窗卷子做完了也整页回课程页:章节状态以服务端为准,
// 光关掉弹窗,SPA 里的章节列表有可能还显示「没做」,会反复点开同一份卷子
gotoCoursePage();
return 'done';
}
// 测试区消失 / 按钮状态不明:不能直接当通过,回课程页让章节列表核对完成状态,
// 没完成的话课程页会重新进来再做一遍
if (r === 'unknown' || !findTestRoot()) {
if (!TEST_SUBMITS) {
// 一次都还没交过:多半是还没渲染出来,再等等,别急着往回跳
if (Date.now() - t0 > 90e3) {
log('测试区一直没出来,回课程页');
gotoCoursePage();
return 'gone';
}
await sleep(1500);
continue;
}
log('测试区已消失,核对这一节是否真的完成');
await sleep(2000);
if (token !== CURRENT_TOKEN) return 'gone';
if (TEST_IN_MODAL) { // 弹窗自己关了:回课程页看这一节有没有打勾
gotoCoursePage();
return 'gone';
}
gotoCoursePage();
return 'gone';
}
/* 卡死兜底:卷子已经在眼前,却连着 20 秒一个选项都没勾上、也没交出去
* (典型就是截图里那样——三道题全空、页面一点反应没有)。
* 这时候刷新页面,下一轮会重新随机一组答案再来。 */
if (TEST_PROGRESS > stallAt) {
stallAt = TEST_PROGRESS;
} else if (findTestRoot() && Date.now() - stallAt > 20000) {
const k = 'Qstall/' + courseIdOf() + '/' + actIdOf();
STATE.attempts[k] = (STATE.attempts[k] || 0) + 1;
saveState();
if (STATE.attempts[k] > 10) return giveUpTest('这份卷子刷新 ' + STATE.attempts[k] + ' 次仍卡住不动');
panelApi && panelApi.set('卷子 20 秒没动静,刷新换一组答案重来(第 ' + STATE.attempts[k] + ' 次)');
log('卷面 20 秒没有任何变化,刷新重来(第', STATE.attempts[k], '次)');
await sleep(600);
if (token !== CURRENT_TOKEN) return 'gone';
NAV_PENDING = true;
location.reload();
return 'reloaded';
}
await sleep(1200);
}
return 'gone';
}
/* ======================================================================
* 播放页的平台报错弹窗
*
* 「出错了:学习时间和实际经过时间不符,学习进度保存失败!如果正在观看视频,
* 请不要向后拖动视频!」——平台的时间校验没过,进度存不上。这个弹层不点
* 「点击刷新」就一直盖在播放器上,视频也停了,脚本以前完全不认识它,
* 于是整页停在那儿不动。
*
* 处理办法:第一次点它自己的「点击刷新」重来(平台建议的就是这个);
* 同一节 10 分钟内第二次还报,刷新也没意义,把这一节记为跳过、回课程页换节点,
* 免得在一节视频上反复刷。
* ==================================================================== */
/* 弹层里那颗「点击刷新 / 刷新 / 重试」 */
function refreshLikeBtn(root) {
if (!root) return null;
const btns = [...root.querySelectorAll('button, [role=button], .ant-btn, a')].filter(visible);
const b = btns.find(x => /刷新|重试|重新加载|重新进入|重新登录/.test(textOf(x)));
if (b) return b;
// 有的弹层把「点击刷新」写成可点的文字(span/a),没有按钮标签:取最内层那个
return [...root.querySelectorAll('span, a, p, div')].filter(visible)
.find(x => /^(点击刷新|刷新|刷新页面|重新加载|重试)$/.test(textOf(x)) &&
![...x.children].some(c => textOf(c) === textOf(x))) || null;
}
function platformErrorModal() {
let boxes = [];
try {
boxes = [...document.querySelectorAll('.ant-modal, [class*=modal], [class*=dialog]')]
.filter(visible)
.filter(m => m.id !== 'auto-done-modal' && m.id !== 'auto-stage-pick' &&
!(m.closest && m.closest('#auto-learn-panel')));
} catch (e) { return null; }
const hits = boxes.filter(m => {
const t = (m.innerText || '').replace(/\s+/g, '').slice(0, 400);
if (/学习时间和实际经过时间不符/.test(t)) return true;
if (/学习进度保存失败/.test(t)) return true;
// 登录凭证过期(Jwt expired):截图里这种「出错了 + An error occurred while
// attempting to decode the Jwt: Jwt expired at …」,点「点击刷新」就重新领一次
if (/Jwt|expiredat|凭证.*过期|登录已过期|登录过期/i.test(t)) return true;
// 其它「出错了」:只要它自己给了刷新按钮,也算——平台让刷新就刷新
return /出错|错误|Anerroroccurred/i.test(t) && !!refreshLikeBtn(m);
});
if (!hits.length) return null;
// 外层容器没有按钮,取有按钮的那一层
return hits.find(m => m.querySelector('button, [role=button], .ant-btn')) || hits[hits.length - 1];
}
/* 报错分两类:'time' 是「学习时间和实际经过时间不符」(这一节的数据有问题);
* 其余(凭证过期、平台其它报错)刷新一下基本就好,不能因此丢掉这一节。 */
function platformErrorKind(m) {
const t = ((m && m.innerText) || '').replace(/\s+/g, '');
if (/学习时间和实际经过时间不符|学习进度保存失败|向后拖动视频/.test(t)) return 'time';
if (/Jwt|expiredat|凭证.*过期|登录已过期|登录过期/i.test(t)) return 'auth';
return 'other';
}
let ERR_LAST = 0; // 上次处理报错弹层的时间,防止同一次弹层被重复计数
async function handlePlatformError(token) {
const m = platformErrorModal();
if (!m) return false;
if (ERR_LAST && Date.now() - ERR_LAST < 10000) return true; // 刚点过刷新,等它生效
ERR_LAST = Date.now();
const kind = platformErrorKind(m);
const cid = courseIdOf(), aid = actIdOf();
const k = 'E/' + kind + '/' + cid + '/' + aid;
const at = STATE.attempts[k + '@'] || 0;
const n = (Date.now() - at < 10 * 60000) ? ((STATE.attempts[k] || 0) + 1) : 1;
STATE.attempts[k] = n;
STATE.attempts[k + '@'] = Date.now();
saveState();
// 时间校验:刷一次就好,刷完还错说明这一节数据有问题,跳过换节点。
// 凭证过期/其它报错:不能丢这一节,给三次刷新机会(刷新会重新领凭证)。
const limit = kind === 'time' ? 1 : 3;
if (n <= limit) {
const b = refreshLikeBtn(m) ||
[...m.querySelectorAll('button, [role=button], .ant-btn')].filter(visible)
.find(x => x.classList.contains('ant-btn-primary'));
const label = b ? textOf(b) : '刷新';
if (kind === 'time') {
log('平台报「学习时间和实际经过时间不符」,点「' + label + '」重来');
panelApi && panelApi.set('平台时间校验没过,点刷新重来');
} else if (kind === 'auth') {
log('平台报登录凭证过期(Jwt expired),点「' + label + '」重新领一次');
panelApi && panelApi.set('登录凭证过期,点刷新重新领一次(第 ' + n + '/3 次)');
} else {
log('平台报错,点「' + label + '」重来');
panelApi && panelApi.set('平台报错,点「' + label + '」重来(第 ' + n + '/3 次)');
}
if (b) fireClick(b); else location.reload();
await sleep(2500);
return true;
}
if (kind === 'time') {
// 同一节短时间内又报:刷新解决不了,先把这一节放下
log('同一节反复时间校验失败(第', n, '次),先跳过这一节');
panelApi && panelApi.set('这一节时间校验反复失败,先跳过换节点');
STATE.skipped.push('平台时间校验失败已跳过:' + (aid || '未知节点'));
if (cid && aid) STATE.attempts[cid + '/' + aid] = MAX_ATTEMPT; // 课程页别马上又点回这一节
saveState();
showToast('「' + (aid || '这一节') + '」平台时间校验没过,先跳过,稍后再回来');
await sleep(800);
if (token !== CURRENT_TOKEN) return true;
gotoCoursePage();
return true;
}
/* 凭证过期刷新三次都不行:这不是某一节的问题,是这一页拿不到新凭证了。
* 回课程列表重新领一次(列表页拿得到新的登录态);万一真掉登录,那边会直接
* 转去登录页,登录流程接管。为了防止「列表↔播放页」来回跳,5 分钟内只升一次。 */
const escKey = 'E/auth/esc@';
if (Date.now() - (STATE.attempts[escKey] || 0) > 5 * 60000) {
STATE.attempts[escKey] = Date.now();
STATE.attempts[k] = 0; // 回来之后重新计数
STATE.attempts[k + '@'] = Date.now();
saveState();
setResume(); // 万一落到首页也能接上
log('凭证/报错连续', n, '次,回课程列表重新领凭证');
panelApi && panelApi.set('登录凭证过期,回课程列表重新领一次');
showToast('平台登录凭证过期了,先回课程列表重新领一次再接着学');
await sleep(1000);
if (token !== CURRENT_TOKEN) return true;
NAV_PENDING = true;
if (getMode() === 'lessonlist') location.reload(); // 已经在列表页了,刷一下就行
else location.href = projectListUrl();
return true;
}
// 刚升过级还是这样:就地再刷一次,不来回跳
log('凭证/报错仍未解决,就地再刷新一次(不离开本页)');
panelApi && panelApi.set('平台报错还没好,就地再刷新一次');
const b2 = refreshLikeBtn(m);
if (b2) fireClick(b2); else location.reload();
await sleep(3000);
return true;
}
async function videoRoutine(token) {
blockVerification();
fakeVisible();
const cid = courseIdOf(), aid = actIdOf();
const key = cid + '/' + aid;
STATE.attempts[key] = (STATE.attempts[key] || 0) + 1;
saveState();
panelApi && panelApi.set('播放页:等待视频加载…');
panelApi && panelApi.sub('原速 1× · 静音 · 自动续播');
// 这个节点可能不是视频:既可能是「标准化测试」弹窗,也可能是文本 / 图文 / 文档类
// 节点(整页根本没有