fix: resume video playback and advance linked exams with attempt controls

This commit is contained in:
薇星幻海
2026-10-18 02:12:06 +08:00
parent 2e044655bb
commit 1b497286dc
20 changed files with 1629 additions and 155 deletions
+434 -65
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 谊安学助手 2.0
// @namespace yianxue-question-bank
// @version 2.1.1
// @version 2.1.2
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
// @match http://10.119.67.7/clientcn/*
// @run-at document-idle
@@ -1132,7 +1132,7 @@ window.JSZip = module.exports;
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null,
activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0,
learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' }
learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, skipSingleAttemptQuiz: true, resumePlaybackOnReturn: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' }
};
let destroyed = false;
let selectionText = '';
@@ -1567,7 +1567,7 @@ window.JSZip = module.exports;
container.appendChild(button); return button;
}
views.overview.appendChild(el(doc, 'h2', 'section-title', '本轮学习任务'));
learningSetting(views.overview, 'mode', '任务模式', [['video', '只刷视频(课中题人工处理)'], ['videoQuiz', '视频与课中答题'], ['exam', '只做考试'], ['all', '视频、课中题与考试']], 'all');
learningSetting(views.overview, 'mode', '任务模式', [['video', '只刷视频(课中题人工处理)'], ['videoQuiz', '视频与课中答题'], ['exam', '只做考试'], ['all', '视频+考试(含课中答题)']], 'all');
learningSetting(views.overview, 'execution', '执行方式', [['sequential', '顺序执行'], ['parallel', '并行:一个视频 + 一场考试']], 'sequential');
const taskActions = el(doc, 'div', 'task-actions');
const startTask = taskButton(taskActions, '开始本轮', 'start', 'primary');
@@ -1589,8 +1589,17 @@ window.JSZip = module.exports;
views.overview.append(learningMessage, summaryGrid, videoSummary, examSummary, el(doc, 'h3', 'section-title', '任务清单'), taskList);
views.video.appendChild(el(doc, 'h2', 'section-title', '视频学习'));
const videoModeNote = el(doc, 'p', 'mode-note'); views.video.appendChild(videoModeNote);
const videoModeNote = el(doc, 'p', 'mode-note');
videoModeNote.setAttribute('data-testid', 'video-mode-note'); views.video.appendChild(videoModeNote);
learningSetting(views.video, 'rate', '播放倍速', [[1, '1X'], [1.25, '1.25X'], [1.5, '1.5X'], [2, '2X(最高)']], 1);
learningSwitch(views.video, 'autoContinuePlayback', '自动继续上次播放', true);
views.video.appendChild(el(doc, 'p', 'hint', '运行中的视频任务出现“是否继续上次播放的位置”时,自动选择“继续播放”;关闭后由你处理。'));
learningSwitch(views.video, 'skipSingleAttemptQuiz', '仅 1 次答题机会时交给我处理', true);
views.video.appendChild(el(doc, 'p', 'hint', '含课中题和课程考试:网页明确显示仅允许或剩余 1 次机会时,不自动答题,等待你处理。次数未显示时不能推断;关闭后仍需满足原有题库核验条件。'));
learningSwitch(views.video, 'resumePlaybackOnReturn', '切回页面时自动恢复播放', true);
const playbackReturnHint = el(doc, 'p', 'hint', '只恢复切页引起的暂停。手动暂停优先;你暂停播放器、暂停任务,或有题目等待处理时,不会自动播放。');
playbackReturnHint.setAttribute('data-testid', 'playback-return-hint');
views.video.appendChild(playbackReturnHint);
const videoInfo = el(doc, 'dl', 'info-grid');
const videoActions = el(doc, 'div', 'task-actions');
const videoPause = taskButton(videoActions, '暂停视频任务', 'videoPause');
@@ -1761,7 +1770,7 @@ window.JSZip = module.exports;
});
}
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; }
function firstValue(object, keys, fallback) {
object = object || {};
@@ -1828,7 +1837,11 @@ window.JSZip = module.exports;
: '本开关已关闭:仅已精确匹配、点击并核验完整答案文字的题目计分,其余题目不计分;满足得分、计时与当前试卷核验条件后可自动交卷。')
+ '切换会暂停当前计划,请在任务总览点击继续重新核验。无论开关状态,试卷未完整加载均禁止自动交卷;预计得分正好 80 分等待人工。练习与课中题不使用考试交卷门槛。');
const videoEnabled = learning.mode !== 'exam';
setText(videoModeNote, videoEnabled ? '' : '本模式未启用视频;可查看设置与记录。'); videoModeNote.hidden = videoEnabled;
setText(videoModeNote, !videoEnabled ? '本模式未启用视频;可查看设置与记录。'
: learning.mode === 'video' ? '当前为“仅视频”:课中题由你处理,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
: learning.mode === 'videoQuiz' ? '当前为“视频+课中答题”:只自动处理课中题,视频完成后不会进入独立或关联考试。若要视频完成后答题,请在任务总览选择“视频+考试”。'
: '当前为“视频+考试”:平台确认视频完成后,进入已开放的关联考试。' + (learning.skipSingleAttemptQuiz !== false ? '仅 1 次答题机会的人工保护仍生效。' : '单次机会保护已关闭,仍按题库核验及交卷条件执行。'));
videoModeNote.hidden = false;
const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode;
setText(examModeNote, examEnabled ? '' : '本模式未启用独立考试;仍可操作当前练习页。'); examModeNote.hidden = examEnabled;
const videoDuration = firstValue(video, ['duration', 'durationSeconds', 'totalSeconds'], null);
@@ -2543,6 +2556,113 @@ window.JSZip = module.exports;
root.YianAnswerRunner = { version: '1.1.0', create: create };
})(typeof window !== 'undefined' ? window : globalThis);
/* Visible attempt labels shared by course quizzes and course exams. */
(function (root) {
'use strict';
const IGNORE = [
'script', 'style', 'noscript', 'template', 'textarea', 'input', 'select', 'option', 'pre', 'code', 'svg', 'canvas',
'.examtest_content_question', '.examtest_content_list', '.examtest_content_list_des',
'.pc_examtest_content_question', '.pc_examtest_content_list',
'.model_answer_wrapper', '.model_answer', '.model_dec',
'[data-question-stem]', '[data-question-text]', '[data-question-options]', '[data-answer-text]',
'.question-stem', '.question-text', '.question-options', '.question-answer', '.option-text', '.answer-text', '[role="option"]'
].join(',');
const BLOCK = /^(?:ADDRESS|ARTICLE|ASIDE|BLOCKQUOTE|DD|DIV|DL|DT|FIELDSET|FIGCAPTION|FIGURE|FOOTER|FORM|H[1-6]|HEADER|HR|LI|MAIN|NAV|OL|P|SECTION|TABLE|TBODY|TD|TFOOT|TH|THEAD|TR|UL)$/;
const DIGITS = '[0-9零〇一二三四五六七八九十百两]+';
const TARGET = '(?:答题|作答|考试)';
const CAPTION = '(?:允许|允许的|可用|可|总|总共|最多|最大|剩余|还剩|还有|还可)';
const SEPARATOR = '(?:[::=]|为|是)?';
const LIMIT = '(?:仅|只有|仅有|只限)?';
const UNIT = '(?:次|[((]次[))])?';
const forms = [
new RegExp('^(?:本次|本场)?' + CAPTION + '(?:可|允许)?' + TARGET + '(?:总|剩余)?(?:次数|机会)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
new RegExp('^(?:本次|本场)?' + TARGET + '(?:允许|可用|总|最多|最大|剩余|还剩)(?:次数|机会)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
new RegExp('^(?:本次|本场)?' + TARGET + '(?:次数|机会)' + SEPARATOR + '(?:上限|最多|最大|仅剩|还剩|剩余)' + SEPARATOR + LIMIT + '(' + DIGITS + ')' + UNIT + '$'),
new RegExp('^(?:本次|本场)?(?:仅|只)(?:有|可|能|允许)?' + TARGET + '(' + DIGITS + ')次$'),
new RegExp('^(?:本次|本场)?(?:最多|总共|总|允许|还可|剩余)(?:可)?' + TARGET + '(' + DIGITS + ')次$'),
new RegExp('^(?:本次|本场)?(?:仅|只)(?:有|剩)?(' + DIGITS + ')次' + TARGET + '机会$'),
new RegExp('^(?:本次|本场)?(?:剩余|还剩|还有)(' + DIGITS + ')次' + TARGET + '机会$'),
new RegExp('^(?:本次|本场)?还可(?:补考|重考)' + SEPARATOR + '(' + DIGITS + ')次$'),
// The saved site's showButton2 label is “不及格,还可补考 N 次”.
// The template may wrap it in parentheses after the normal retake button.
new RegExp('^不及格[,,]还可补考(' + DIGITS + ')次$'),
new RegExp('^(?:重新答题|重新考试)?[((]不及格[,,]还可补考(' + DIGITS + ')次[))]$')
];
function clean(value) {
return String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/[0-9]/g, char => String(char.charCodeAt(0) - 0xff10)).replace(/\s+/g, ' ').trim();
}
function visible(node) {
if (!node || !node.isConnected || node.closest('[hidden],[aria-hidden="true"],[inert]')) return false;
const view = node.ownerDocument && node.ownerDocument.defaultView;
if (!view) return false;
for (let current = node; current && current.nodeType === 1; current = current.parentElement) {
const style = view.getComputedStyle(current);
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || Number(style.opacity) === 0) return false;
}
return node.getClientRects().length > 0 || view.getComputedStyle(node).display === 'contents';
}
function numberValue(raw) {
if (/^[0-9]+$/.test(raw)) return Number(raw);
if (/^[零〇]+$/.test(raw)) return 0;
if (raw === '一') return 1;
// Only zero and one affect this policy. Other explicit Chinese counts still
// form part of the signature but are never mistaken for a single attempt.
return 2;
}
function evidenceText(scope) {
const candidates = new Set();
function walk(node) {
if (node.nodeType === 3) return node.textContent;
if (node.nodeType !== 1 || node.matches(IGNORE) || !visible(node)) return '';
if (node.tagName === 'BR') return '\n';
const text = Array.from(node.childNodes, walk).join(' ');
// Small DOM rows permit labels and values in separate inline spans while
// whole pages and unrelated paragraphs are never treated as one label.
if (text.length <= 240) candidates.add(clean(text));
return BLOCK.test(node.tagName) ? '\n' + text + '\n' : text;
}
const lines = walk(scope).split(/[\n,,;;。!!]+/).map(clean).filter(Boolean);
lines.forEach(line => { if (line.length <= 240) candidates.add(line); });
// Definition lists commonly render a status label and its value separately.
scope.querySelectorAll('dt').forEach(label => {
const value = label.nextElementSibling;
if (value && value.tagName === 'DD' && visible(label) && visible(value) && !label.closest(IGNORE) && !value.closest(IGNORE)) {
candidates.add(clean(walk(label) + ' ' + walk(value)));
}
});
return candidates;
}
function read(scope) {
const empty = { blocked: false, known: false, count: null, reason: '', signature: '[]' };
// The caller must pass the current exam/quiz container. Reading Document or
// body could borrow another course's limits or a hidden background dialog.
if (!scope || scope.nodeType !== 1 || /^(?:HTML|BODY)$/.test(scope.tagName) || !visible(scope) || scope.closest(IGNORE)) return empty;
const evidence = new Set();
evidenceText(scope).forEach(raw => {
const value = raw.replace(/\s+/g, '').replace(/[::]$/, '');
for (const expression of forms) {
const match = value.match(expression);
if (!match) continue;
evidence.add(JSON.stringify([value, numberValue(match[1])]));
break;
}
});
const labels = Array.from(evidence).sort();
const values = labels.map(item => JSON.parse(item)[1]);
const count = values.includes(0) ? 0 : values.includes(1) ? 1 : null;
return {
blocked: count !== null,
known: labels.length > 0,
count: count,
reason: count === 0 ? '网页明确提示已无可用答题或考试次数,等待人工处理' : count === 1 ? '网页明确提示仅允许或剩余 1 次答题或考试机会,等待人工处理' : '',
signature: JSON.stringify(labels.map(item => JSON.parse(item)))
};
}
const api = { read: read };
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.YianAttemptPolicy = api;
})(typeof window === 'object' ? window : typeof globalThis === 'object' ? globalThis : this);
/* YianLearningEngine: 读取网页实际目录、控制原生播放器并协调已授权的学习任务。 */
(function (root, factory) {
'use strict';
@@ -2558,6 +2678,7 @@ window.JSZip = module.exports;
const LEASE_MS = 18000;
const SESSION_KEY = 'session.v1';
const CONTROL_KEY = 'control.v1';
const POLICY_KEYS = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
function clean(value) { return String(value == null ? '' : value).replace(/[\u200b-\u200d\ufeff]/g, '').replace(/\s+/g, ' ').trim(); }
function clone(value) { return JSON.parse(JSON.stringify(value)); }
@@ -2567,6 +2688,9 @@ window.JSZip = module.exports;
try { const url = new URL(value, base); return url.origin === 'http://10.119.67.7' && url.pathname.startsWith('/clientcn/') ? url : null; } catch (_) { return null; }
}
function canonical(value) { try { const u = new URL(value); u.hash = ''; return u.href; } catch (_) { return String(value || ''); } }
function examIdentity(value) {
try { const url = new URL(value); const match = url.pathname.match(/^\/clientcn\/(?:exam|examtest)\/([^/]+)(?:\/questions)?\/?$/); return match ? url.origin + ':exam:' + match[1] : ''; } catch (_) { return ''; }
}
function visible(node) {
if (!node || node.closest('[hidden],[aria-hidden="true"],.ant-modal-hidden')) return false;
for (let n = node; n && n.nodeType === 1; n = n.parentElement) {
@@ -2623,6 +2747,21 @@ window.JSZip = module.exports;
if (portal) return portal.closest('.question-modal-pc');
return Array.from(doc.querySelectorAll('app-question-modal,[data-learning-quiz]')).find(node => visible(node) && Boolean(node.querySelector('.pc_examtest_content,[app-exam-question],input[type="radio"],input[type="checkbox"],[data-question],.question-content,.question_content') || clean(node.textContent))) || null;
}
function playbackPromptOf(doc) {
// 原站 ngx-bootstrap 将模板放在 body portal,app-dialog-box 自身可能为空。
const bodies = Array.from(doc.querySelectorAll('.modal-dialog.dialog_box .modal-body,[role="dialog"] .modal-body'))
.filter(node => visible(node) && /^是否继续上次播放的位置[??]$/.test(clean(node.textContent)));
const candidates = [];
for (const body of bodies) {
const container = body.closest('.modal-content,[role="dialog"]'); if (!container) continue;
if (candidates.some(item => item.container === container)) continue;
const buttons = Array.from(container.querySelectorAll('.modal-footer button')).filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true');
const keep = buttons.filter(node => clean(node.textContent) === '继续播放');
const restart = buttons.filter(node => clean(node.textContent) === '重新开始');
candidates.push({ container, body, button: keep.length === 1 && restart.length === 1 ? keep[0] : null });
}
return candidates.length ? candidates.length === 1 ? candidates[0] : { ambiguous: true } : null;
}
function describePage(doc, url) {
const current = canonical(url || doc.URL);
const video = Array.from(doc.querySelectorAll('app-course-courseware-video video,app-video-base video,video[data-learning-video],video')).find(visible) || null;
@@ -2712,10 +2851,13 @@ window.JSZip = module.exports;
let session = null; let activeTaskId = ''; let pageKey = ''; let saveSignature = ''; let pendingQuiz = null; let worker = null;
let lastTime = null; let lastAdvancedAt = now(); let attemptedAt = 0; let playStarted = false;
let interactionAt = -Infinity; let lastAutoPlayAt = -Infinity; let manualVideoPause = false; let watchedDocument = null;
let visibilityPlayback = null; let returnResumeRequested = false; let pageAway = false; let playbackPromptClick = null;
let sharedPaused = false;
let manualConfirmRevision = 0; let pendingManualConfirm = null;
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
let policySessionId = '', policyReadSerial = 0;
const authoritativePolicies = new Set(), policyAppliedReads = new Map(), policyLocalWrites = new Map();
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
function snapshot() {
const given = typeof options.getSnapshot === 'function' ? options.getSnapshot() || {} : {};
@@ -2725,10 +2867,32 @@ window.JSZip = module.exports;
if (event.isTrusted && event.target && event.target.closest && event.target.closest('video,.xgplayer')) interactionAt = now();
}
function onVideoPause(event) {
if (event.target && event.target.tagName === 'VIDEO' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) manualVideoPause = true;
if (event.target && event.target.tagName === 'VIDEO' && !pageAway && watchedDocument?.visibilityState !== 'hidden' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) {
manualVideoPause = true; visibilityPlayback = null; returnResumeRequested = false;
}
}
function rememberPlayback() {
const snap = snapshot(); if (!snap.document || !safeURL(snap.url, snap.url)) return;
const task = state.tasks.find(t => t.id === activeTaskId); const page = describePage(snap.document, snap.url);
if (state.status !== 'running' || config.videoPaused || manualVideoPause || !task || task.kind !== 'video'
|| !['loading', 'playing', 'retrying'].includes(task.status) || !page.video || page.quiz || playbackPromptOf(snap.document) || page.video.ended || page.platformComplete) return;
if (!visibilityPlayback && (playStarted || !page.video.paused)) visibilityPlayback = { taskId: task.id, url: page.url, video: page.video, generation };
lastAdvancedAt = now(); attemptedAt = 0;
}
function onPageHidden() { pageAway = true; rememberPlayback(); }
function onPageReturn() {
if (watchedDocument && watchedDocument.visibilityState === 'hidden') return;
pageAway = false; lastAdvancedAt = now(); attemptedAt = 0;
returnResumeRequested = Boolean(visibilityPlayback && config.resumePlaybackOnReturn);
// 仍走正常调度、租约和弹题检查,事件本身不直接点击或播放。
root.setTimeout(() => { if (!destroyed) tick().catch(() => {}); }, 0);
}
function onVisibilityChange() {
if (watchedDocument && watchedDocument.visibilityState === 'hidden') onPageHidden(); else onPageReturn();
}
function notify() {
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, manualConfirmIncomplete: config.manualConfirmIncomplete, videoPaused: config.videoPaused, examPaused: config.examPaused });
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, videoPaused: config.videoPaused, examPaused: config.examPaused });
for (const key of POLICY_KEYS) state[key] = config[key];
const counted = state.tasks.filter(t => t.status !== 'expanded' && allows(t.kind));
state.total = counted.length; state.completed = counted.filter(t => t.status === 'completed').length;
state.skipped = counted.filter(t => t.status === 'skipped').length; state.waiting = counted.filter(t => t.status === 'waiting').length; state.failed = counted.filter(t => t.status === 'failed').length;
@@ -2762,6 +2926,23 @@ window.JSZip = module.exports;
}
function allows(kind) { return kind === 'course' || kind === 'unsupported' || kind === 'video' && config.mode !== 'exam' || kind === 'exam' && (config.mode === 'exam' || config.mode === 'all'); }
function account(snap, page) { return clean(snap.accountId || page.accountId || ''); }
function readExamAttempt(task, snap, page) {
const scope = snap.document.querySelector('app-examdetail,app-examtest,.pc_examtest');
const live = root.YianAttemptPolicy?.read(scope) || { known: false, blocked: false, count: null, reason: '', signature: '[]' };
if (!['exam', 'paper'].includes(page.kind)) return live;
const taskURL = task && (task.examPaperURL || task.url || task.pageUrl);
const sameExam = taskURL && examIdentity(taskURL) && examIdentity(taskURL) === examIdentity(page.url);
if (task && task.kind === 'exam' && (canonical(taskURL) === page.url || sameExam)) {
if (live.known) { task.attemptPolicy = clone(live); task.attemptPolicyURL = page.url; }
// 同一任务从详情进入试卷后,次数提示可能不再显示;保留已核对的证据。
else if (task.attemptPolicy && (task.attemptPolicyURL === page.url || sameExam)) return clone(task.attemptPolicy);
}
return live;
}
function getAttemptPolicy() {
const snap = snapshot(); if (!snap.document) return { known: false, blocked: false, count: null, reason: '', signature: '[]' };
return readExamAttempt(state.tasks.find(t => t.id === activeTaskId), snap, describePage(snap.document, snap.url));
}
async function persist() {
if (!session) return;
session.config = clone(config); session.tasks = clone(state.tasks); session.logs = clone(state.logs); session.activeTaskId = activeTaskId; session.updatedAt = now();
@@ -2773,7 +2954,7 @@ window.JSZip = module.exports;
}
const currentControl = await storage.get(CONTROL_KEY);
if (currentControl && currentControl.sessionId !== session.id) return;
syncManualConfirmation(currentControl); session.config = clone(config);
await syncPolicies(currentControl); session.config = clone(config);
const signature = JSON.stringify([session.tasks, session.config, session.activeTaskId, session.status, session.accountId, session.planId, session.bankVersion]);
if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; }
}
@@ -2806,14 +2987,25 @@ window.JSZip = module.exports;
tasks.forEach(task => {
if (parent && task.id !== parent.id && task.kind !== 'course') task.parentCourse = parent.id;
const current = state.tasks.find(t => t.id === activeTaskId);
const conflictingExam = current && task.kind === 'exam' && examIdentity(current.examPaperURL || current.url || current.pageUrl)
&& examIdentity(task.url || task.pageUrl) && examIdentity(current.examPaperURL || current.url || current.pageUrl) !== examIdentity(task.url || task.pageUrl);
// 开考前详情路由与试卷路由不同;同一已授权转场不再生成第二场考试。
const alias = task.current && current && current.kind === task.kind &&
(task.kind === 'exam' && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title || task.kind === 'video' && task.title === current.title);
const old = state.tasks.find(t => t.id === task.id || task.identity && t.kind === task.kind && t.identity === task.identity) || (alias ? current : null);
(task.kind === 'exam' && !conflictingExam && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title
&& (!current.examPaperURL || current.examPaperURL === task.url)
&& (!current.enterClickedAt || now() - current.enterClickedAt < 60000 || current.examPaperURL === task.url)
|| task.kind === 'video' && task.title === current.title);
const old = state.tasks.find(t => t.id === task.id || task.identity && t.kind === task.kind && t.identity === task.identity
|| task.current && task.kind === t.kind && task.url && (canonical(t.examPaperURL || t.url) === canonical(task.url)
|| task.kind === 'exam' && examIdentity(t.examPaperURL || t.url || t.pageUrl) && examIdentity(t.examPaperURL || t.url || t.pageUrl) === examIdentity(task.url))) || (alias ? current : null);
if (!old) state.tasks.push(task);
else {
if (alias && task.url) old.url = task.url;
if (task.status === 'completed') { old.status = 'completed'; old.progress = 1; old.reason = '平台确认已完成'; }
if (task.current && task.kind === 'exam' && /\/questions\/?$/.test(new URL(task.url).pathname)) old.examPaperURL = task.url;
if (task.status === 'completed') {
old.status = 'completed'; old.progress = 1; old.reason = '平台确认已完成';
if (old.kind === 'video' && state.video?.taskId === old.id) Object.assign(state.video, { platformComplete: true, status: 'completed' });
}
}
});
}
@@ -2837,6 +3029,8 @@ window.JSZip = module.exports;
async function initializeWork() {
const snap = snapshot(); if (!snap.document) return;
watchedDocument = snap.document; watchedDocument.addEventListener('pointerdown', onInteraction, true); watchedDocument.addEventListener('keydown', onInteraction, true); watchedDocument.addEventListener('pause', onVideoPause, true);
watchedDocument.addEventListener('visibilitychange', onVisibilityChange, true);
if (watchedDocument.defaultView) { watchedDocument.defaultView.addEventListener('blur', onPageHidden); watchedDocument.defaultView.addEventListener('focus', onPageReturn); }
const page = describePage(snap.document, snap.url);
const saved = await storage.get(SESSION_KEY);
const marker = new URL(snap.url || 'http://invalid').hash.match(/(?:^#|&)__yian_worker=([^&]+)/);
@@ -2872,33 +3066,81 @@ window.JSZip = module.exports;
} else { state.message = '账号、学习计划或题库版本已变化,未恢复旧任务'; log('warn', state.message); }
}
if (worker && session) { const checkpoint = await storage.get('task:' + session.id + ':' + activeTaskId); const task = state.tasks.find(t => t.id === activeTaskId); if (checkpoint && task) Object.assign(task, checkpoint); }
if (session) syncManualConfirmation(await storage.get(CONTROL_KEY));
if (session) await syncPolicies(await storage.get(CONTROL_KEY));
mergeTasks(discoverTasks(snap.document, snap.url)); notify();
}
function syncManualConfirmation(shared) {
if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return;
const revision = Number(shared.manualConfirmRevision) || 0;
if (revision < manualConfirmRevision || typeof shared.config.manualConfirmIncomplete !== 'boolean') return;
config.manualConfirmIncomplete = shared.config.manualConfirmIncomplete; state.manualConfirmIncomplete = config.manualConfirmIncomplete; manualConfirmRevision = revision;
session.config = Object.assign({}, session.config, { manualConfirmIncomplete: config.manualConfirmIncomplete });
if (revision < manualConfirmRevision) return;
for (const key of POLICY_KEYS) if (!authoritativePolicies.has(key) && typeof shared.config[key] === 'boolean') { config[key] = shared.config[key]; state[key] = config[key]; }
manualConfirmRevision = revision; session.config = Object.assign({}, session.config);
for (const key of POLICY_KEYS) session.config[key] = config[key];
}
function ensurePolicySession() {
const id = session?.id || '';
if (policySessionId === id) return;
policySessionId = id; authoritativePolicies.clear(); policyAppliedReads.clear(); policyLocalWrites.clear();
}
function policyKey(key, id) { return 'policy:' + (id || session.id) + ':' + key; }
async function syncPolicies(shared) {
if (!session) return;
ensurePolicySession();
syncManualConfirmation(shared);
const id = session.id, serial = ++policyReadSerial;
const writes = new Map(POLICY_KEYS.map(key => [key, policyLocalWrites.get(key) || 0]));
const records = await Promise.all(POLICY_KEYS.map(key => storage.get(policyKey(key, id))));
if (!session || session.id !== id || destroyed) return;
records.forEach((record, index) => {
const key = POLICY_KEYS[index];
if (!record || record.sessionId !== id || typeof record.value !== 'boolean'
|| pendingManualConfirm?.values && key in pendingManualConfirm.values
|| (policyLocalWrites.get(key) || 0) !== writes.get(key)
|| serial < (policyAppliedReads.get(key) || 0)) return;
// 独立键是共享策略的权威值;旧 control 全量快照不能覆盖它。
authoritativePolicies.add(key); policyAppliedReads.set(key, serial);
config[key] = record.value; state[key] = record.value;
});
session.config = Object.assign({}, session.config);
for (const key of POLICY_KEYS) session.config[key] = config[key];
}
async function control(status, force, explicitWorkerAction, manualPatch) {
if (!session || worker && !explicitWorkerAction) return;
const controlEpoch = generation, controlSessionId = session.id;
ensurePolicySession();
const current = await storage.get(CONTROL_KEY);
if (!force && current && current.sessionId !== session.id) return;
const sameSession = current && current.sessionId === session.id;
if (manualPatch) {
config.manualConfirmIncomplete = manualPatch.value;
if (sameSession && current.config) for (const key of POLICY_KEYS) {
if (!(key in manualPatch.values) && !authoritativePolicies.has(key) && typeof current.config[key] === 'boolean') config[key] = current.config[key];
}
Object.assign(config, manualPatch.values);
manualConfirmRevision = Math.max(manualConfirmRevision, sameSession ? Number(current.manualConfirmRevision) || 0 : 0) + 1;
} else syncManualConfirmation(current);
const id = session.id;
await Promise.all(Object.entries(manualPatch.values).map(async ([key, value]) => {
authoritativePolicies.add(key);
await storage.set(policyKey(key, id), { sessionId: id, value, revision: manualConfirmRevision, updatedAt: now(), owner });
}));
}
await syncPolicies(current);
// 策略通过独立键发布,不将旧 control 快照写回,避免覆盖另一页刚发出的暂停。
if (manualPatch && sameSession || controlEpoch !== generation || session?.id !== controlSessionId) return;
const sharedConfig = clone(worker && sameSession ? current.config : config);
sharedConfig.manualConfirmIncomplete = config.manualConfirmIncomplete;
for (const key of POLICY_KEYS) sharedConfig[key] = config[key];
// 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。
await storage.set(CONTROL_KEY, { sessionId: session.id, status: manualPatch && sameSession ? current.status : status, config: sharedConfig,
manualConfirmRevision, updatedAt: now(), owner: worker && sameSession ? current.owner : owner });
}
async function configure(patch) {
await initialize(); patch = patch || {}; const wasRunning = state.status === 'running';
async function configure(patch, restoreOptions) {
await initialize(); patch = patch || {};
if (restoreOptions?.restore === true && session) {
await syncPolicies(await storage.get(CONTROL_KEY));
// 普通设置快照可能来自另一个标签的旧全量写回;重载只恢复尚无权威记录的策略。
patch = Object.assign({}, patch);
for (const key of POLICY_KEYS) if (authoritativePolicies.has(key)) delete patch[key];
}
const wasRunning = state.status === 'running';
if (patch.retryDelaySeconds != null) patch = Object.assign({}, patch, { recoverySeconds: patch.retryDelaySeconds });
if (patch.maxRefresh != null) patch = Object.assign({}, patch, { maxRefreshes: patch.maxRefresh });
const modeChanged = patch.mode && patch.mode !== config.mode || patch.execution && patch.execution !== config.execution;
@@ -2911,8 +3153,12 @@ window.JSZip = module.exports;
if (patch.associations && typeof patch.associations === 'object') config.associations = clone(patch.associations);
if (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused;
if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused;
const manualPatch = typeof patch.manualConfirmIncomplete === 'boolean' ? { value: patch.manualConfirmIncomplete } : null;
if (manualPatch) { pendingManualConfirm = manualPatch; config.manualConfirmIncomplete = manualPatch.value; }
const policies = Object.fromEntries(POLICY_KEYS.filter(key => typeof patch[key] === 'boolean').map(key => [key, patch[key]]));
const manualPatch = Object.keys(policies).length ? { values: policies } : null;
if (manualPatch) {
ensurePolicySession(); pendingManualConfirm = manualPatch; Object.assign(config, policies);
for (const key of Object.keys(policies)) policyLocalWrites.set(key, (policyLocalWrites.get(key) || 0) + 1);
}
if (patch.clearLogs) state.logs = [];
if (modeChanged && wasRunning) await pause('执行模式已改变,请核对任务后继续');
if (config.videoPaused) { const snap = snapshot(); const v = snap.document && describePage(snap.document, snap.url).video; if (v) v.pause(); }
@@ -2931,14 +3177,19 @@ window.JSZip = module.exports;
state.tasks = []; activeTaskId = ''; worker = null; saveSignature = ''; mergeTasks(discoverTasks(snap.document, snap.url));
// 全新一轮不沿用旧执行状态,但保留平台本身已完成标记。
if (previous) await release();
visibilityPlayback = null; returnResumeRequested = false;
state.status = 'running'; sharedPaused = false; state.message = '正在按当前模式核对任务'; generation++; await control('running', true); await persist(); log('info', '开始本轮学习:' + config.mode + ' / ' + config.execution);
return tick();
}
async function pause(reason) {
await initialize(); generation++; state.status = 'paused'; sharedPaused = true; state.message = reason || '已暂停全部任务;网站考试倒计时仍会继续';
await initialize(); const pauseEpoch = ++generation; visibilityPlayback = null; returnResumeRequested = false; state.status = 'paused'; sharedPaused = false; state.message = reason || '已暂停全部任务;网站考试倒计时仍会继续';
if (session) session.status = 'paused'; const snap = snapshot(); const page = snap.document && describePage(snap.document, snap.url); if (page && page.video) page.video.pause();
if (typeof options.onExamPause === 'function') options.onExamPause(state.message);
await control('paused', false, true); await release(); await persist(); log('info', state.message); return getState();
await control('paused', false, true);
if (pauseEpoch !== generation || state.status !== 'paused') return getState();
// 暂停写入完成后才接受远端“继续”;使写入期间读到旧 running 的轮询失效。
sharedPaused = true; generation++;
await release(); await persist(); log('info', state.message); return getState();
}
async function resume() {
await initialize(); if (!session) return start();
@@ -2948,7 +3199,7 @@ window.JSZip = module.exports;
}
state.tasks.filter(t => (t.status === 'waiting' || ACTIVE.includes(t.status) || t.status === 'waitingPlatform') && !t.locked).forEach(t => { t.status = 'pending'; t.reason = ''; t.examRequested = false; });
config.videoPaused = false; config.examPaused = false;
generation++; state.status = 'running'; sharedPaused = false; session.status = 'running'; activeTaskId = ''; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
generation++; visibilityPlayback = null; returnResumeRequested = false; state.status = 'running'; sharedPaused = false; session.status = 'running'; activeTaskId = ''; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
await control('running', false, true); await persist(); log('info', '继续任务,重新核验页面实际状态'); return tick();
}
async function stop() {
@@ -2962,18 +3213,21 @@ window.JSZip = module.exports;
if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return false; }
}
if (!(await claim(task))) { taskState(task, 'waiting', '另一个页面正在处理同类任务,避免重复领取'); return false; }
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false;
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false; visibilityPlayback = null; returnResumeRequested = false;
const onPage = canonical(task.url || task.pageUrl) === page.url;
const isCurrentVideo = task.kind === 'video' && page.video && (task.current || resolveNode(task, snap) === page.activeCourseware);
const isCurrentExam = task.kind === 'exam' && ['exam', 'paper'].includes(page.kind) && onPage;
if (isCurrentVideo || isCurrentExam) { taskState(task, 'loading', '已领取当前页面任务'); return true; }
const node = resolveNode(task, snap);
taskState(task, 'loading', '正在通过网页现有入口打开任务');
task.navigationRequestedAt = now();
if (node && task.kind === 'exam') task.examLinkClickedAt = now();
await persist();
const targetURL = task.url || (canonical(task.pageUrl) !== page.url ? task.pageUrl : '');
task.navigationRequestedAt = now();
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, title: task.title, targetURL: canonical(targetURL), accountId: account(snap, page), expires: now() + 60000 });
if (node) { (node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node).click(); return true; }
if (node) {
(node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node).click(); return true;
}
if (task.url && typeof options.navigate === 'function' && safeURL(task.url, snap.url)) { await options.navigate(task.url); return true; }
if (canonical(task.pageUrl) !== page.url && safeURL(task.pageUrl, snap.url) && typeof options.navigate === 'function') { await options.navigate(task.pageUrl); return true; }
taskState(task, 'waiting', '找不到原任务入口,请打开该课程后继续'); activeTaskId = ''; await release(); return false;
@@ -3001,7 +3255,23 @@ window.JSZip = module.exports;
const currentTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : null;
state.video = { taskId: task.id, title: task.title, currentTime, duration, actualRate: video.playbackRate, learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, platformComplete: false, background: page.hidden, status: 'playing', retryCount: task.retryCount || 0 };
Object.assign(task, { currentTime, duration, learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, progress: duration ? Math.min(1, currentTime / duration) : 0 });
const playbackPrompt = playbackPromptOf(snap.document);
if (playbackPrompt && !popup) {
lastAdvancedAt = now(); attemptedAt = 0; visibilityPlayback = null; returnResumeRequested = false; state.video.status = 'waiting';
if (manualVideoPause || !config.autoContinuePlayback || playbackPrompt.ambiguous || !playbackPrompt.button) {
taskState(task, 'waiting', manualVideoPause ? '用户已暂停,请手动选择续播并继续任务' : !config.autoContinuePlayback ? '自动续播已关闭,请在网页选择继续播放或重新开始后继续任务' : '续播弹窗无法唯一确认,请人工选择后继续'); return;
}
if (playbackPromptClick && playbackPromptClick.container === playbackPrompt.container) {
if (now() - playbackPromptClick.at > 15000) taskState(task, 'waiting', '已选择继续播放,但网页弹窗未关闭,请人工检查');
return;
}
if (epoch !== generation || state.status !== 'running' || config.videoPaused || !config.autoContinuePlayback) return;
playbackPromptClick = { container: playbackPrompt.container, at: now() }; playStarted = false; lastTime = null;
taskState(task, 'loading', '已选择继续上次位置,等待网页恢复播放器'); playbackPrompt.button.click(); return;
}
if (!playbackPrompt) playbackPromptClick = null;
if (popup) {
visibilityPlayback = null; returnResumeRequested = false;
video.pause(); lastAdvancedAt = now(); attemptedAt = 0; playStarted = false;
if (config.mode === 'video') { taskState(task, 'waiting', '仅视频模式遇到强制课中题,请人工完成后继续'); state.video.status = 'waiting'; return; }
taskState(task, 'quiz', '正在核对课中题完整文字'); state.video.status = 'quiz';
@@ -3009,7 +3279,7 @@ window.JSZip = module.exports;
if (pendingQuiz) return;
pendingQuiz = popup;
try {
const result = await options.solveQuiz(popup, { task: clone(task), answerInterval: config.answerInterval, document: snap.document });
const result = await options.solveQuiz(popup, { task: clone(task), answerInterval: config.answerInterval, skipSingleAttemptQuiz: config.skipSingleAttemptQuiz, document: snap.document });
if (epoch !== generation || state.status !== 'running') return;
if (!result || result.status !== 'completed' || result.verified !== true || quizOf(snap.document)) taskState(task, 'waiting', result && result.reason || '课中题尚未确认提交成功,请人工核对后继续');
else { taskState(task, 'playing', '课中题已核验且弹题已关闭,继续视频'); lastAdvancedAt = now(); }
@@ -3019,6 +3289,19 @@ window.JSZip = module.exports;
}
if (manualVideoPause) { taskState(task, 'waiting', '用户已在播放器主动暂停,请点击继续恢复任务'); state.video.status = 'waiting'; return; }
if (video.ended || duration && currentTime >= duration - 0.05) { taskState(task, 'waitingPlatform', '视频已播放到末尾,等待平台确认学习完成'); state.video.status = 'waitingPlatform'; attemptedAt = 0; return; }
if ((page.hidden || pageAway) && video.paused && (playStarted || visibilityPlayback)) {
rememberPlayback(); lastAdvancedAt = now(); attemptedAt = 0;
state.video.status = 'backgroundPaused'; taskState(task, 'playing', '网站在后台暂停播放,等待切回页面后恢复'); return;
}
if (returnResumeRequested) {
const previous = visibilityPlayback; returnResumeRequested = false; visibilityPlayback = null;
if (previous && previous.taskId === task.id && previous.url === page.url && previous.video === video && previous.generation === epoch && config.resumePlaybackOnReturn) {
lastAdvancedAt = now(); attemptedAt = 0; lastTime = null;
if (video.paused) { playStarted = false; taskState(task, 'playing', '已切回页面,正在继续原播放位置'); }
}
} else if (visibilityPlayback && !page.hidden && !pageAway && video.paused && !config.resumePlaybackOnReturn) {
state.video.status = 'waiting'; taskState(task, 'waiting', '切回页面自动恢复已关闭,请手动播放后继续任务'); return;
}
if (!Number.isFinite(video.playbackRate) || Math.abs(video.playbackRate - config.rate) > 0.01) { try { video.playbackRate = config.rate; } catch (_) {} }
state.video.actualRate = video.playbackRate;
if (Math.abs(video.playbackRate - config.rate) > 0.01) state.video.rateMessage = '网站未接受所选倍速,当前实际 ' + video.playbackRate + 'X';
@@ -3061,6 +3344,17 @@ window.JSZip = module.exports;
else if (!readOnly && stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
}
async function examTick(task, snap, page) {
if (['exam', 'paper'].includes(page.kind)) {
const sourceURL = task.examPaperURL || task.url || task.pageUrl;
const sameURL = canonical(sourceURL) === page.url || examIdentity(sourceURL) && examIdentity(sourceURL) === examIdentity(page.url);
if (!sameURL) { taskState(task, 'waiting', '当前考试与已领取任务的地址不同,请核对后开始新一轮'); return; }
if (page.kind === 'paper' && task.enterClickedAt && !task.examPaperURL && page.title === task.title) task.examPaperURL = page.url;
}
const attempts = readExamAttempt(task, snap, page);
if (config.skipSingleAttemptQuiz && attempts.blocked && !task.examRequested) {
state.exam = { taskId: task.id, title: task.title, stage: 'waiting', requiresManualSubmission: true, message: attempts.reason };
taskState(task, 'waiting', attempts.reason); return;
}
// 人工交卷后仍需接收网站确认;等待/局部暂停期间不得重新开考或启动答题。
if (task.status === 'waiting' || config.examPaused) {
await observeExamState(task, true);
@@ -3165,14 +3459,15 @@ window.JSZip = module.exports;
if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); }
if (session) {
const controlValue = await storage.get(CONTROL_KEY);
if (epoch !== generation) return getState();
if (await observeControl(controlValue)) return getState();
if (worker && controlValue && controlValue.sessionId === session.id && controlValue.config) {
const sharedConfig = Object.assign({}, controlValue.config); delete sharedConfig.manualConfirmIncomplete;
const sharedConfig = Object.assign({}, controlValue.config); for (const key of POLICY_KEYS) delete sharedConfig[key];
Object.assign(config, sharedConfig);
}
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) { await pause('账号或题库版本改变,已暂停旧任务'); return getState(); }
}
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); }
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); visibilityPlayback = null; returnResumeRequested = false; playbackPromptClick = null; }
let task = state.tasks.find(t => t.id === activeTaskId);
if (task && task.kind === 'course' && page.kind === 'course' || task && task.kind === 'course' && page.kind === 'video') {
if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续');
@@ -3204,8 +3499,10 @@ window.JSZip = module.exports;
}
async function observeControl(value) {
if (!session) return false;
const controlEpoch = generation;
const shared = value === undefined ? await storage.get(CONTROL_KEY) : value;
syncManualConfirmation(shared);
await syncPolicies(shared);
if (controlEpoch !== generation) return true;
if (state.status !== 'running' && !sharedPaused) return false;
if (sharedPaused && shared && shared.sessionId === session.id && shared.status === 'running') {
sharedPaused = false; generation++; state.status = 'running'; session.status = 'running'; state.message = '已收到继续全部任务,重新核验当前页面'; playStarted = false; manualVideoPause = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
@@ -3223,10 +3520,14 @@ window.JSZip = module.exports;
function clearLogs() { state.logs = []; notify(); persist().catch(() => {}); }
function destroy() {
destroyed = true; generation++;
if (watchedDocument) { watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true); }
if (watchedDocument) {
watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true);
watchedDocument.removeEventListener('visibilitychange', onVisibilityChange, true);
if (watchedDocument.defaultView) { watchedDocument.defaultView.removeEventListener('blur', onPageHidden); watchedDocument.defaultView.removeEventListener('focus', onPageReturn); }
}
if (typeof options.onExamPause === 'function') options.onExamPause('学习控制器已退出'); release().catch(() => {});
}
return { configure, start, pause, resume, stop, tick, getState, destroy, log, clearLogs };
return { configure, start, pause, resume, stop, tick, getState, getAttemptPolicy, destroy, log, clearLogs };
}
return { create, describePage, discoverTasks, preflightTopic };
});
@@ -3297,11 +3598,16 @@ window.JSZip = module.exports;
return Array.from(container.querySelectorAll('.modal-footer button,.pc_examtest_content_question_showanswer,button[data-quiz-submit]'))
.filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && expression.test(clean(node.textContent)));
}
function attemptPolicy(view) {
if (!view?.container) return null;
const scope = view.container.closest(OWNER) || view.container;
return root.YianAttemptPolicy?.read?.(scope) || null;
}
async function run(options) {
const doc = options.document || root.document, matcher = options.matcher || root.YianAnswerMatcher;
const context = options.context || {};
if (!doc || !matcher || typeof matcher.match !== 'function') return { status: 'waiting', verified: false, reason: '课中题匹配组件未就绪' };
let cancelled = false, verifiedCount = 0;
let cancelled = false, verifiedCount = 0, attemptBlocked = null;
const timeout = Math.max(100, Math.min(15000, Number(options.timeoutMs) || 6000));
const configured = Number(context.answerInterval);
const interval = Math.max(0, Math.min(120, Number.isFinite(configured) ? configured : 4)) * 1000;
@@ -3310,6 +3616,17 @@ window.JSZip = module.exports;
if (cancelled || options.signal?.aborted) return false;
try { return typeof options.getActive !== 'function' || options.getActive() === true; } catch (_) { return false; }
};
const attemptAllowed = () => {
if (context.skipSingleAttemptQuiz === false) return true;
if (!root.YianAttemptPolicy || typeof root.YianAttemptPolicy.read !== 'function') {
attemptBlocked = '答题次数核验组件未就绪,已停止自动操作,请人工处理';
return false;
}
const policy = attemptPolicy(detect(doc));
if (!policy?.blocked) return true;
attemptBlocked = policy.reason || '网页提示课中题答题机会受限,已停止自动操作,请人工处理';
return false;
};
const manual = event => {
if (!event.isTrusted) return;
const node = event.target?.nodeType === 1 ? event.target : event.target?.parentElement;
@@ -3334,16 +3651,18 @@ window.JSZip = module.exports;
}
return null;
};
const click = node => { if (!active() || !visible(node) || node.disabled || node.getAttribute('aria-disabled') === 'true') return false; node.click(); return true; };
const click = node => { if (!active() || !attemptAllowed() || !visible(node) || node.disabled || node.getAttribute('aria-disabled') === 'true') return false; node.click(); return true; };
try {
let view = detect(doc);
if (!view || view.ambiguous || view.kind === 'portalAnswer') return result('waiting', '未发现唯一且可完整核验的课中题,等待人工检查');
for (let questionIndex = 0; questionIndex < 100; questionIndex++) {
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
if (!attemptAllowed()) return result('waiting', attemptBlocked, { manualRequired: true, singleAttempt: true });
const original = read(view, context);
if (original.error) return result('waiting', original.error);
const matched = await matcher.match(original.question, context, options.snapshot);
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
if (!attemptAllowed()) return result('waiting', attemptBlocked, { manualRequired: true, singleAttempt: true });
if (!matched || matched.status !== 'matched') return result(['missing', 'conflict'].includes(matched?.status) ? matched.status : 'unsupported', matched?.reason || '统一题库没有唯一准确答案');
if (!Array.isArray(matched.correctTexts) || !matched.correctTexts.length || !same(matched.correctTexts, original.question.options.filter(option => matched.correctLabels?.includes(option.label)).map(option => option.text))) return result('waiting', '正确答案全文不能对应当前选项,已停止');
const current = () => {
@@ -3361,7 +3680,7 @@ window.JSZip = module.exports;
}
for (const step of plan) {
const latest = current();
if (!latest || !click(latest.nodes[step.i])) return result('waiting', '题目或任务状态已变化,停止课中答题');
if (!latest || !click(latest.nodes[step.i])) return result('waiting', attemptBlocked || '题目或任务状态已变化,停止课中答题', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
const changed = await waitFor(() => { const next = current(); return next && next.nodes[step.i].classList.contains(CHECKED) === step.checked ? next : null; });
if (!changed || changed.cancelled) return result('waiting', '网站未确认选中状态或任务已取消');
if (!await pause(150)) return result('waiting', '课中答题已取消');
@@ -3369,7 +3688,7 @@ window.JSZip = module.exports;
const evidence = current();
if (!evidence || !same(evidence.question.options.filter((_, i) => evidence.nodes[i].classList.contains(CHECKED)).map(option => option.text), matched.correctTexts)) return result('waiting', '课中题实际选中集合与正确答案全文不一致');
const submits = buttons(view.container, /^(查看答案|提交答案|确认答案)$/);
if (submits.length !== 1 || !current() || !click(submits[0])) return result('waiting', '课中题提交入口无法唯一确认');
if (submits.length !== 1 || !current() || !click(submits[0])) return result('waiting', attemptBlocked || '课中题提交入口无法唯一确认', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
// The real template replaces the question with an answer overlay immediately;
// absence of the question alone does not imply that the grade request succeeded.
const graded = await waitFor(() => {
@@ -3388,7 +3707,7 @@ window.JSZip = module.exports;
if (graded.closed) return result('complete', '答案全文核验通过,正常提交后弹题已关闭');
const advances = buttons(graded.container, /^(下一题|关闭)$/);
const isLast = advances.length === 1 && clean(advances[0].textContent) === '关闭';
if (advances.length !== 1 || !click(advances[0])) return result('waiting', '课中题已判定正确,但下一题或关闭按钮无法唯一确认');
if (advances.length !== 1 || !click(advances[0])) return result('waiting', attemptBlocked || '课中题已判定正确,但下一题或关闭按钮无法唯一确认', attemptBlocked ? { manualRequired: true, singleAttempt: true } : null);
const next = await waitFor(() => {
const seen = detect(doc);
// CDK detach/attach can briefly render neither overlay between questions.
@@ -3520,8 +3839,24 @@ window.JSZip = module.exports;
}
function settings() { return options.getSettings ? options.getSettings() : {}; }
function manualIncomplete() { return settings().manualConfirmIncomplete !== false; }
function attemptPolicy() {
const policy = (options.getAttemptPolicy ? options.getAttemptPolicy() : root.YianAttemptPolicy?.read(doc.querySelector('app-examdetail,app-examtest,.pc_examtest'))) || {};
const active = settings().skipSingleAttemptQuiz !== false;
return { active, blocked: active && policy.blocked === true, count: policy.count == null ? null : policy.count,
reason: clean(policy.reason) || '仅有 1 次答题机会,已停止自动答题与交卷,请在原网页人工操作',
signature: active ? JSON.stringify([policy.signature || '', policy.blocked === true, policy.count == null ? null : policy.count]) : '' };
}
function blockSingleAttempt(policy) {
generation++; enabled = false;
if (['submitting', 'confirming', 'unknown'].includes(stage)) stage = 'unknown';
else stage = 'waiting';
error = policy.reason;
return emit({ skipSingleAttemptQuiz: policy.active, singleAttemptBlocked: true, attemptCount: policy.count,
requiresManualSubmission: true, message: error });
}
function scopeValue(ctx, evidence, topic) {
return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses, manualIncomplete()]);
const policy = attemptPolicy();
return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses, manualIncomplete(), policy.active, policy.signature]);
}
function resultVisible() {
return Array.from(doc.querySelectorAll('.exam_submited_box,.exam-result,app-exam-result,[data-exam-result]'))
@@ -3576,8 +3911,12 @@ window.JSZip = module.exports;
async function enable() {
const token = ++generation, requested = context();
enabled = false;
const initialPolicy = attemptPolicy();
if (initialPolicy.blocked) { blockSingleAttempt(initialPolicy); return false; }
await observe();
if (token !== generation || !sameContext(requested, context()) || !entry || ['submitting', 'confirming', 'unknown'].includes(stage) || destroyed || resultVisible()) return false;
const currentPolicy = attemptPolicy();
if (currentPolicy.blocked) { blockSingleAttempt(currentPolicy); return false; }
if (entry.manualRequired && stage !== 'awaitingAttempt' && manualIncomplete()) {
stage = 'waiting'; error = '网站提示存在未作答题,请在原网页手动确认交卷';
emit({ requiresManualSubmission: true, message: error }); return false;
@@ -3596,6 +3935,8 @@ window.JSZip = module.exports;
if (!manualIncomplete() && entry.manualRequired) { entry.manualRequired = false; entry.confirmed = false; delete entry.submitAt; }
scope = scopeValue(ctx, evidence, topic);
enabled = true; stage = 'answering'; error = ''; entry.stage = stage; entry.paperSignature = evidence.signature; await persist();
const finalPolicy = attemptPolicy();
if (token === generation && finalPolicy.blocked) { blockSingleAttempt(finalPolicy); return false; }
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context()) || !context().ready || options.getTopic()?.status !== 'matched' || scope !== scopeFor(context())) {
if (token === generation) disable('启动过程中页面、题库或任务状态已变化,请重新启动'); return false;
}
@@ -3616,6 +3957,8 @@ window.JSZip = module.exports;
}
function canClick(token, ctx, confirmation) {
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context())) return false;
const policy = attemptPolicy();
if (policy.blocked) { blockSingleAttempt(policy); return false; }
const current = context(), topic = options.getTopic(), evidence = readPaper(doc, current, options.getSnapshot(), topic);
const configured = Number(settings().minSubmitMinutes), minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
const pendingTooLong = confirmation && now() - entry.submitAt > 12000;
@@ -3654,6 +3997,7 @@ window.JSZip = module.exports;
return emit({ message: '网站已显示考试结果', submitted: true, requiresManualSubmission: false });
}
const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic);
const policy = attemptPolicy();
const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000);
const configured = Number(settings().minSubmitMinutes);
const minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
@@ -3663,8 +4007,10 @@ window.JSZip = module.exports;
entry.lastObservedAt = now(); entry.lastRemaining = remaining;
emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining,
manualConfirmIncomplete: manualIncomplete(),
skipSingleAttemptQuiz: policy.active, singleAttemptBlocked: policy.blocked, attemptCount: policy.count,
requiresManualSubmission: manualIncomplete() && (Boolean(entry.manualRequired) || runner === 'complete' && !evidence.fullyVerified),
predictedScore: Math.round(evidence.predictedScore * 100) / 100, submitted: false, message: error || evidence.reason }));
if (policy.blocked) return blockSingleAttempt(policy);
if (!enabled) return last;
if (scope !== scopeValue(ctx, evidence, topic) || topic?.status !== 'matched') {
disable('试卷、题库或主题关联已变更,请重新启动核验'); return last;
@@ -3762,9 +4108,16 @@ window.JSZip = module.exports;
let positionRevision = 0;
let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle';
let actionEpoch = 0;
let learningSettings = { mode: 'videoQuiz', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {} };
const policyKeys = ['manualConfirmIncomplete', 'autoContinuePlayback', 'resumePlaybackOnReturn', 'skipSingleAttemptQuiz'];
const pendingLearningSettings = new Map();
let learningSettings = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, autoContinuePlayback: true, resumePlaybackOnReturn: true, skipSingleAttemptQuiz: true, associations: {} };
const inScope = () => YianSiteScope.isAllowedURL(location.href);
function skipSingleAttempt() { return learning ? learning.getState().skipSingleAttemptQuiz !== false : learningSettings.skipSingleAttemptQuiz !== false; }
function attemptPolicy() {
return learning?.getAttemptPolicy() || window.YianAttemptPolicy.read(document.querySelector('app-examdetail,app-examtest,.pc_examtest'));
}
function topicPreflight() {
return window.YianLearningEngine.preflightTopic(document, location.href, snapshot, learningSettings.associations || {});
}
@@ -3815,13 +4168,15 @@ window.JSZip = module.exports;
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
const signature = route.root ? structureSignature(bank) : '';
const topic = route.mode === 'test' ? topicPreflight() : null;
const attempt = route.mode === 'test' ? attemptPolicy() : null;
const attemptBlocked = !!attempt && skipSingleAttempt() && attempt.blocked;
return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key
&& page.root === route.root && page.signature === signature,
&& page.root === route.root && page.signature === signature && !attemptBlocked,
practice: route.practice, exam: route.exam, mode: route.mode, local: route.local, role: config.role, course: bank.title,
sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root,
pageSignature: signature, revision: snapshot.revision, snapshot, submitted: route.submitted, expired: route.expired,
courses: topic && topic.status === 'matched' ? topic.courses : undefined,
topicKey: topic ? JSON.stringify([topic.status, topic.courses, learningSettings.associations]) : '' };
topicKey: topic ? JSON.stringify([topic.status, topic.courses, learningSettings.associations, skipSingleAttempt(), attempt.signature]) : '' };
}
const runner = window.YianAnswerRunner.create({ getContext,
@@ -3845,10 +4200,12 @@ window.JSZip = module.exports;
isLearned: learningState.video.platformComplete, refreshCount: learningState.video.retryCount }) : null;
const route = routeInfo();
const currentExam = route.mode === 'test' && route.root ? Object.assign({}, learningState.exam || {}, examState) : learningState.exam || {};
panel.setState({ learning: Object.assign({}, learningSettings, learningState, {
const rendered = Object.assign({}, learningSettings, learningState, {
video, exam: Object.assign({}, currentExam, { status: currentExam.stage || currentExam.status,
submitWaitSeconds: currentExam.waitSeconds }), associations: learningSettings.associations || {},
currentTitle: window.YianLearningEngine.describePage(document, location.href).title }) });
currentTitle: window.YianLearningEngine.describePage(document, location.href).title });
for (const [key, pending] of pendingLearningSettings) rendered[key] = pending.value;
panel.setState({ learning: rendered });
}
function refreshPanel(extra) {
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
@@ -3875,6 +4232,9 @@ window.JSZip = module.exports;
function setAutoAnswer(enabled) {
if (!enabled) actionEpoch++;
const context = getContext();
if (enabled && context.mode === 'test' && skipSingleAttempt() && attemptPolicy().blocked) {
panel.setNotice(attemptPolicy().reason, 'warning'); refreshPanel(); return false;
}
if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !snapshot.total)) {
panel.setNotice('请先导入统一题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
}
@@ -3941,11 +4301,11 @@ window.JSZip = module.exports;
});
async function changeLearningSetting(key, value) {
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'manualConfirmIncomplete', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', ...policyKeys, 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
if (!allowed.includes(key)) return;
if (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].includes(value)) throw new Error('请选择有效任务模式');
if (key === 'execution' && !['sequential', 'parallel'].includes(value)) throw new Error('请选择顺序或并行');
if (key === 'manualConfirmIncomplete' && typeof value !== 'boolean') throw new Error('题目不全时手动确认须为开或关');
if (policyKeys.includes(key) && typeof value !== 'boolean') throw new Error('开关设置须为开或关');
const ranges = { rate: [1, 2], answerInterval: [0, 120], minSubmitMinutes: [0.5, 180], stallSeconds: [1, 600], retryDelaySeconds: [1, 120], maxRefresh: [1, 3] };
if (ranges[key]) {
const range = ranges[key]; value = Number(value);
@@ -3957,15 +4317,20 @@ window.JSZip = module.exports;
|| Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) {
throw new Error('主题关联须使用完整标题及题库课程名称列表');
}
if (['mode', 'execution', 'associations', 'manualConfirmIncomplete'].includes(key)) {
actionEpoch++;
config.autoAnswer = false; runner.stop('执行模式或主题关联变更'); examController.disable('设置变更,须重新启动核验');
await learning.pause();
const pending = { value }; pendingLearningSettings.set(key, pending);
try {
if (['mode', 'execution', 'associations', 'manualConfirmIncomplete', 'skipSingleAttemptQuiz'].includes(key)) {
actionEpoch++;
config.autoAnswer = false; runner.stop('执行策略或主题关联变更'); examController.disable('设置变更,须重新启动核验');
await learning.pause();
}
learningSettings = Object.assign({}, learningSettings, { [key]: value });
await learning.configure({ [key]: value });
await saveSetting('learningSettings', learningSettings);
} finally {
if (pendingLearningSettings.get(key) === pending) pendingLearningSettings.delete(key);
refreshPanel(); renderLearning();
}
learningSettings = Object.assign({}, learningSettings, { [key]: value });
await learning.configure({ [key]: value });
await saveSetting('learningSettings', learningSettings);
refreshPanel(); renderLearning();
}
async function taskAction(action) {
actionEpoch++;
@@ -3991,8 +4356,9 @@ window.JSZip = module.exports;
const learningStorage = { get: (key, fallback) => store.getSetting('learning:' + key, fallback), set: (key, value) => store.setSetting('learning:' + key, value) };
examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot,
getTopic: topicPreflight, getSettings: () => Object.assign({}, learningSettings, {
manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false
}), getRunnerState: () => runnerState,
manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false,
skipSingleAttemptQuiz: skipSingleAttempt()
}), getAttemptPolicy: attemptPolicy, getRunnerState: () => runnerState,
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
onState: state => { examState = state; renderLearning(); } });
learning = window.YianLearningEngine.create({
@@ -4012,7 +4378,7 @@ window.JSZip = module.exports;
const epoch = actionEpoch;
const value = await window.YianQuizAdapter.solve({ document, snapshot, matcher,
context: { course: window.YianLearningEngine.describePage(document, location.href).title,
sourceUrl: location.href, answerInterval: quizContext.answerInterval },
sourceUrl: location.href, answerInterval: quizContext.answerInterval, skipSingleAttemptQuiz: quizContext.skipSingleAttemptQuiz },
getActive: () => epoch === actionEpoch && !disposed && inScope() && learning.getState().status === 'running'
&& !learning.getState().videoPaused && mode === learningSettings.mode && revision === snapshot.revision });
return Object.assign({}, value, { status: value.status === 'complete' ? 'completed' : value.status });
@@ -4027,9 +4393,7 @@ window.JSZip = module.exports;
},
onState: state => {
learningState = state;
if (!loading && typeof state.manualConfirmIncomplete === 'boolean') {
learningSettings.manualConfirmIncomplete = state.manualConfirmIncomplete;
}
if (!loading) for (const key of policyKeys) if (!pendingLearningSettings.has(key) && typeof state[key] === 'boolean') learningSettings[key] = state[key];
renderLearning();
}
});
@@ -4054,6 +4418,9 @@ window.JSZip = module.exports;
}
const route = routeInfo();
if (!loading) {
if (route.mode === 'test' && skipSingleAttempt() && attemptPolicy().blocked && config.autoAnswer) {
config.autoAnswer = false; runKey = ''; runner.stop(attemptPolicy().reason); panel.setNotice(attemptPolicy().reason, 'warning');
}
examController.tick().catch(error => panel.setNotice(error.message, 'error'));
learning.tick().catch(error => panel.setNotice(error.message, 'error'));
}
@@ -4132,7 +4499,9 @@ window.JSZip = module.exports;
const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
snapshot = values[0]; config.role = '';
if (snapshot.migration?.status === 'pending') panel.setNotice('旧题库仍可使用,但统一存储迁移尚未保存:' + snapshot.migration.error, 'warning');
learningSettings = Object.assign({}, learningSettings, values[1]); await learning.configure(learningSettings);
learningSettings = Object.assign({}, learningSettings, values[1]); await learning.configure(learningSettings, { restore: true });
const restoredPolicies = learning.getState();
for (const key of policyKeys) learningSettings[key] = restoredPolicies[key];
config.allowCopy = values[2] !== false;
const position = values[3];
// A drag during asynchronous storage loading wins over the older saved position.