Files
yianxue-assistant/practice-exporter.js

493 lines
35 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
if (window.YianQuestionBankExporter) return;
const core = window.QuestionBankCore;
const store = window.YianBankStore;
const matcher = window.YianAnswerMatcher;
const config = { autoAnswer: false, allowCopy: true, exportEnabled: false, role: '', course: '', panelPosition: null };
let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' };
let loading = true, disposed = false, scopedOut = false, page = null, previous = null;
let searchQuery = '', runKey = '', debounceTimer;
const exported = new Set();
const settleMs = 1200;
const copy = window.YianCopyController.create();
const emptyMetrics = () => ({ selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: 0 });
let metrics = emptyMetrics();
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.4';
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() {
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 || {});
}
function visible(node) {
if (!node || node.closest('[hidden],.hidden')) return false;
const style = getComputedStyle(node);
return node.getClientRects().length > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function routeInfo() {
const original = location.protocol === 'file:' ? core.savedSourceURL(document) : '';
const url = new URL(original || location.href);
let params = url.searchParams;
if (url.hash.startsWith('#/')) params = new URL(url.hash.slice(1), url.origin).searchParams;
const root = document.querySelector('app-examtest, .pc_examtest');
const mode = (params.get('flag') || '').toLowerCase();
const marker = root && Array.from(root.querySelectorAll('.pc_examtest_commit'))
.some(node => node.textContent.trim() === '重新练习' && !node.classList.contains('hidden'));
const desktop = root && root.querySelector('.pc_examtest_left') && root.querySelector('.pc_examtest_card')
&& (root.matches('.pc_examtest') || root.querySelector('.pc_examtest'));
const commit = root && root.querySelector('.pc_examtest_commit');
const countdown = root && root.querySelector('.pc_examtest_countdown .time');
const expired = countdown && /^\s*0{1,2}:00:00\s*$/.test(countdown.textContent);
const submitting = Array.from(document.querySelectorAll('[beingsubmitted],.beingsubmitting,.exam_submited_box')).some(visible);
const submitted = !!(root && root.querySelector('#examtestDisabled,.pc_examtest_commitdefult')) || submitting;
// app-dialog-box 的 modal 模板会挂到全局;不能只检查组件后代。
const confirming = Array.from(document.querySelectorAll('.dialog_box,app-dialog-box > *,.examine_box')).some(visible);
const practice = !!root && (mode ? mode === 'exercise' : !!marker);
const exam = !!desktop && mode === 'test' && !!commit && visible(commit)
&& commit.textContent.trim() === '交卷' && !submitted && !expired && !confirming;
const identity = new URL(location.href);
identity.searchParams.delete('t');
return { root, practice, exam, answerSupported: practice || exam, expired: !!expired,
submitted: !!submitted, confirming,
local: location.protocol === 'file:', sourceUrl: url.href, key: identity.href,
expectedTitle: mode === 'exercise' || mode === 'test' ? params.get('title') || '' : '', mode };
}
function structureSignature(bank) {
return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(q =>
[q.number, q.type, q.question, q.options.map(option => [option.label, option.text, option.images])])]);
}
// 点击前现读上下文,阻断轮询尚未发现的路由、岗位、题目变化。
function getContext() {
const route = routeInfo();
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 && !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, skipSingleAttempt(), attempt.signature]) : '' };
}
const runner = window.YianAnswerRunner.create({ getContext,
match: (question, context) => matcher.match(question, context, snapshot),
getInterval: () => learningSettings.answerInterval,
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');
}
});
function courses() {
return Array.from(new Set(snapshot.records.map(record => record.course))).sort();
}
function renderLearning() {
if (!learning) return;
const video = learningState.video ? Object.assign({}, learningState.video, { learnedTime: learningState.video.learnedSeconds,
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 || {};
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 });
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,
exportEnabled: config.exportEnabled, position: config.panelPosition, role: config.role, course: config.course, courses: courses(),
bankTotal: snapshot.total, uniqueTotal: snapshot.questionTotal, conflictTotal: snapshot.conflictTotal,
roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
}
function performSearch(query = searchQuery, options = {}) {
searchQuery = String(query || '');
const offset = options.offset || 0;
const result = searchQuery.trim() ? matcher.search(searchQuery,
{ role: config.role, course: config.course, limit: 20, offset }, snapshot) : { items: [], total: 0 };
panel.renderSearch(Object.assign({}, result, { offset }));
return result;
}
async function saveSetting(key, value) {
try { await store.setSetting(key, value); }
catch (error) { panel.setNotice('设置保存失败:' + error.message, 'error'); }
}
async function setRole(role) {
// Compatibility for old integrations; a role no longer filters any answers.
config.role = ''; return '';
}
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;
}
if (enabled && context.mode === 'test' && topicPreflight().status !== 'matched') {
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;
}
function setCopy(enabled) {
config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); saveSetting('allowCopy', config.allowCopy);
}
async function importFiles(files) {
examController?.disable('题库正在更新');
if (learning) await learning.pause();
runner.stop('正在更新题库'); loading = true; runKey = ''; refreshPanel(); panel.setNotice('正在解析题库,请稍候…');
try {
snapshot = await store.importFiles(Array.from(files || []), { role: config.role });
metrics = emptyMetrics(); config.course = '';
panel.setNotice('统一题库导入成功:' + snapshot.total + ' 条来源记录,' + snapshot.questionTotal + ' 道题,' + snapshot.conflictTotal + ' 道存在答案冲突。', 'success');
return snapshot;
} catch (error) {
panel.setNotice('导入失败:' + error.message + '。原有题库保留。', 'error'); throw error;
} finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); }
}
function exportCurrent(format = 'csv') {
if (!inScope()) throw new Error('当前页面不在谊安学脚本生效范围内');
if (!config.exportEnabled) throw new Error('请先开启“导出题库”开关');
const route = routeInfo();
if (!route.practice) throw new Error('请先进入支持的练习页面');
const bank = core.extract(document, route.sourceUrl);
if (!bank.total) throw new Error('页面题目尚未加载');
core.download(bank, format);
panel.setNotice((bank.complete ? '已发起下载:' : '已发起不完整题库下载:') + bank.title
+ ',' + bank.total + ' 题、' + bank.answered + ' 个答案。若没有文件,请点击导出按钮。', bank.complete ? 'success' : 'warning');
return bank;
}
function setExportEnabled(enabled) {
const opening = !!enabled && !config.exportEnabled;
config.exportEnabled = !!enabled;
// 用户主动重新开启可再次下载;同一开启期间只自动导出一次相同题库。
if (opening) exported.clear();
refreshPanel(); tick(); return config.exportEnabled;
}
const panel = window.YianAssistantPanel.create({
onAutoChange: setAutoAnswer, onCopyChange: setCopy,
onPositionChange: position => { positionRevision++; config.panelPosition = position; saveSetting('panelPosition', position); },
onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); },
onCourseChange: course => { config.course = course; performSearch(); },
onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch,
onSelectionSearch: text => performSearch(text),
onExport: format => { try { exportCurrent(format); } catch (error) { panel.setNotice(error.message, 'error'); } },
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, 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'];
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 (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);
if (!Number.isFinite(value) || value < range[0] || value > range[1] || key === 'maxRefresh' && !Number.isInteger(value)) {
throw new Error('设置数值须在 ' + range[0] + '~' + range[1] + ' 范围内');
}
}
if (key === 'associations' && (!value || typeof value !== 'object' || Array.isArray(value)
|| Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) {
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++;
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();
}
}
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') {
if (action === 'start') await learning.start(); else await learning.resume();
} else if (action === 'pause' || action === 'stop') {
config.autoAnswer = false; runner.stop('用户暂停或停止任务'); examController.disable('用户暂停或停止任务');
if (action === 'pause') await learning.pause(); else await learning.stop();
} else if (action === 'videoPause' || action === 'videoResume') await learning.configure({ videoPaused: action === 'videoPause' });
else if (action === 'examPause') { await learning.configure({ examPaused: true }); setAutoAnswer(false); }
else if (action === 'examResume') {
if (learning.getState().status !== 'running') {
panel.setNotice('当前计划已暂停,请先在任务总览点击“继续上次任务”重新核验。', 'warning'); return;
}
await learning.configure({ examPaused: false });
const epoch = actionEpoch;
if (await examController.enable() && epoch === actionEpoch) setAutoAnswer(true);
}
renderLearning();
}
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,
skipSingleAttemptQuiz: skipSingleAttempt()
}), getAttemptPolicy: attemptPolicy, getRunnerState: () => runnerState,
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
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: '' }),
getExamState: () => examController.getState(),
onExamStart: async () => {
const epoch = actionEpoch;
if (await examController.enable() && epoch === actionEpoch && learning.getState().status === 'running'
&& !learning.getState().examPaused) return setAutoAnswer(true);
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;
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, 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 });
},
storage: learningStorage,
navigate: url => { if (YianSiteScope.isAllowedURL(url)) location.assign(url); else throw new Error('任务链接不在脚本范围内'); },
reload: () => location.reload(),
openWorker: url => {
if (!YianSiteScope.isAllowedURL(url)) throw new Error('任务链接不在脚本范围内');
if (typeof GM_openInTab === 'function') return GM_openInTab(url, { active: false, insert: true, setParent: true });
const child = window.open(url, '_blank'); if (!child) throw new Error('浏览器阻止并行标签页,请允许打开后重试'); return child;
},
onState: state => {
learningState = state;
if (!loading) for (const key of policyKeys) if (!pendingLearningSettings.has(key) && typeof state[key] === 'boolean') learningSettings[key] = state[key];
renderLearning();
}
});
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;
config.autoAnswer = false; config.exportEnabled = false; runKey = '';
runner.stop('页面已离开脚本生效范围'); copy.setEnabled(false);
examController.disable('页面已离开脚本范围'); learning.pause();
panel.host.style.setProperty('display', 'none', 'important');
previous = page; page = null; metrics = emptyMetrics();
}
return;
}
if (scopedOut) {
scopedOut = false;
panel.host.style.setProperty('display', 'block', 'important');
copy.setEnabled(config.allowCopy);
}
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'));
}
if (!route.root) {
if (runner.running) runner.stop('当前页面未识别到练习题目');
if (page) previous = page;
page = null;
refreshPanel({ canAnswer: false, canExport: false, pageMode: '', pageStatus: '当前网页可搜索题库和复制文字;未识别到支持的题目。' }); return;
}
const bank = core.extract(document, route.sourceUrl), signature = structureSignature(bank), now = Date.now();
if (!page || page.key !== route.key || page.root !== route.root) {
if (runner.running) runner.stop('页面已切换,旧任务已停止');
const last = page || previous;
page = { key: route.key, root: route.root, signature, since: now, ready: false,
waiting: !!last && last.key !== route.key && last.root === route.root && last.signature === signature };
if (route.mode === 'test') config.autoAnswer = false;
runKey = ''; metrics = emptyMetrics(); refreshPanel({ expanded: true });
}
if (page.signature !== signature) {
if (runner.running) runner.stop('题目内容已更新');
if (route.mode === 'test') config.autoAnswer = false;
page.signature = signature; page.since = now; page.waiting = false; runKey = ''; metrics = emptyMetrics();
}
const countComplete = route.mode === 'test' ? bank.expectedTotal > 0 && bank.expectedTotal === bank.total
: bank.expectedTotal === null || bank.expectedTotal === bank.total;
const titleReady = !route.expectedTitle || route.expectedTitle.trim() === bank.title.trim();
page.ready = !!bank.total && countComplete && titleReady && !page.waiting && now - page.since >= settleMs;
let status;
if (route.mode === 'test' && !route.exam) status = route.expired ? '考试计时已结束,自动选择已停止。'
: route.submitted ? '考试已提交或提交中,自动选择已停止。'
: route.confirming ? '考试对话框已打开,自动选择已停止。' : '考试页面尚未确认可作答,等待完整桌面试卷加载。';
else if (!route.answerSupported) status = '已识别题目,但未确认支持的练习或桌面考试模式。';
else if (page.waiting || !titleReady) status = '等待本次练习的题目加载…';
else if (!bank.total || now - page.since < settleMs) status = '正在读取题目与隐藏答案…';
else if (!countComplete) status = bank.expectedTotal === null ? '等待考试答题卡总题数,确认完整试卷后才能自动选择。'
: '已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。';
else if (route.exam) status = bank.title + ':' + bank.total + ' 题。按统一题库严格核对题干、选项和正确答案全文。'
+ (examController.getState().enabled ? '自动交卷条件已启用,请留意预计得分与倒计时。' : '仅选择答案;通过任务总览启动考试任务后才按条件自动交卷。');
else status = bank.title + ':' + bank.total + ' 题,' + (bank.missingAnswers.length
? '缺少 ' + bank.missingAnswers.length + ' 个网页答案;可用导入题库选择。' : '网页答案齐全。')
+ (route.local ? ' 保存网页支持搜索与导出;自动选择需要在线练习页面。' : '');
if (route.mode === 'test' && !route.exam) { config.autoAnswer = false; runKey = ''; }
refreshPanel({ pageStatus: status, pageMode: route.mode === 'test' ? 'test' : route.practice ? 'exercise' : '',
canExport: route.practice && !!bank.total,
canAnswer: route.answerSupported && !route.local && page.ready && !loading && !!snapshot.total });
if (!route.answerSupported || !page.ready || loading) { if (runner.running) runner.stop('等待可作答页面准备完成'); return; }
if (route.practice && config.exportEnabled && bank.complete) {
const key = route.key + '|' + signature;
if (!exported.has(key)) {
try { exportCurrent('csv'); exported.add(key); }
catch (error) { config.exportEnabled = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); }
}
}
const nextRunKey = route.key + '|' + signature + '|' + snapshot.revision;
if (config.autoAnswer && snapshot.total && runKey !== nextRunKey && !runner.running) {
runKey = nextRunKey;
runner.start().catch(error => { config.autoAnswer = false; refreshPanel(); panel.setNotice('自动选择失败:' + error.message, 'error'); });
}
}
const observer = new MutationObserver(records => {
if (records.every(record => record.target === panel.host || panel.host.contains(record.target))) return;
clearTimeout(debounceTimer); debounceTimer = setTimeout(tick, 200);
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
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), 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 });
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.
if (positionRevision === positionRevisionAtRead) {
config.panelPosition = position && typeof position.left === 'number' && Number.isFinite(position.left)
&& 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();
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;
refreshPanel(); tick();
})();