fix: recover expired task leases and persist diagnostic logs

This commit is contained in:
薇星幻海
2026-10-18 02:30:56 +08:00
parent 1b497286dc
commit ef35b461f7
15 changed files with 1368 additions and 85 deletions
+371 -38
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 谊安学助手 2.0
// @namespace yianxue-question-bank
// @version 2.1.2
// @version 2.1.3
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
// @match http://10.119.67.7/clientcn/*
// @run-at document-idle
@@ -1108,6 +1108,187 @@ window.JSZip = module.exports;
global.YianCopyController = { create: create };
})(typeof window !== 'undefined' ? window : globalThis);
/* Bounded, local-only diagnostics. Never stores question or answer contents. */
(function (root) {
'use strict';
const KEY = 'diagnosticLog.v1';
const ENABLED_KEY = 'diagnosticLog.enabled';
const LIMIT = 2000;
const DETAIL_KEYS = new Set(('action attempt attemptId attempts autoAnswer autoContinuePlayback available bankTotal bankVersion bankRevision blocked canAnswer canExport completed confirmed count currentTime deadline delay destination details dropped duration elapsed elapsedSeconds enabled epoch error errorName event execution expected expires expiresAt failed fresh generation hasTask heartbeatAt holder id interval kind lastActivityAt lastProgressAt lease leaseKey leaseOwner leases leaseWaiting loaded manualConfirmIncomplete maxAttempts maxRefresh message minSubmitMinutes mode name nextTask now owner ownerId ownOwner page pageId pageKind pageUrl path paused pending predictedScore previous previousOwner previousStatus previousReason progress rate reason remaining remainingAttempts remainingSeconds requested result retryCount role route running scope seconds selected sequence session sessionId signature since singleAttempt skipSingleAttemptQuiz skipped source stack stage stale startedAt status stopReason submitted task taskCount taskId taskKey taskTitle taskType tasks time title total totalAttempts totalQuestions type updatedAt url verified verifiedCount version video visible visibility waitedMs waiting waitReason worker workerId workers').split(' '));
const PRIVATE_KEY = /password|passwd|token|cookie|authorization|credential|secret|^(?:answers?|correctTexts?|questions?|options?|题干|题目|答案|选项)$/i;
function safeURL(value) {
try { const parsed = new URL(String(value)); return /^(https?|file):$/.test(parsed.protocol) ? parsed.origin + parsed.pathname : '[已省略地址]'; }
catch (_) { return String(value).split(/[?#]/)[0].slice(0, 400); }
}
function text(value, maximum) {
let string;
try { string = String(value == null ? '' : value); } catch (_) { return '[无法读取]'; }
return string
.replace(/https?:\/\/[^\s<>"']+/gi, safeURL)
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [已隐藏]')
.replace(/\b(password|passwd|token|cookie|authorization|secret)\s*[:=]\s*[^\s,;]+/gi, '$1=[已隐藏]')
.slice(0, maximum || 700);
}
function sanitize(value, depth, seen) {
if (value == null || typeof value === 'boolean') return value;
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') return text(value);
if (typeof value !== 'object' || depth > 4 || seen.has(value)) return null;
seen.add(value);
let result;
if (Array.isArray(value)) result = value.slice(0, 30).map(function (entry) { return sanitize(entry, depth + 1, seen); });
else {
result = {};
Object.keys(value).slice(0, 100).forEach(function (key) {
if (PRIVATE_KEY.test(key) || !DETAIL_KEYS.has(key)) return;
if (typeof value[key] === 'function' || typeof value[key] === 'undefined') return;
result[key] = /^(url|pageUrl|path|route|destination)$/.test(key) && typeof value[key] === 'string' ? safeURL(value[key]) : sanitize(value[key], depth + 1, seen);
});
}
seen.delete(value);
return result;
}
function clean(value) { try { return sanitize(value, 0, new Set()); } catch (_) { return {}; } }
function create(options) {
options = options || {};
const store = options.storage || { name: 'memory', get: async function (_, fallback) { return fallback; }, set: async function () {} };
const now = typeof options.now === 'function' ? options.now : Date.now;
const session = Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36);
let sequence = 0, records = [], pending = [], enabled = true, initialized = false, destroyed = false;
let dropped = 0, clearedAt = -1, timer = null, errorCount = 0, storageError = '', queue = Promise.resolve();
let settingRevision = 0;
function timestamp() { const value = Number(now()); return Number.isFinite(value) ? value : Date.now(); }
function emit() { if (typeof options.onState === 'function') { try { options.onState(getState()); } catch (_) { /* UI failures must not interrupt tasks. */ } } }
function report(error) {
storageError = text(error && error.message || error || '日志存储失败', 240);
if (errorCount++ < 1 && typeof options.onError === 'function') { try { options.onError(new Error(storageError)); } catch (_) { /* Do not recurse through diagnostics. */ } }
}
function validate(data) {
if (!data || data.schema !== 1 || !Array.isArray(data.records)) return { records: [], dropped: 0, clearedAt: -1 };
const cutoff = Number.isFinite(data.clearedAt) ? data.clearedAt : -1;
return { records: data.records.filter(function (item) { return item && typeof item.id === 'string' && Number.isFinite(item.time) && item.time > cutoff; }).slice(-LIMIT).map(function (item) {
return { id: text(item.id, 100), time: item.time, lastTime: Number.isFinite(item.lastTime) ? item.lastTime : item.time,
level: ['info', 'warning', 'error'].includes(item.level) ? item.level : 'info', event: text(item.event, 100), message: text(item.message), details: clean(item.details), context: clean(item.context), count: Math.max(1, Math.min(1000000, Math.floor(Number(item.count) || 1))) };
}), dropped: Math.max(0, Number(data.dropped) || 0), clearedAt: cutoff };
}
function merge(left, right, cutoff) {
const map = new Map();
left.concat(right).forEach(function (item) {
if (item.time <= cutoff) return;
const old = map.get(item.id);
if (!old || item.count > old.count || item.lastTime > old.lastTime) map.set(item.id, item);
});
const result = Array.from(map.values()).sort(function (a, b) { return a.time - b.time || a.id.localeCompare(b.id); });
const removed = Math.max(0, result.length - LIMIT);
return { records: result.slice(-LIMIT), removed: removed };
}
const ready = Promise.resolve().then(async function () {
const revision = settingRevision;
try {
const values = await Promise.all([store.get(KEY, null), store.get(ENABLED_KEY, true)]);
const saved = validate(values[0]);
if (revision === 0 && settingRevision === 0) enabled = values[1] !== false;
clearedAt = saved.clearedAt; dropped = saved.dropped;
records = merge(saved.records, enabled ? records : [], clearedAt).records;
if (!enabled) pending = [];
} catch (error) { report(error); }
initialized = true;
if (pending.length) schedule();
emit();
return getState();
});
function getState() {
return { enabled: enabled, total: records.length, dropped: dropped, limit: LIMIT, storage: store.name || 'local', storageError: storageError, initialized: initialized, pending: pending.length, sessionId: session };
}
function schedule() {
if (timer || destroyed || !initialized) return;
timer = root.setTimeout(function () { timer = null; flush().catch(function () {}); }, 450);
if (timer && typeof timer.unref === 'function') timer.unref();
}
function record(level, event, message, details) {
if (!enabled || destroyed) return false;
let context = {};
try { context = typeof options.getContext === 'function' ? clean(options.getContext()) : {}; } catch (_) { /* Logging may never interrupt a task. */ }
const time = Math.max(timestamp(), clearedAt + 1);
const entry = { id: session + '.' + (++sequence), time: time, lastTime: time,
level: level === 'warn' ? 'warning' : ['info', 'warning', 'error'].includes(level) ? level : 'info',
event: /^[\w.:-]{1,100}$/.test(String(event || '')) ? String(event) : 'diagnostic', message: text(message), details: clean(details || {}), context: context, count: 1 };
const previous = records[records.length - 1];
if (previous && previous.id.startsWith(session + '.') && time - previous.lastTime >= 0 && time - previous.lastTime <= 2000 && JSON.stringify([previous.level, previous.event, previous.message, previous.details, previous.context]) === JSON.stringify([entry.level, entry.event, entry.message, entry.details, entry.context])) {
previous.count = Math.min(1000000, previous.count + 1); previous.lastTime = time;
pending.push(Object.assign({}, previous));
} else { records.push(entry); pending.push(entry); }
// Both the visible buffer and unsaved batch are bounded, even if storage is unavailable.
if (records.length > LIMIT) { dropped += records.length - LIMIT; records = records.slice(-LIMIT); }
const batch = new Map(); pending.forEach(function (item) { batch.set(item.id, item); }); pending = Array.from(batch.values()).slice(-LIMIT);
schedule(); return true;
}
function lock(action) {
const locks = root.navigator && root.navigator.locks;
return locks && typeof locks.request === 'function' ? locks.request('yianxue-diagnostic-log-v1', action) : action();
}
function flush() {
if (timer) { root.clearTimeout(timer); timer = null; }
queue = queue.then(async function () {
await ready;
try {
await lock(async function () {
const saved = validate(await store.get(KEY, null));
const cutoff = Math.max(clearedAt, saved.clearedAt);
const merged = merge(saved.records, records, cutoff);
const nextDropped = Math.max(dropped, saved.dropped + merged.removed);
const payload = JSON.parse(JSON.stringify({ schema: 1, records: merged.records, dropped: nextDropped, clearedAt: cutoff }));
if (pending.length) await store.set(KEY, payload);
const savedIds = new Map(payload.records.map(function (item) { return [item.id, [item.count, item.lastTime]]; }));
pending = pending.filter(function (item) { const value = savedIds.get(item.id); return !value || item.count > value[0] || item.lastTime > value[1]; });
const local = merge(payload.records, pending, cutoff); records = local.records; dropped = Math.max(dropped, nextDropped + local.removed); clearedAt = cutoff;
storageError = '';
});
} catch (error) { report(error); }
emit();
return getState();
});
return queue;
}
async function setEnabled(value) {
settingRevision++; enabled = value !== false;
await ready;
if (!enabled) await flush();
try { await store.set(ENABLED_KEY, enabled); } catch (error) { report(error); }
emit();
return getState();
}
function getRecords() { return JSON.parse(JSON.stringify(records)); }
async function clear() {
await ready; await flush();
queue = queue.then(async function () { try { return await lock(async function () {
clearedAt = Math.max(timestamp(), records.reduce(function (latest, item) { return Math.max(latest, item.time); }, -1));
records = []; pending = []; dropped = 0;
try { await store.set(KEY, { schema: 1, records: [], dropped: 0, clearedAt: clearedAt }); storageError = ''; } catch (error) { report(error); }
emit();
return getState();
}); } catch (error) { report(error); return getState(); } });
return queue;
}
async function exportJSON() {
await flush();
return JSON.stringify({ schema: 1, exportedAt: new Date(timestamp()).toISOString(), state: getState(), records: getRecords() }, null, 2);
}
async function exportText() {
await flush();
const state = getState();
return '谊安学助手诊断日志\n导出时间:' + new Date(timestamp()).toISOString() + '\n已保存:' + state.total + ' 条;超出容量移除:' + state.dropped + ' 条\n\n' + records.map(function (item) {
return new Date(item.time).toISOString() + ' [' + item.level + '] ' + item.event + ' ' + item.message + (item.count > 1 ? '(重复 ' + item.count + ' 次,最后 ' + new Date(item.lastTime).toISOString() + ')' : '') + '\n context=' + JSON.stringify(item.context) + '\n details=' + JSON.stringify(item.details);
}).join('\n');
}
async function destroy() { destroyed = true; if (timer) { root.clearTimeout(timer); timer = null; } return flush(); }
return { ready: ready, record: record, setEnabled: setEnabled, getState: getState, getRecords: getRecords, clear: clear, exportJSON: exportJSON, exportText: exportText, flush: flush, destroy: destroy };
}
const api = Object.freeze({ create: create, key: KEY, enabledKey: ENABLED_KEY, limit: LIMIT });
root.YianDiagnosticLogger = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
/* 独立题库助手面板:页面正文与面板样式互不影响。 */
(function (global) {
'use strict';
@@ -1132,6 +1313,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,
diagnostics: { enabled: true, total: 0, dropped: 0, limit: 2000, storage: '', pending: 0, storageError: '' },
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;
@@ -1665,7 +1847,21 @@ window.JSZip = module.exports;
invoke('onLearningSetting', 'associations', associations); refreshLearning(); setNotice('主题关联已删除。', 'success');
});
views.console.appendChild(el(doc, 'h2', 'section-title', '运行控制台'));
views.console.appendChild(el(doc, 'h2', 'section-title', '故障诊断记录'));
const diagnosticSwitch = makeSwitch('持续记录诊断日志', function () {
state.diagnostics.enabled = diagnosticSwitch.input.checked;
invoke('onLogAction', 'record', diagnosticSwitch.input.checked); refreshLearning();
}, views.console);
diagnosticSwitch.input.setAttribute('data-testid', 'diagnostic-record');
const diagnosticStatus = el(doc, 'p', 'hint'); diagnosticStatus.setAttribute('data-testid', 'diagnostic-status');
const diagnosticActions = el(doc, 'div', 'console-actions');
[['exportJson', '导出诊断 JSON'], ['exportText', '导出诊断 TXT'], ['clearSaved', '清空已保存诊断']].forEach(function (entry) {
const button = el(doc, 'button', '', entry[1]); button.type = 'button';
button.setAttribute('data-testid', 'diagnostic-' + entry[0]);
button.addEventListener('click', function () { invoke('onLogAction', entry[0]); });
diagnosticActions.appendChild(button);
});
views.console.append(diagnosticStatus, el(doc, 'p', 'hint', '记录任务切换、播放、执行权和交卷等待原因,刷新后可导出核对;不保存题库、题目或答案正文。关闭只停止新增记录。'), diagnosticActions, el(doc, 'h2', 'section-title', '本轮运行显示'));
const logActions = el(doc, 'div', 'console-actions');
const logFilter = el(doc, 'select', 'console-filter'); logFilter.setAttribute('aria-label', '日志级别');
[['all', '全部日志'], ['info', '运行状态'], ['warning', '警告'], ['error', '错误']].forEach(function (entry) { const option = el(doc, 'option', '', entry[1]); option.value = entry[0]; logFilter.appendChild(option); });
@@ -1770,7 +1966,7 @@ window.JSZip = module.exports;
});
}
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: '已读取目录' };
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', backgroundPaused: '后台暂停,等待返回', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingLease: '等待执行权(自动重试)', 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 || {};
@@ -1793,7 +1989,7 @@ window.JSZip = module.exports;
const total = Math.max(0, Number(learning.total) || 0), completed = Math.max(0, Number(learning.completed) || 0);
const paused = ['paused', 'stopped'].includes(learning.status);
const active = !['idle', 'ready', 'stopped', 'completed', 'done', 'finished'].includes(learning.status || 'idle');
setText(globalStatus, statusName(learning.status) + (Number(learning.waiting) > 0 ? ' · ' + Number(learning.waiting) + ' 项等待人工' : ''));
setText(globalStatus, statusName(learning.status) + (Number(learning.waiting) > 0 ? ' · ' + Number(learning.waiting) + ' 项等待人工' : '') + (Number(learning.leaseWaiting) > 0 ? ' · ' + Number(learning.leaseWaiting) + ' 项等待执行权' : ''));
setText(globalPause, paused ? '继续全部' : '暂停全部');
globalPause.disabled = !active && !paused;
globalProgress.max = Math.max(total, 1); globalProgress.value = Math.min(completed, total);
@@ -1802,7 +1998,7 @@ window.JSZip = module.exports;
const examTitle = plain(firstValue(exam, ['title', 'theme', 'name'], '暂无考试任务'));
const videoState = statusName(video.status || 'idle'), examState = statusName(exam.status || 'idle');
setText(workerStatus, '视频:' + videoState + ' 考试:' + examState + (learning.message ? '\n' + plain(learning.message) : ''));
setText(collapseSummary, completed + '/' + total + (Number(learning.waiting) > 0 ? ' · 待人工' : paused ? ' · 已暂停' : ''));
setText(collapseSummary, completed + '/' + total + (Number(learning.waiting) > 0 ? ' · 待人工' : paused ? ' · 已暂停' : Number(learning.leaseWaiting) > 0 ? ' · 待执行权' : ''));
collapseSummary.hidden = Boolean(state.expanded);
setText(learningMessage, learning.message || '在学习计划、课程或考试页面开始本轮,先读取实际任务,再按网页状态执行。人工刷新后需继续上次任务。');
Object.keys(summaryNodes).forEach(function (key) { setText(summaryNodes[key], key === 'etaSeconds' ? duration(learning.etaSeconds) : Math.max(0, Number(learning[key]) || 0)); });
@@ -1874,6 +2070,13 @@ window.JSZip = module.exports;
setText(associationList, Object.keys(associations).map(function (title) { return title + ' → ' + (Array.isArray(associations[title]) ? associations[title] : []).join('、'); }).join('\n') || '暂无手工主题关联。');
if (root.activeElement !== associationCourses) associationCourses.value = (associations[normalized(associationTitle.value)] || []).join('\n');
}
const diagnostics = state.diagnostics || {};
diagnosticSwitch.input.checked = diagnostics.enabled !== false;
setText(diagnosticStatus, (diagnostics.enabled === false ? '已停止新增记录' : '记录已开启') + ' · 已记录 ' + Math.max(0, Number(diagnostics.total) || 0) + ' 条 / 最多 ' + (Number(diagnostics.limit) || 2000) + ' 条'
+ (Number(diagnostics.pending) > 0 ? ' · 待保存 ' + Number(diagnostics.pending) + ' 条' : '')
+ (Number(diagnostics.dropped) > 0 ? ' · 已移除最早 ' + Number(diagnostics.dropped) + ' 条' : '')
+ (diagnostics.storage === 'memory' ? ' · 当前仅内存保存' : diagnostics.storage === 'origin' ? ' · 当前浏览器本地保存' : '')
+ (diagnostics.storageError ? '\n保存异常:' + plain(diagnostics.storageError) + ';可先导出当前记录。' : ''));
const logs = (Array.isArray(learning.logs) ? learning.logs : []).slice(-500);
const filtered = logs.filter(function (entry) { return logFilter.value === 'all' || (entry.level === 'warn' ? 'warning' : entry.level || 'info') === logFilter.value; });
const nextLogSignature = JSON.stringify([logFilter.value, logs]);
@@ -2040,6 +2243,7 @@ window.JSZip = module.exports;
if (Object.prototype.hasOwnProperty.call(update, 'learning')) {
update.learning = Object.assign({}, state.learning || {}, update.learning || {});
}
if (Object.prototype.hasOwnProperty.call(update, 'diagnostics')) update.diagnostics = Object.assign({}, state.diagnostics || {}, update.diagnostics || {});
Object.assign(state, update);
refresh();
if (acceptPosition) {
@@ -2673,7 +2877,7 @@ window.JSZip = module.exports;
'use strict';
const TERMINAL = ['completed', 'skipped', 'failed', 'expanded'];
const ACTIVE = ['loading', 'playing', 'quiz', 'answering', 'waitingSubmit', 'retrying'];
const ACTIVE = ['loading', 'playing', 'quiz', 'answering', 'waitingSubmit', 'waitingLease', 'retrying'];
const MODES = ['video', 'videoQuiz', 'exam', 'all'];
const LEASE_MS = 18000;
const SESSION_KEY = 'session.v1';
@@ -2853,6 +3057,7 @@ window.JSZip = module.exports;
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 logSequence = 0; let leaseBanner = null;
let manualConfirmRevision = 0; let pendingManualConfirm = null;
let policySessionId = '', policyReadSerial = 0;
const authoritativePolicies = new Set(), policyAppliedReads = new Map(), policyLocalWrites = new Map();
@@ -2896,9 +3101,10 @@ window.JSZip = module.exports;
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;
state.leaseWaiting = counted.filter(t => t.status === 'waitingLease').length;
const todo = counted.filter(t => !TERMINAL.includes(t.status));
const estimates = todo.map(t => {
if (t.status === 'waiting' || t.kind === 'course' || t.kind === 'unsupported') return null;
if (['waiting', 'waitingLease'].includes(t.status) || t.kind === 'course' || t.kind === 'unsupported') return null;
if (t.kind === 'video') {
const v = state.video && state.video.taskId === t.id ? state.video : t;
if (!(v.duration > 0)) return null;
@@ -2916,13 +3122,15 @@ window.JSZip = module.exports;
state.etaSeconds = estimates.some(e => e == null) ? null : config.execution === 'parallel' ? Math.max(0, ...['video', 'exam'].map(kind => estimates.reduce((n, e, i) => n + (todo[i].kind === kind ? e : 0), 0))) : estimates.reduce((n, e) => n + e, 0);
if (typeof options.onState === 'function') options.onState(clone(state));
}
function log(level, message, task) {
state.logs.push({ time: now(), level: ['info', 'warn', 'error'].includes(level) ? level : 'info', message: String(message), task: task && task.title || '', taskId: task && task.id || '' });
function log(level, message, task, details, event) {
const record = { id: owner + ':' + (++logSequence), time: now(), level: ['info', 'warn', 'error'].includes(level) ? level : 'info', event: event || 'learning.message', message: String(message), task: task && task.title || '', taskId: task && task.id || '', details: details || {} };
state.logs.push(record);
if (typeof options.onLog === 'function') { try { options.onLog(clone(record)); } catch (_) {} }
state.logs = state.logs.slice(-500); notify();
}
function taskState(task, status, reason) {
const changed = task.status !== status || task.reason !== (reason || ''); task.status = status; task.reason = reason || '';
if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task);
if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task, { kind: task.kind, status }, 'task.state');
}
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 || ''); }
@@ -2959,7 +3167,49 @@ window.JSZip = module.exports;
if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; }
}
async function release() {
for (const kind of ['video', 'exam']) { const key = 'lease:' + kind; const lease = await storage.get(key); if (lease && lease.owner === owner) await storage.set(key, null); }
for (const kind of ['video', 'exam']) {
const key = 'lease:' + kind; const lease = await storage.get(key);
if (lease && lease.owner === owner) { await storage.set(key, null); log('info', '已释放' + (kind === 'video' ? '视频/课程' : '考试') + '执行权', null, lease, 'lease.released'); }
}
}
function waitForLease(task, lease, reason) {
const previous = task.leaseWait;
task.leaseWait = { previousStatus: previous?.previousStatus || (task.status === 'waitingLease' ? 'pending' : task.status), previousReason: previous ? previous.previousReason : task.reason || '', lastLoggedAt: previous?.lastLoggedAt,
since: previous?.since == null ? now() : previous.since, kind: task.kind === 'course' ? 'video' : task.kind,
owner: lease?.owner || '', taskId: lease?.taskId || '', expires: Number(lease?.expires) || 0, pageUrl: lease?.pageUrl || '', reason };
if (!previous && activeTaskId === task.id) {
// 等待执行权期间取消本页旧动作;重新领取后仍须按当前页面重验。
if (task.kind === 'video') {
const snap = snapshot(); const video = snap.document && describePage(snap.document, snap.url).video;
if (video) video.pause(); playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now();
if (state.video) state.video.status = 'waitingLease';
}
if (task.kind === 'exam' && task.examRequested && task.leaseWait.previousStatus !== 'waiting') {
task.examNeedsRecheck = true;
if (typeof options.onExamPause === 'function') options.onExamPause('本页已失去考试执行权,停止旧自动作答并等待重新核验');
}
}
const secondsLeft = Math.max(0, Math.ceil((task.leaseWait.expires - now()) / 1000));
const holder = lease?.title || lease?.taskId || '另一页面任务';
const message = reason === 'control' ? '正在重新核对全局任务状态,暂不操作当前页面'
: '等待' + (task.leaseWait.kind === 'exam' ? '考试' : '视频/课程') + '执行权:' + holder + '由另一个页面处理,约 ' + secondsLeft + ' 秒后检查;执行权释放后自动继续';
taskState(task, 'waitingLease', '同类任务由另一个页面处理,自动等待并重试');
state.message = message; leaseBanner = { taskId: task.id, message };
if (!previous || previous.owner !== task.leaseWait.owner || previous.taskId !== task.leaseWait.taskId || previous.reason !== reason || now() - (previous.lastLoggedAt || 0) >= 15000) {
task.leaseWait.lastLoggedAt = now();
log('info', message, task, Object.assign({ ownOwner: owner }, task.leaseWait), 'lease.wait');
}
}
function acquiredLease(task) {
if (!task.leaseWait) return;
const waited = task.leaseWait;
if (task.status === 'waitingLease') { task.status = waited.previousStatus || 'pending'; task.reason = waited.previousReason || ''; }
delete task.leaseWait;
if (leaseBanner?.taskId === task.id) {
if (state.message === leaseBanner.message) state.message = '已取得' + (task.kind === 'exam' ? '考试' : '视频/课程') + '执行权,正在继续任务';
leaseBanner = null;
}
log('info', '等待结束,已取得执行权并重新核对任务', task, { waitedMs: Math.max(0, now() - waited.since), kind: waited.kind, previousOwner: waited.owner }, 'lease.resumed');
}
async function claim(task) {
const kind = task.kind === 'course' ? 'video' : task.kind;
@@ -2967,13 +3217,16 @@ window.JSZip = module.exports;
async function acquire() {
const key = 'lease:' + kind; const lease = await storage.get(key);
const shared = await storage.get(CONTROL_KEY);
if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) return false;
if (lease && lease.owner !== owner && lease.sessionId === session.id && lease.expires > now()) return false;
const wanted = { owner, taskId: task.id, sessionId: session.id, expires: now() + LEASE_MS };
if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) { waitForLease(task, null, 'control'); return false; }
if (lease && lease.owner !== owner && lease.sessionId === session.id && lease.expires > now()) { waitForLease(task, lease, 'busy'); return false; }
if (lease && lease.expires <= now()) log('info', '原执行权已到期,重新领取任务', task, lease, 'lease.expired');
const wanted = { owner, taskId: task.id, title: task.title, pageUrl: canonical(snapshot().url), sessionId: session.id, expires: now() + LEASE_MS, updatedAt: now() };
await storage.set(key, wanted);
// GM存储无CAS;延后回读并在每次动作前复核。支持Web Locks时同源标签领取是互斥的。
if (!(root.navigator && root.navigator.locks)) await new Promise(resolve => root.setTimeout(resolve, 25));
const saved = await storage.get(key); return Boolean(saved && saved.owner === owner && saved.taskId === task.id);
const saved = await storage.get(key);
if (!saved || saved.owner !== owner || saved.taskId !== task.id || saved.sessionId !== session.id) { waitForLease(task, saved, 'race'); return false; }
acquiredLease(task); log('info', '已取得' + (kind === 'exam' ? '考试' : '视频/课程') + '执行权', task, wanted, 'lease.acquired'); return true;
}
return root.navigator && root.navigator.locks ? root.navigator.locks.request('yian-learning-' + kind, acquire) : acquire();
}
@@ -2981,7 +3234,9 @@ window.JSZip = module.exports;
const key = 'lease:' + (task.kind === 'course' ? 'video' : task.kind); const lease = await storage.get(key);
if (!lease || lease.owner !== owner || lease.taskId !== task.id || lease.sessionId !== session.id) return false;
const shared = await storage.get(CONTROL_KEY); if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) return false;
lease.expires = now() + LEASE_MS; await storage.set(key, lease); return true;
if (lease.expires <= now()) log('info', '本页原执行权已到期,核对所有者后恢复', task, lease, 'lease.renewed');
lease.expires = now() + LEASE_MS; lease.updatedAt = now(); lease.pageUrl = canonical(snapshot().url); lease.title = task.title;
await storage.set(key, lease); return true;
}
function mergeTasks(tasks, parent) {
tasks.forEach(task => {
@@ -3040,6 +3295,9 @@ window.JSZip = module.exports;
const identityOK = (!saved.accountId || saved.accountId === account(snap, page)) && (!saved.planId || !snap.planId || saved.planId === snap.planId) && (!saved.bankVersion || !snap.bankVersion || saved.bankVersion === snap.bankVersion);
if (identityOK) {
session = saved; Object.assign(config, saved.config || {}); state.tasks = saved.tasks; state.logs = Array.isArray(saved.logs) ? saved.logs.slice(-500) : []; activeTaskId = saved.activeTaskId || '';
for (const task of state.tasks) if (task.status === 'waiting' && task.reason === '另一个页面正在处理同类任务,避免重复领取') {
task.status = 'waitingLease'; task.leaseWait = { previousStatus: 'pending', previousReason: '', since: now() };
}
if (marker) {
const request = await storage.get('worker:' + marker[1]);
const control = await storage.get(CONTROL_KEY);
@@ -3212,7 +3470,7 @@ window.JSZip = module.exports;
task.preflight = preflightTopic(snap.document, snap, config.associations, task);
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; }
if (!(await claim(task))) return 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);
@@ -3283,7 +3541,7 @@ window.JSZip = module.exports;
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(); }
} catch (error) { if (epoch === generation) taskState(task, 'waiting', '课中题处理失败:' + error.message); }
} catch (error) { if (epoch === generation) { log('error', '课中题处理发生异常', task, { errorName: error.name, error: error.message, stack: error.stack }, 'quiz.error'); taskState(task, 'waiting', '课中题处理失败:' + error.message); } }
finally { pendingQuiz = null; }
return;
}
@@ -3309,14 +3567,14 @@ window.JSZip = module.exports;
playStarted = true;
lastAutoPlayAt = now();
try { const promise = video.play(); if (promise && promise.catch) await promise; }
catch (_) { taskState(task, 'waiting', '浏览器拒绝自动播放,请手动播放后继续'); state.video.status = 'waiting'; return; }
catch (error) { log('warn', '播放器启动失败', task, { errorName: error?.name, error: String(error?.message || error || '未知播放错误'), currentTime, rate: video.playbackRate }, 'video.playFailed'); taskState(task, 'waiting', '浏览器拒绝自动播放,请手动播放后继续'); state.video.status = 'waiting'; return; }
if (epoch !== generation || state.status !== 'running') { video.pause(); return; }
lastAdvancedAt = now();
}
if (lastTime === null || Math.abs(currentTime - lastTime) > 0.01) { lastTime = currentTime; lastAdvancedAt = now(); attemptedAt = 0; taskState(task, 'playing', page.hidden ? '页面在后台,按真实播放与平台记录跟踪' : '视频播放中'); return; }
if (!attemptedAt && now() - lastAdvancedAt >= config.stallSeconds * 1000) {
attemptedAt = now(); taskState(task, 'retrying', '播放进度长时间未变化,先尝试续播');
try { await video.play(); } catch (_) { taskState(task, 'waiting', '续播被浏览器拒绝,请人工操作'); }
try { await video.play(); } catch (error) { log('warn', '播放器续播失败', task, { errorName: error?.name, error: String(error?.message || error || '未知播放错误'), currentTime, rate: video.playbackRate }, 'video.resumeFailed'); taskState(task, 'waiting', '续播被浏览器拒绝,请人工操作'); }
return;
}
if (attemptedAt && now() - attemptedAt >= config.recoverySeconds * 1000) {
@@ -3351,6 +3609,10 @@ window.JSZip = module.exports;
if (page.kind === 'paper' && task.enterClickedAt && !task.examPaperURL && page.title === task.title) task.examPaperURL = page.url;
}
const attempts = readExamAttempt(task, snap, page);
if (task.examRequested && task.examNeedsRecheck) {
await observeExamState(task, true);
if (task.status === 'completed') return;
}
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;
@@ -3382,11 +3644,12 @@ window.JSZip = module.exports;
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, targetURL: '', title: task.title, accountId: account(snap, page), expires: now() + 60000 });
buttons[0].click(); return;
}
if (!task.examRequested || task.examPage !== page.kind) {
if (!task.examRequested || task.examNeedsRecheck || task.examPage !== page.kind) {
task.examPage = page.kind; taskState(task, 'answering', '考试主题已确认,交给答题与交卷控制器');
if (typeof options.onExamStart === 'function') {
const accepted = await options.onExamStart({ task: clone(task), preflight: clone(task.preflight), document: snap.document, page: page.kind, config: clone(config) });
task.examRequested = accepted !== false;
if (accepted !== false) task.examNeedsRecheck = false;
if (accepted === false) { if (!task.readyWaitAt) task.readyWaitAt = now(); if (now() - task.readyWaitAt > 15000) taskState(task, 'waiting', '试卷未完整加载,请人工核对后继续'); }
}
else taskState(task, 'waiting', '考试控制器尚未接入,请人工处理');
@@ -3476,17 +3739,24 @@ window.JSZip = module.exports;
if (state.status !== 'running') { await persist(); notify(); return getState(); }
if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; }
if (!task) {
const candidates = state.tasks.filter(t => t.status === 'pending' && allows(t.kind) && (!worker || t.id === worker.taskId));
const candidates = state.tasks.filter(t => ['pending', 'waitingLease'].includes(t.status) && allows(t.kind) && (!worker || t.id === worker.taskId));
// 当前页面的实际课件优先;顺序执行先完成可用视频,再处理关联考试。
function rank(t) { return (canonical(t.pageUrl) === page.url ? 0 : 10) + (t.kind === 'video' ? 0 : t.kind === 'exam' ? 1 : 2); }
candidates.sort((a, b) => rank(a) - rank(b));
for (const next of candidates) { if (await activate(next, snap, page)) { task = next; break; } }
const blockedKinds = new Set();
for (const next of candidates) {
const kind = next.kind === 'course' ? 'video' : next.kind; if (blockedKinds.has(kind)) continue;
if (await activate(next, snap, page)) { task = next; break; }
if (next.status === 'waitingLease') { blockedKinds.add(kind); if (config.execution === 'sequential') break; }
}
}
if (task && epoch === generation) {
const held = await heartbeat(task) || await claim(task);
if (!held) state.message = '等待同类任务租约释放,暂不操作当前页面';
else if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch);
else if (task.kind === 'exam') await examTick(task, snap, page);
if (held) {
acquiredLease(task);
if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch);
else if (task.kind === 'exam') await examTick(task, snap, page);
}
}
if (epoch === generation) await parallelTick(snap, page);
if (worker && session) { const request = await storage.get('worker:' + worker.nonce); if (request) { const own = task || state.tasks.find(t => t.id === worker.taskId); request.heartbeatAt = now(); request.status = own && own.status || 'idle'; request.video = state.video; request.exam = state.exam; await storage.set('worker:' + worker.nonce, request); } }
@@ -3494,7 +3764,7 @@ window.JSZip = module.exports;
if (!task && !remaining.length) { state.status = state.tasks.length ? 'finished' : 'waiting'; state.message = state.tasks.length ? '本轮调度结束;请检查完成、跳过及失败清单' : '当前页面没有可识别的任务,请打开学习地图、课程或试卷'; if (session) session.status = state.status; await control(state.status); }
else if (!task && remaining.every(t => t.status === 'waiting')) { state.message = '剩余任务等待人工处理;确认后点击继续'; }
await persist(); notify(); return getState();
} catch (error) { state.message = '学习调度异常:' + error.message; state.status = 'paused'; if (session) session.status = 'paused'; await control('paused'); await release(); log('error', state.message); return getState(); }
} catch (error) { state.message = '学习调度异常:' + error.message; state.status = 'paused'; if (session) session.status = 'paused'; await control('paused'); await release(); log('error', state.message, null, { errorName: error.name, error: error.message, stack: error.stack }, 'learning.error'); return getState(); }
finally { busy = false; }
}
async function observeControl(value) {
@@ -4108,10 +4378,15 @@ window.JSZip = module.exports;
let positionRevision = 0;
let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle';
let actionEpoch = 0;
let diagnostics = null, lastExamDiagnostic = '', lastAnswerDiagnostic = '', diagnosticRoute = location.href;
const version = '2.1.3';
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 recordDiagnostic(level, event, message, details) {
return diagnostics?.record(level, event, message, details) || false;
}
function skipSingleAttempt() { return learning ? learning.getState().skipSingleAttemptQuiz !== false : learningSettings.skipSingleAttemptQuiz !== false; }
function attemptPolicy() {
@@ -4182,10 +4457,16 @@ window.JSZip = module.exports;
const runner = window.YianAnswerRunner.create({ getContext,
match: (question, context) => matcher.match(question, context, snapshot),
getInterval: () => learningSettings.answerInterval,
onProgress: progress => { metrics = progress; panel.setState({ metrics }); },
onProgress: progress => {
metrics = progress; panel.setState({ metrics });
const counts = { selected: progress.selected, pending: progress.pending, missing: progress.missing, conflict: progress.conflict, failed: progress.failed, total: progress.total };
const signature = JSON.stringify(counts);
if (signature !== lastAnswerDiagnostic) { lastAnswerDiagnostic = signature; recordDiagnostic('info', 'answer.progress', '答案核验进度已更新', counts); }
},
onState: (state, reason) => {
if (disposed) return;
runnerState = state;
recordDiagnostic(state === 'paused' ? 'warning' : 'info', 'answer.state', reason || '答题状态更新', { status: state });
if (state === 'paused') { config.autoAnswer = false; runKey = ''; panel.setState({ autoAnswer: false }); }
if (state === 'paused') { examController?.disable(reason); learning?.configure({ examPaused: true }); }
if (reason && (state === 'paused' || state === 'complete')) panel.setNotice(reason, state === 'paused' ? 'warning' : 'success');
@@ -4242,6 +4523,7 @@ window.JSZip = module.exports;
panel.setNotice('请先在题库栏确认当前考试标题与题库课程/主题的关联。', 'warning'); return false;
}
config.autoAnswer = !!enabled; runKey = '';
recordDiagnostic('info', 'answer.toggle', '自动选择' + (enabled ? '开启' : '关闭'), { enabled: !!enabled });
if (!enabled) { runner.stop('自动选择已关闭,已生效的选择保留。'); examController?.disable('自动选择已关闭'); }
refreshPanel(); tick(); return config.autoAnswer;
}
@@ -4291,14 +4573,34 @@ window.JSZip = module.exports;
onExportEnabledChange: setExportEnabled
,onTaskAction: action => { taskAction(action).catch(error => panel.setNotice(error.message, 'error')); },
onLearningSetting: (key, value) => { changeLearningSetting(key, value).catch(error => panel.setNotice(error.message, 'error')); },
onLogAction: action => {
if (action === 'clear') learning?.clearLogs?.();
if (action === 'copy') {
const text = (learningState.logs || []).map(item => new Date(item.time).toLocaleTimeString() + ' [' + item.level + '] ' + item.message).join('\n');
if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else navigator.clipboard?.writeText(text).catch(() => {});
}
}
onLogAction: (action, value) => { logAction(action, value).catch(error => panel.setNotice('日志操作失败:' + error.message, 'error')); }
});
diagnostics = window.YianDiagnosticLogger.create({
storage: { name: '本机用户脚本存储', get: (key, fallback) => store.getSetting('diagnostics:' + key, fallback), set: (key, value) => store.setSetting('diagnostics:' + key, value) },
getContext: () => ({ version, url: location.href, pageKind: window.YianLearningEngine.describePage(document, location.href).kind,
status: learningState.status || 'initializing', mode: learningSettings.mode, execution: learningSettings.execution,
bankTotal: snapshot.total, bankRevision: snapshot.revision }),
onState: info => panel.setState({ diagnostics: info }),
onError: error => panel.setNotice('诊断日志保存失败,仍保留内存记录:' + error.message, 'warning')
});
async function exportDiagnostics(format) {
if (!inScope() || !['json', 'txt'].includes(format)) throw new Error('请选择 JSON 或 TXT 日志格式');
const contents = await (format === 'json' ? diagnostics.exportJSON() : diagnostics.exportText());
const url = URL.createObjectURL(new Blob([format === 'txt' ? '\ufeff' + contents : contents], { type: format === 'json' ? 'application/json;charset=utf-8' : 'text/plain;charset=utf-8' }));
const link = document.createElement('a'); link.href = url; link.download = '谊安学-诊断日志-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + format;
document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000);
panel.setNotice('诊断日志已导出,可用来核对卡住前后的执行过程。', 'success'); return contents;
}
async function logAction(action, value) {
if (action === 'record') return diagnostics.setEnabled(value === true);
if (action === 'exportJson' || action === 'exportText') return exportDiagnostics(action === 'exportJson' ? 'json' : 'txt');
if (action === 'clearSaved') { await diagnostics.clear(); panel.setNotice('已清空保存的诊断日志;题库和任务检查点保留。', 'success'); return; }
if (action === 'clear') learning?.clearLogs?.();
if (action === 'copy') {
const text = (learningState.logs || []).map(item => new Date(item.time).toLocaleTimeString() + ' [' + item.level + '] ' + item.message).join('\n');
if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else await navigator.clipboard?.writeText(text);
}
}
async function changeLearningSetting(key, value) {
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', ...policyKeys, 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
@@ -4318,6 +4620,7 @@ window.JSZip = module.exports;
throw new Error('主题关联须使用完整标题及题库课程名称列表');
}
const pending = { value }; pendingLearningSettings.set(key, pending);
recordDiagnostic('info', 'settings.changed', '学习设置已修改', { name: key, requested: value });
try {
if (['mode', 'execution', 'associations', 'manualConfirmIncomplete', 'skipSingleAttemptQuiz'].includes(key)) {
actionEpoch++;
@@ -4334,6 +4637,7 @@ window.JSZip = module.exports;
}
async function taskAction(action) {
actionEpoch++;
recordDiagnostic('info', 'task.action', '任务操作:' + action, { action, status: learningState.status, mode: learningSettings.mode, execution: learningSettings.execution });
await ready;
if (!inScope()) return;
if (action === 'start' || action === 'resume') {
@@ -4360,7 +4664,16 @@ window.JSZip = module.exports;
skipSingleAttemptQuiz: skipSingleAttempt()
}), getAttemptPolicy: attemptPolicy, getRunnerState: () => runnerState,
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
onState: state => { examState = state; renderLearning(); } });
onState: state => {
examState = state;
const signature = JSON.stringify([state.stage, state.enabled, state.verifiedCount, state.predictedScore, state.submitted, String(state.message || '').replace(/\d+(?:\.\d+)?/g, '#')]);
if (signature !== lastExamDiagnostic) {
lastExamDiagnostic = signature;
recordDiagnostic(['unknown', 'failed'].includes(state.stage) ? 'warning' : 'info', 'exam.state', state.message || '考试状态更新',
{ stage: state.stage, enabled: state.enabled, verifiedCount: state.verifiedCount, total: state.total, predictedScore: state.predictedScore, elapsedSeconds: state.elapsedSeconds, remainingSeconds: state.remainingSeconds, submitted: state.submitted, manualConfirmIncomplete: state.manualConfirmIncomplete, skipSingleAttemptQuiz: state.skipSingleAttemptQuiz });
}
renderLearning();
} });
learning = window.YianLearningEngine.create({
getSnapshot: () => ({ document, url: location.href, courses: courses(), bank: snapshot,
bankVersion: snapshot.revision, accountId: document.querySelector('[data-user-id]')?.getAttribute('data-user-id') || '', planId: '' }),
@@ -4372,6 +4685,7 @@ window.JSZip = module.exports;
return false;
},
onExamPause: reason => { setAutoAnswer(false); examController.disable(reason); },
onLog: record => recordDiagnostic(record.level, record.event, record.message, Object.assign({ id: record.id, taskId: record.taskId, taskTitle: record.task }, record.details)),
solveQuiz: async (_popup, quizContext) => {
const revision = snapshot.revision;
const mode = learningSettings.mode;
@@ -4400,6 +4714,9 @@ window.JSZip = module.exports;
function tick() {
if (disposed) return;
if (diagnosticRoute !== location.href) {
recordDiagnostic('info', 'page.changed', '页面地址已变化', { source: diagnosticRoute, destination: location.href }); diagnosticRoute = location.href;
}
if (!inScope()) {
if (!scopedOut) {
scopedOut = true;
@@ -4488,15 +4805,27 @@ window.JSZip = module.exports;
const timer = setInterval(tick, 600);
const onNavigation = () => { runner.stop('页面地址已变化'); runKey = ''; tick(); };
window.addEventListener('popstate', onNavigation); window.addEventListener('hashchange', onNavigation);
const onError = event => recordDiagnostic('error', 'runtime.error', event.message || '网页脚本发生错误', { errorName: event.error?.name, stack: event.error?.stack });
const onRejection = event => recordDiagnostic('error', 'runtime.rejection', event.reason?.message || String(event.reason || '异步操作失败'), { errorName: event.reason?.name, stack: event.reason?.stack });
const onVisibility = () => recordDiagnostic('info', 'page.visibility', document.visibilityState === 'hidden' ? '页面进入后台' : '页面返回前台', { visibility: document.visibilityState });
const onNetwork = event => recordDiagnostic(event.type === 'offline' ? 'warning' : 'info', 'network.' + event.type, event.type === 'offline' ? '浏览器报告网络已断开' : '浏览器报告网络已恢复');
const onPageHide = () => { diagnostics.flush().catch(() => {}); };
window.addEventListener('error', onError); window.addEventListener('unhandledrejection', onRejection);
window.addEventListener('online', onNetwork); window.addEventListener('offline', onNetwork); window.addEventListener('pagehide', onPageHide);
document.addEventListener('visibilitychange', onVisibility);
function dispose() {
disposed = true; observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer);
window.removeEventListener('popstate', onNavigation); window.removeEventListener('hashchange', onNavigation);
window.removeEventListener('error', onError); window.removeEventListener('unhandledrejection', onRejection);
window.removeEventListener('online', onNetwork); window.removeEventListener('offline', onNetwork); window.removeEventListener('pagehide', onPageHide);
document.removeEventListener('visibilitychange', onVisibility);
runner.destroy(); examController.destroy(); learning.destroy(); copy.destroy(); panel.destroy();
diagnostics.destroy().catch(() => {});
}
const ready = (async () => {
const positionRevisionAtRead = positionRevision;
try {
const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null), diagnostics.ready]);
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, { restore: true });
@@ -4510,10 +4839,14 @@ window.JSZip = module.exports;
&& typeof position.top === 'number' && Number.isFinite(position.top) ? { left: position.left, top: position.top } : null;
}
} catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); }
loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); tick(); return snapshot;
loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel();
recordDiagnostic('info', 'app.ready', '谊安学助手已加载', { version, mode: learningSettings.mode, execution: learningSettings.execution, total: snapshot.total });
tick(); return snapshot;
})();
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext,
setLearningSetting: changeLearningSetting, taskAction, getLearningState: () => learning.getState(), getExamState: () => examController.getState(),
getDiagnosticState: () => diagnostics.getState(), flushDiagnostics: () => diagnostics.flush(), setDiagnosticRecording: enabled => diagnostics.setEnabled(enabled === true),
clearDiagnostics: () => diagnostics.clear(), exportDiagnostics,
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, learning: learning.getState(), exam: examController.getState(), pageReady: !!page && page.ready }),
exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose });
window.YianAssistant = api; window.YianQuestionBankExporter = api;