fix: recover expired task leases and persist diagnostic logs
This commit is contained in:
+74
-11
@@ -16,10 +16,15 @@
|
||||
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() {
|
||||
@@ -90,10 +95,16 @@
|
||||
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');
|
||||
@@ -150,6 +161,7 @@
|
||||
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;
|
||||
}
|
||||
@@ -199,14 +211,34 @@
|
||||
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'];
|
||||
@@ -226,6 +258,7 @@
|
||||
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++;
|
||||
@@ -242,6 +275,7 @@
|
||||
}
|
||||
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') {
|
||||
@@ -268,7 +302,16 @@
|
||||
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: '' }),
|
||||
@@ -280,6 +323,7 @@
|
||||
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;
|
||||
@@ -308,6 +352,9 @@
|
||||
|
||||
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;
|
||||
@@ -396,15 +443,27 @@
|
||||
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 });
|
||||
@@ -418,10 +477,14 @@
|
||||
&& 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;
|
||||
|
||||
Reference in New Issue
Block a user