diff --git a/README.md b/README.md
index 2334c22..0543b00 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
脚本使用 `@match *://*/*` 和 `@match file:///*`,不固定域名、IP、端口或网址路径,换地址后无需修改脚本。普通网页均显示可折叠入口;`chrome://` 等浏览器限制页面无法注入。访问本地 HTML 时,需在扩展设置中开启“允许访问文件网址”。
-**网址通用不等于所有平台都能自动答题。** 自动选择适配提供的谊安学桌面练习页结构,还需明确识别为“练习”;考试/测试页面及无法识别的页面不会自动选择。`file:` 保存网页只用于搜索、复制和导出,不执行自动选择。
+**网址通用不等于所有平台都能自动答题。** 自动选择适配提供的谊安学桌面练习页,以及明确为 `flag=test`、完整加载并且仍可作答的桌面考试页。未知模式、移动端虚拟翻页试卷、已提交或计时结束的考试不会自动选择。`file:` 保存网页只用于搜索、复制和核对;原练习页可导出,不执行自动选择。
## 导入题库与搜索答案
@@ -36,9 +36,19 @@
## 两个功能开关
-**自动选择答案:默认关闭,每次刷新后需手动开启。** 先导入题库、选择岗位,并进入已加载完成的兼容练习页,再开启。脚本按岗位、题型、题干和全部选项精确匹配,优先结合课程来源消歧;按选项文字映射当前字母,支持选项换序。无匹配、冲突未消除、图片题或选项无法唯一对应时,跳过并显示原因。搜索结果不会直接触发自动选择。
+**自动选择答案:默认关闭,每次刷新或进入另一份考试后需手动开启。** 先导入题库、选择岗位,并进入已加载完成的兼容练习或桌面考试页,再开启。脚本按岗位、题型、完整题干和全部选项文字精确匹配,优先结合课程来源消歧;以题库正确答案全文定位网页当前选项,支持选项换序。自动匹配只清理排版空白和不可见字符,保留大小写、单位、指数、数字、否定词和标点。关键词及宽松搜索结果不会直接用于自动选择。无匹配、冲突未消除、图片题或选项无法唯一对应时,跳过并显示原因。
-脚本点击网页真实选项,网站会按正常逻辑保存练习记录。单选、判断可能点击后立即判分并锁定;关闭开关只能停止后续操作,不能撤回已生效的选择。多选只调整选中集合,显示“待确认”,由用户在网页中确认。不自动点击“查看答案”或交卷。切换岗位、题库或页面时停止旧任务,随后按新上下文处理尚未作答的题目。
+脚本点击网页真实选项,网站会按正常逻辑保存作答记录。练习单选、判断可能点击后立即判分并锁定;练习多选显示“待确认”,由用户在网页中确认。考试单选、判断可改选,考试多选按当前集合取消多余选项并补选正确项,回读确认完整选中内容与题库答案一致后处理下一题。
+
+“查看答案核对”折叠区显示已处理题目的题干、题库课程、正确答案全文与网页当前选项字母;未匹配等题目显示在未处理清单中。关闭开关或检测到手动作答时立即停止后续操作,已生效的选择保留。重新开启会重新读取选中状态并核对;切换岗位、题库或页面时取消旧任务。
+
+## 桌面考试适配
+
+考试题干中的分值(例如“(2分)”“(4分)”)和难度属于独立显示元素,提取时按 DOM 标记移除;题目自身的括号和数字保持原文。考试没有“查看答案”按钮不再被误判为锁定。
+
+提供的考试样本“1-1安全生产方针和目标”共有 40 题:15 单选、10 多选、15 判断;页面参考答案为空,答案来自已导入的岗位题库。清理分值与难度后,三岗位均可精确匹配全部 40 题,其中 20 题的正确字母与题库中不同。例如题库 `B. 技术` 对应考试 `D. 技术`,脚本核对“技术”全文后选择网页 D。
+
+此次适配桌面全题 DOM 页面,按顺序选择并回读核对,不操作移动端翻页。不自动点击“查看答案”或交卷;完成后检查核对结果并自行交卷。点击交卷确认、页面提交中或倒计时到零时停止自动选择。网页原有的缓存、接口保存及到时自动交卷逻辑继续生效。
**允许复制文字:首次使用默认开启,后续保留设置。** 开启后解除已适配的文字选择、复制和右键限制;关闭后恢复网页原有 `body` 限制属性和相关样式。搜索结果的复制按钮独立可用。
@@ -48,6 +58,8 @@
开启“导出题库”后展开 CSV / JSON / TXT 按钮。在兼容练习页中,题目和参考答案加载完整后会自动下载一次 CSV,也可点击格式按钮手动导出。再次关闭后停止后续导出;主动重新开启可再次下载当前题库。导出直接读取网页中的参考答案,包括隐藏答案,不用导入题库补全缺失答案。
+考试页参考答案为空,不提供考试题库导出;可使用岗位题库搜索和答案核对。
+
- CSV 使用 UTF-8 BOM,可用 Excel 打开;JSON 保留结构化选项和图片地址;TXT 便于阅读。
- 缺题或缺答案时不自动下载;手动导出会标记不完整。没有答题卡总数时,只能核对当前页面内容。
- 分页或滚动后才加载的题目,需要先加载或逐页导出。同一次页面运行中,相同练习内容只自动下载一次。
diff --git a/answer-matcher.js b/answer-matcher.js
index c43148a..646a751 100644
--- a/answer-matcher.js
+++ b/answer-matcher.js
@@ -5,11 +5,19 @@
const cache = new WeakMap();
function normalize(value) {
- return String(value == null ? '' : value).normalize('NFKC')
- .replace(/[−﹣]/g, '-').replace(/\u00a0/g, ' ')
- .replace(/[\s\u200b\uFEFF]+/g, ' ').trim().toLowerCase();
+ // Strict answer identity: MPa/mPa, m²/m2 and Ⅰ/I are distinct.
+ return String(value == null ? '' : value)
+ .replace(/[\u200b-\u200d\u2060\uFEFF]/g, '')
+ .replace(/\s+/g, ' ').trim();
}
+ // Search is intentionally more tolerant; these keys never authorize clicks.
+ function searchNormalize(value) {
+ return normalize(value).normalize('NFKC').replace(/[−﹣]/g, '-').toLowerCase();
+ }
+
+ function courseNormalize(value) { return searchNormalize(value); }
+
function normalizeType(value) {
const type = String(value || '').trim().replace(/^[\[【]|[\]】]$/g, '').replace(/题$/, '');
return ({ 单选: '单选', 单项选择: '单选', single: '单选', radio: '单选', 多选: '多选', 多项选择: '多选', multiple: '多选', checkbox: '多选', 判断: '判断', 判断正误: '判断', boolean: '判断' })[type.toLowerCase()] || type;
@@ -77,9 +85,9 @@
const exactSource = records.filter(record => record.sourceUrl && sourceIdentity(record.sourceUrl) === identity);
if (exactSource.length) { records = exactSource; sourceMatched = true; }
}
- const course = normalize(context.course);
+ const course = courseNormalize(context.course);
if (course) {
- const exactCourse = records.filter(record => normalize(record.course) === course);
+ const exactCourse = records.filter(record => courseNormalize(record.course) === course);
if (exactCourse.length) { records = exactCourse; sourceMatched = true; }
}
const answers = new Set(records.map(correctKey));
@@ -89,7 +97,9 @@
if (!wantedKeys.length || new Set(wantedKeys).size !== wantedKeys.length || wantedKeys.some(text => !currentTexts.includes(text))) return result('unsupported', '题库参考答案无法唯一对应当前选项', records);
const selected = question.options.filter(option => wantedKeys.includes(normalize(option.text)));
if (normalizeType(question.type) !== '多选' && selected.length !== 1) return result('unsupported', '单选/判断题的参考答案数量不正确', records);
- return result('matched', sourceMatched ? '岗位、课程、题干及选项完全匹配' : '岗位、题干及选项完全匹配,所有来源答案一致', records, selected.map(option => normalizedLabel(option.label)), selected.map(option => option.text));
+ // Keep label/text pairs in current-page order, but obtain each text from bank evidence.
+ const evidenceTexts = selected.map(option => wanted.find(text => normalize(text) === normalize(option.text)));
+ return result('matched', sourceMatched ? '岗位、课程、题干及选项完全匹配' : '岗位、题干及选项完全匹配,所有来源答案一致', records, selected.map(option => normalizedLabel(option.label)), evidenceTexts);
}
function search(query, settings, snapshot) {
@@ -97,14 +107,14 @@
const role = settings.role;
const empty = reason => ({ items: [], total: 0, reason, role: role || '', query: String(query || ''), hasMore: false });
if (!ROLES.includes(role)) return empty('请先选择主操、班长或副操');
- const fullQuery = normalize(query);
+ const fullQuery = searchNormalize(query);
if (!fullQuery) return empty('请输入题干、关键词或选项文字');
const tokens = fullQuery.split(/\s+/).filter(Boolean);
- const course = normalize(settings.course);
+ const course = courseNormalize(settings.course);
const records = snapshot && Array.isArray(snapshot.records) ? snapshot.records : [];
const groups = new Map(), conflicts = new Map();
records.forEach(record => {
- if (record.role !== role || (course && normalize(record.course) !== course)) return;
+ if (record.role !== role || (course && courseNormalize(record.course) !== course)) return;
const baseKey = groupKey(record), answerKey = correctKey(record), key = baseKey + '\n' + answerKey;
if (!conflicts.has(baseKey)) conflicts.set(baseKey, new Set());
conflicts.get(baseKey).add(answerKey);
@@ -116,9 +126,9 @@
});
const items = [];
groups.forEach(group => {
- const stem = normalize(group.question);
- const optionText = (group.options || []).map(option => normalize(option.text)).join('\n');
- const answerText = (group.correctTexts || []).map(normalize).join('\n');
+ const stem = searchNormalize(group.question);
+ const optionText = (group.options || []).map(option => searchNormalize(option.text)).join('\n');
+ const answerText = (group.correctTexts || []).map(searchNormalize).join('\n');
const haystack = stem + '\n' + optionText + '\n' + answerText;
if (!tokens.every(token => haystack.includes(token))) return;
let score = stem === fullQuery ? 10000 : stem.includes(fullQuery) ? 5000 : 0;
@@ -134,7 +144,7 @@
return { items: items.slice(offset, offset + limit), total, role, query: String(query), reason: total ? '' : '当前岗位未找到符合条件的题目', hasMore: offset + limit < total };
}
- const api = Object.freeze({ version: '2.0.0', normalize, normalizeType, optionKey, questionKey, sourceIdentity, match, search });
+ const api = Object.freeze({ version: '2.0.0', normalize, searchNormalize, normalizeType, optionKey, questionKey, sourceIdentity, match, search });
root.YianAnswerMatcher = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/answer-runner.js b/answer-runner.js
index 30f2d11..17996be 100644
--- a/answer-runner.js
+++ b/answer-runner.js
@@ -1,4 +1,4 @@
-/* YianAnswerRunner: 只在已识别的练习页面串行点击真实选项。 */
+/* YianAnswerRunner: 只在已明确识别的练习或考试页面串行点击真实选项。 */
(function (root) {
'use strict';
@@ -7,10 +7,11 @@
const WAIT_LIMIT = 1200;
const CLICK_GAP = 150;
const COUNT_KEYS = ['selected', 'pending', 'missing', 'conflict', 'locked', 'failed', 'unsupported'];
- const CONTEXT_KEYS = ['role', 'course', 'sourceUrl', 'routeKey', 'pageSignature', 'revision'];
+ const CONTEXT_KEYS = ['role', 'course', 'sourceUrl', 'routeKey', 'pageSignature', 'revision', 'exam', 'mode'];
function normalize(value) {
- return String(value == null ? '' : value).replace(/\u00a0/g, ' ').replace(/[\t\n\r ]+/g, ' ').trim();
+ // 仅中和排版空白和不可见分隔符;不折叠大小写、数字、单位或标点。
+ return String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/\s+/g, ' ').trim();
}
function questionSignature(question) {
@@ -24,8 +25,13 @@
return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(questionSignature)]);
}
- function isLocked(box, selectors) {
+ function isLocked(box, selectors, context) {
if (box.querySelector('.pc_examtest_content_form_disabled')) return true;
+ if (box.closest('[disabled],[aria-disabled="true"]')) return true;
+ if (Array.from(box.querySelectorAll(selectors.option)).some(function (node) { return Boolean(node.closest('[disabled],[aria-disabled="true"]')); })) return true;
+ // 考试正常作答时没有查看答案控件,单选/判断也允许改选。
+ // 已提交、到时或出现提交对话框由 getContext 将 exam 置 false,每一步都会复核。
+ if (context && context.exam === true) return false;
const component = box.closest('[app-exam-question],app-exam-question');
const flags = [box, component].filter(Boolean);
if (flags.some(function (element) {
@@ -39,12 +45,12 @@
});
}
- function optionState(box, selectors) {
+ function optionState(box, selectors, context) {
const nodes = Array.from(box.querySelectorAll(selectors.option));
return {
nodes: nodes,
selected: nodes.map(function (node) { return node.classList.contains(CHECKED); }),
- locked: isLocked(box, selectors)
+ locked: isLocked(box, selectors, context)
};
}
@@ -76,7 +82,14 @@
records.forEach(function (record) {
if (COUNT_KEYS.includes(record.status)) counts[record.status]++;
if (record.pending) counts.pending++;
- details.push({ number: record.number, status: record.status, reason: record.reason || '' });
+ details.push({
+ number: record.number, question: record.question, course: record.course,
+ pageCourse: record.pageCourse, sourceCourses: record.sourceCourses.slice(),
+ status: record.status, reason: record.reason || '',
+ correctTexts: record.correctTexts.slice(), correctLabels: record.correctLabels.slice(),
+ currentLabels: record.currentLabels.slice(), currentTexts: record.currentTexts.slice(),
+ currentAnswerText: record.currentAnswerText
+ });
});
counts.details = details;
return counts;
@@ -89,7 +102,19 @@
}
function setRecord(item, status, reason, pending) {
- records.set(item.key, { number: item.question.number, status: status, reason: reason || '', pending: Boolean(pending) });
+ const nodes = Array.from(item.box.querySelectorAll(selectors.option));
+ const selected = item.question.options.filter(function (_, index) { return nodes[index] && nodes[index].classList.contains(CHECKED); });
+ const sourceCourses = (item.sourceCourses || []).slice();
+ records.set(item.key, {
+ index: item.index, number: item.question.number, question: item.question.question,
+ course: sourceCourses.length ? sourceCourses.join('、') : item.course,
+ pageCourse: item.course, sourceCourses: sourceCourses,
+ status: status, reason: reason || '', pending: Boolean(pending),
+ correctTexts: (item.correctTexts || []).slice(), correctLabels: (item.correctLabels || []).slice(),
+ currentLabels: selected.map(function (option) { return option.label; }),
+ currentTexts: selected.map(function (option) { return option.text; }),
+ currentAnswerText: selected.map(function (option) { return option.label + '. ' + option.text; }).join(';')
+ });
notifyProgress();
}
@@ -104,11 +129,11 @@
}
function initialReason(context) {
- if (!context || context.ready !== true) return '等待练习页面就绪。';
+ if (!context || context.ready !== true) return '等待答题页面就绪。';
if (context.local === true || root.location && root.location.protocol === 'file:') return '保存的静态网页可搜索和导出,不能记录自动选择。';
- if (context.practice !== true) return '当前页面未识别为练习,自动选择已停止。';
+ if (context.practice !== true && context.exam !== true) return '当前页面未识别为可作答的练习或考试,自动选择已停止。';
if (!context.role) return '请先选择主操、班长或副操。';
- if (!context.root || !context.root.isConnected || context.root.ownerDocument !== doc) return '练习内容已切换,等待新页面。';
+ if (!context.root || !context.root.isConnected || context.root.ownerDocument !== doc) return '答题内容已切换,等待新页面。';
return '';
}
@@ -165,7 +190,7 @@
const items = boxes.map(function (box, index) {
const question = bank.questions[index];
const signature = questionSignature(question);
- return { box: box, index: index, question: question, signature: signature, key: index + '\u0000' + signature };
+ return { box: box, index: index, question: question, course: context.course || '', signature: signature, key: index + '\u0000' + signature };
}).filter(function (item) { return context.root === item.box || context.root.contains(item.box); });
return { items: items, signature: bankSignature(bank) };
}
@@ -178,10 +203,10 @@
if (boxes[item.index] !== item.box) return { reason: '题目顺序已变化,已停止本轮自动选择。', cancelled: true };
const bank = core.extract(doc);
// 父面板的 MutationObserver 尚未运行时,也直接阻断标题或其他题目的变化。
- if (bankSignature(bank) !== run.bankSignature) return { reason: '练习题目或课程已更新,已停止本轮自动选择。', cancelled: true };
+ if (bankSignature(bank) !== run.bankSignature) return { reason: '题目或课程已更新,已停止本轮自动选择。', cancelled: true };
const current = bank.questions[item.index];
if (!current || questionSignature(current) !== item.signature) return { reason: '题干或选项已变化,已停止本轮自动选择。', cancelled: true };
- const state = optionState(item.box, selectors);
+ const state = optionState(item.box, selectors, run.context);
if (state.nodes.length !== current.options.length) return { reason: '选项尚未完整加载。' };
return { question: current, state: state };
}
@@ -234,12 +259,18 @@
const actualLabels = question.options.map(function (option) { return normalize(option.label); });
if (new Set(actualLabels).size !== actualLabels.length || labels.some(function (label) { return !actualLabels.includes(label); })) return { reason: '答案不能唯一对应网页选项,已跳过。' };
if (question.type !== '多选' && labels.length !== 1) return { reason: '单选或判断题匹配到多个答案,已跳过。' };
- if (Array.isArray(matched.correctTexts) && matched.correctTexts.length) {
- const expectedTexts = matched.correctTexts.map(normalize).sort();
- const actualTexts = question.options.filter(function (option) { return labels.includes(normalize(option.label)); }).map(function (option) { return normalize(option.text); }).sort();
- if (JSON.stringify(expectedTexts) !== JSON.stringify(actualTexts)) return { reason: '匹配答案文字与网页选项不一致,已跳过。' };
- }
- return { wanted: actualLabels.map(function (label) { return labels.includes(label); }) };
+ if (!Array.isArray(matched.correctTexts) || matched.correctTexts.length !== labels.length) return { reason: '题库缺少完整正确答案文字,不能仅凭答案字母自动选择。' };
+ const expectedTexts = matched.correctTexts.map(normalize);
+ if (expectedTexts.some(function (text) { return !text; }) || new Set(expectedTexts).size !== expectedTexts.length) return { reason: '正确答案文字为空或重复,无法唯一核验。' };
+ const selected = question.options.filter(function (option) { return labels.includes(normalize(option.label)); });
+ const actualTexts = selected.map(function (option) { return normalize(option.text); });
+ if (JSON.stringify(expectedTexts.slice().sort()) !== JSON.stringify(actualTexts.slice().sort())) return { reason: '题库正确答案全文与网页选项不一致,已跳过。' };
+ return {
+ wanted: actualLabels.map(function (label) { return labels.includes(label); }),
+ correctLabels: selected.map(function (option) { return option.label; }),
+ // 原始题库文本按当页选项顺序存入证据,方便与 currentLabels 逐项核对。
+ correctTexts: actualTexts.map(function (text) { return matched.correctTexts[expectedTexts.indexOf(text)]; })
+ };
}
async function processQuestion(run, item) {
@@ -247,15 +278,19 @@
let current = readCurrent(run, item);
if (current.cancelled) return abortForChange(run, current.reason);
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
- if (records.has(item.key)) return true;
- if (!item.box.closest('[app-exam-question],app-exam-question')) { setRecord(item, 'unsupported', '题目缺少已适配的练习组件,已跳过。'); return true; }
+ const previous = records.get(item.key);
+ if (previous && previous.status !== 'selected' && !previous.manualInvalid) return true;
+ if (!item.box.closest('[app-exam-question],app-exam-question')) { setRecord(item, 'unsupported', '题目缺少已适配的答题组件,已跳过。'); return true; }
if (!['单选', '多选', '判断'].includes(current.question.type)) { setRecord(item, 'unsupported', '暂不支持该题型自动选择。'); return true; }
- if (current.state.locked) { setRecord(item, 'locked', '该题已判分或锁定,保留原作答。'); return true; }
+ if (current.state.locked && !previous) { setRecord(item, 'locked', '该题已判分或锁定,保留原作答。'); return true; }
const matched = await options.match(current.question, options.getContext());
current = readCurrent(run, item);
if (current.cancelled) return abortForChange(run, current.reason);
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
- if (current.state.locked) { setRecord(item, 'locked', '匹配期间该题已锁定,保留原作答。'); return true; }
+ item.correctTexts = matched && Array.isArray(matched.correctTexts) ? matched.correctTexts.slice() : [];
+ item.correctLabels = matched && Array.isArray(matched.correctLabels) ? matched.correctLabels.slice() : [];
+ item.sourceCourses = Array.from(new Set((matched && Array.isArray(matched.records) ? matched.records : []).map(function (record) { return normalize(record.course); }).filter(Boolean)));
+ if (current.state.locked && !previous) { setRecord(item, 'locked', '匹配期间该题已锁定,保留原作答。'); return true; }
if (!matched || matched.status !== 'matched') {
const status = matched && ['missing', 'conflict', 'unsupported'].includes(matched.status) ? matched.status : 'unsupported';
setRecord(item, status, matched && matched.reason || '未获得可自动选择的唯一答案。');
@@ -263,6 +298,13 @@
}
const target = resolveTargets(current.question, matched);
if (target.reason) { setRecord(item, 'unsupported', target.reason); return true; }
+ item.correctTexts = target.correctTexts;
+ item.correctLabels = target.correctLabels;
+ if (current.state.locked) {
+ if (sameSelection(current.state.selected, target.wanted)) setRecord(item, 'selected', '已重新核验原作答与题库答案一致。');
+ else setRecord(item, 'locked', '该题已锁定且当前作答不同于题库答案,保留原作答。');
+ return true;
+ }
let changed = false;
if (current.question.type === '多选') {
// 每次按当前集合核对;先去掉错误选项,随后补选目标选项,不重复 toggle。
@@ -289,7 +331,7 @@
if (current.cancelled) return abortForChange(run, current.reason);
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
if (!sameSelection(current.state.selected, target.wanted)) { setRecord(item, 'failed', '网页当前选中集合与答案不一致,已停止修改该题。'); return true; }
- const pending = current.question.type === '多选' && !current.state.locked;
+ const pending = run.context.exam !== true && current.question.type === '多选' && !current.state.locked;
setRecord(item, 'selected', pending ? '已选择,待网页确认。' : changed ? '已选择匹配答案。' : '当前已选中匹配答案。', pending);
return true;
}
@@ -334,7 +376,7 @@
const currentKeys = new Set(items.map(function (item) { return item.key; }));
records.forEach(function (_, key) { if (!currentKeys.has(key)) records.delete(key); });
notifyProgress();
- if (!items.length) { notifyState('idle', '当前没有可处理的练习题目。'); return Promise.resolve(progress()); }
+ if (!items.length) { notifyState('idle', '当前没有可处理的答题题目。'); return Promise.resolve(progress()); }
const run = { id: ++generation, context: captured, bankSignature: extracted.signature, cancelled: false, waiters: new Set(), currentBox: null, promise: null };
active = run;
// 先写入 promise,再通知或执行,使同步 UI 回调中的 start 也不会并发运行。
@@ -347,11 +389,43 @@
}
function manualClick(event) {
- if (!event.isTrusted || !active || active.cancelled) return;
+ if (!event.isTrusted || destroyed) return;
const target = event.target && event.target.nodeType === 1 ? event.target : event.target && event.target.parentElement;
if (!target) return;
+ const run = active && !active.cancelled ? active : null;
+ let currentScope = run && run.context;
+ if (!currentScope && scope && records.size) {
+ const context = options.getContext();
+ if (!initialReason(context) && sameContext(scope, captureContext(context))) currentScope = scope;
+ }
+ if (!currentScope) return;
+ const submit = target.closest('.pc_examtest_commit,button[type="submit"],input[type="submit"]');
+ if (submit && currentScope.root.contains(submit)) {
+ pause('检测到手动交卷,已暂停自动选择。');
+ return;
+ }
const box = target.closest(selectors.question);
- if (box && (active.context.root === box || active.context.root.contains(box))) pause('检测到手动作答,已暂停自动选择;可重新开启继续。');
+ if (!box || currentScope.root !== box && !currentScope.root.contains(box)) return;
+ let invalidated = false;
+ if (target.closest(selectors.option)) {
+ const index = Array.from(doc.querySelectorAll(selectors.question)).indexOf(box);
+ records.forEach(function (record) {
+ if (record.index !== index || record.status !== 'selected') return;
+ // 捕获阶段网页 onclick 还未执行,不能把此前的 checked 集合当成改选后的证据。
+ record.status = 'unsupported';
+ record.reason = '手动作答后需重新核对';
+ record.pending = false;
+ record.manualInvalid = true;
+ record.correctTexts = [];
+ record.correctLabels = [];
+ record.currentLabels = [];
+ record.currentTexts = [];
+ record.currentAnswerText = '';
+ invalidated = true;
+ });
+ }
+ if (invalidated) notifyProgress();
+ if (run || invalidated) pause('检测到手动作答,已暂停自动选择;可重新开启继续。');
}
doc.addEventListener('click', manualClick, true);
@@ -375,5 +449,5 @@
return { start: start, stop: stop, reset: reset, destroy: destroy, get running() { return Boolean(active && !active.cancelled); } };
}
- root.YianAnswerRunner = { version: '1.0.0', create: create };
+ root.YianAnswerRunner = { version: '1.1.0', create: create };
})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/assistant-panel.js b/assistant-panel.js
index 23a0a28..034c83a 100644
--- a/assistant-panel.js
+++ b/assistant-panel.js
@@ -20,7 +20,7 @@
const doc = global.document;
const state = {
autoAnswer: false, allowCopy: true, role: '', course: '', courses: [],
- bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别练习页面',
+ bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false
};
let destroyed = false;
@@ -38,6 +38,7 @@
*,*::before,*::after{box-sizing:border-box}button,input,select{font:inherit}button,input,select,label{-webkit-user-select:none;user-select:none}button{cursor:pointer}button:disabled,input:disabled,select:disabled{cursor:not-allowed;opacity:.5}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #b8d1ff;outline-offset:2px}button{border:1px solid var(--line);background:white;color:#273953;border-radius:7px;padding:6px 10px}button:hover:enabled{background:#f2f6ff;border-color:#b6caf1}input[type=text],select{width:100%;min-width:0;border:1px solid #cfd8e7;border-radius:7px;background:#fff;color:#17253d;padding:8px}select{padding:6px 8px}input[type=checkbox],input[type=radio]{accent-color:var(--accent)}
.panel{width:354px;max-width:calc(100vw - 24px);background:white;border:1px solid #dce3ee;border-radius:13px;box-shadow:0 8px 36px #14264528;overflow:hidden}.header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:#172a49;color:white}.brand{font-size:15px;font-weight:700;letter-spacing:.2px}.toggle{border:0;background:#ffffff17;color:white;padding:3px 9px;border-radius:5px;min-width:30px}.toggle:hover:enabled{background:#ffffff2c;color:white;border-color:transparent}.body{padding:13px;max-height:calc(100vh - 102px);max-height:calc(100dvh - 102px);overflow:auto;overscroll-behavior:contain}.body[hidden]{display:none}.panel.collapsed{width:auto;min-width:140px}.collapsed .header{padding:10px 12px;gap:16px}.switches{display:grid;gap:9px;padding-bottom:12px;border-bottom:1px solid var(--line)}.switch-row{display:flex;align-items:center;justify-content:space-between;position:relative;gap:12px;cursor:pointer;font-weight:600}.switch{appearance:none;-webkit-appearance:none;width:35px;height:21px;margin:0;border:0!important;border-radius:20px;background:#b7c0ce;position:relative;transition:background .15s}.switch::after{content:"";position:absolute;width:15px;height:15px;top:3px;left:3px;border-radius:50%;background:white;box-shadow:0 1px 3px #0002;transition:transform .15s}.switch:checked{background:var(--accent)}.switch:checked::after{transform:translateX(14px)}.hint{font-size:12px;color:var(--muted);margin:6px 0 0;overflow-wrap:anywhere}.section{padding-top:12px}.section-title{font-size:13px;font-weight:700;margin:0 0 7px}.import-row{display:flex;align-items:center;gap:10px}.import-row button{white-space:nowrap}.bank-info{font-size:12px;color:var(--muted);overflow-wrap:anywhere}.storage{color:#97702e;margin:4px 0 0;font-size:11px}.roles{display:flex;gap:6px;flex-wrap:wrap}.role{display:flex;align-items:center;gap:4px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;cursor:pointer;background:white;flex:1;justify-content:center;white-space:nowrap}.role.active{background:#edf3ff;border-color:#abc5fa;color:#194dab}.role input{margin:0}.role-count{font-size:10px;color:var(--muted)}.course-row{display:grid;grid-template-columns:36px 1fr;gap:7px;align-items:center;margin-top:8px}.search-row{display:flex;gap:6px;margin-top:9px}.search-row input{flex:1}.primary{background:var(--accent);border-color:var(--accent);color:white;white-space:nowrap}.primary:hover:enabled{background:#184baf;color:white;border-color:#184baf}.selection-search{margin-top:7px;font-size:12px;width:100%}.notice{padding:8px 9px;border-radius:7px;margin:10px 0 0;font-size:12px;overflow-wrap:anywhere;background:#edf3ff;color:#254e8b}.notice.warning{background:#fff5df;color:#8a5d11}.notice.error{background:#fff0ed;color:#a23a2c}.notice.success{background:#eaf7f0;color:#1e754c}.notice[hidden]{display:none}.results-title{font-size:12px;color:var(--muted);margin:11px 0 7px}.empty{background:var(--surface);border-radius:8px;padding:13px 10px;color:var(--muted);font-size:12px}.result{border:1px solid var(--line);border-radius:9px;padding:10px;margin-bottom:9px;-webkit-user-select:text;user-select:text}.question{font-weight:600;line-height:1.6;overflow-wrap:anywhere;margin:0 0 7px;white-space:pre-wrap}.type{display:inline-block;font-size:10px;font-weight:500;color:#556984;background:#eff3f8;padding:1px 5px;border-radius:4px;margin-right:5px;vertical-align:1px}.answer{font-weight:600;color:#1b714a;background:#edf8f1;border-radius:5px;padding:6px 7px;overflow-wrap:anywhere}.options{padding:0;margin:7px 0;list-style:none;font-size:12px;display:grid;gap:4px}.option{padding:3px 6px;border-radius:4px;overflow-wrap:anywhere;white-space:pre-wrap}.option.correct{color:#196442;background:#edf8f1;font-weight:600}.sources{font-size:11px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere;margin-top:5px}.conflict{color:#9d6012;background:#fff5df;border-radius:5px;font-size:11px;padding:6px;margin:6px 0}.copy-result{margin-top:8px;font-size:11px}.pagination{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted);margin:5px 0 0}.pagination button{font-size:11px;padding:4px 8px}.page-section{border-top:1px solid var(--line);margin-top:13px;padding-top:11px}.page-status{font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted)}.metrics{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11px;color:var(--muted);margin-top:6px}.unprocessed{font-size:11px;color:#8b621c;margin-top:7px}.unprocessed summary{cursor:pointer}.unprocessed-list{max-height:155px;overflow:auto;padding:7px;border-radius:6px;background:#fff7e8;margin-top:4px;display:grid;gap:5px;overflow-wrap:anywhere}.exports{display:flex;gap:6px;margin-top:9px}.exports button{flex:1;font-size:12px}.auto-export{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);margin-top:8px}.auto-export input{margin:0}.busy{color:#83692c}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){.body{padding:11px}.panel{width:350px}.role{padding:6px 5px}}
`;
+ style.textContent += '.answer-check{font-size:11px;color:#235c43;margin-top:7px}.answer-check summary{cursor:pointer}.answer-check-list{max-height:210px;overflow:auto;overscroll-behavior:contain;margin-top:5px;display:grid;gap:7px}.answer-check-item{border:1px solid #d9eade;border-radius:6px;background:#f0f8f3;padding:8px;overflow-wrap:anywhere;-webkit-user-select:text;user-select:text}.answer-check-question{font-weight:600;margin:0 0 4px;white-space:pre-wrap}.answer-check-course{color:var(--muted);margin:0 0 5px}.answer-check-label{font-weight:600;margin:0 0 3px}.answer-check-options{margin:0;padding:0;list-style:none;display:grid;gap:4px;white-space:pre-wrap}.answer-check-note{font-size:10px;color:var(--muted);margin:5px 0 0}';
root.appendChild(style);
const visibilityStyle = doc.createElement('style');
visibilityStyle.textContent = '[hidden]{display:none!important}';
@@ -219,15 +220,21 @@
});
const pageSection = el(doc, 'div', 'page-section');
- pageSection.appendChild(el(doc, 'h2', 'section-title', '当前练习'));
+ const pageTitle = el(doc, 'h2', 'section-title', '当前页面');
+ pageSection.appendChild(pageTitle);
const pageStatus = el(doc, 'div', 'page-status');
const metrics = el(doc, 'div', 'metrics');
+ const answerCheck = el(doc, 'details', 'answer-check');
+ const answerCheckSummary = el(doc, 'summary', '', '查看答案核对');
+ const answerCheckList = el(doc, 'div', 'answer-check-list');
+ answerCheck.append(answerCheckSummary, answerCheckList);
+ answerCheck.hidden = true;
const unprocessed = el(doc, 'details', 'unprocessed');
const unprocessedSummary = el(doc, 'summary', '', '查看未处理题目');
const unprocessedList = el(doc, 'div', 'unprocessed-list');
unprocessed.append(unprocessedSummary, unprocessedList);
unprocessed.hidden = true;
- pageSection.append(pageStatus, metrics, unprocessed);
+ pageSection.append(pageStatus, metrics, answerCheck, unprocessed);
body.appendChild(pageSection);
const exportSection = el(doc, 'section', 'page-section export-section');
@@ -241,7 +248,7 @@
exportContent.id = 'yian-export-content';
exportContent.hidden = true;
exportSwitch.input.setAttribute('aria-controls', exportContent.id);
- exportContent.appendChild(el(doc, 'p', 'hint', '开启后,题目和答案完整时自动导出 CSV;也可点击下方按钮重新导出。关闭后停止后续导出。'));
+ exportContent.appendChild(el(doc, 'p', 'hint', '仅在练习页面导出。开启后,练习题目和答案完整时自动导出 CSV;也可点击下方按钮重新导出。关闭后停止后续导出。'));
const exportRow = el(doc, 'div', 'exports');
const exportButtons = [];
['csv', 'json', 'txt'].forEach(function (format) {
@@ -260,7 +267,7 @@
function autoUnavailableReason() {
if (!state.role) return '请选择岗位后再开启自动选择。';
if (!(Number(state.roleCounts && state.roleCounts[state.role]) > 0)) return '请导入包含' + state.role + '题目的题库。';
- if (!state.canAnswer) return state.pageStatus || '当前页面未识别到支持的练习题目。';
+ if (!state.canAnswer) return state.pageStatus || '当前页面未识别到支持的练习或考试题目。';
return '';
}
autoSwitch.label.addEventListener('pointerdown', function () {
@@ -268,6 +275,81 @@
if (!state.autoAnswer && reason) setNotice(reason, 'warning');
});
+ const metricLabels = { selected: '已选择', pending: '待确认', missing: '未匹配', conflict: '冲突', locked: '已锁定', unsupported: '需人工核对', failed: '失败', skipped: '已跳过' };
+ const metricNodes = new Map();
+ const answerCheckNodes = new Map();
+ const unprocessedNodes = new Map();
+
+ function setText(node, value) {
+ value = plain(value);
+ if (node.textContent !== value) node.textContent = value;
+ }
+ // 每次进度更新复用相同题目的节点,保留列表滚动与 details 的展开状态。
+ function syncRows(container, entries, nodeCache, makeRow, updateRow) {
+ const present = new Set();
+ let previousNode = null;
+ entries.forEach(function (entry, index) {
+ const key = index + '\u0000' + plain(entry.number) + '\u0000' + plain(entry.question);
+ present.add(key);
+ let saved = nodeCache.get(key);
+ if (!saved) { saved = { node: makeRow(), signature: '' }; nodeCache.set(key, saved); }
+ const signature = JSON.stringify([entry.number, entry.status, entry.reason, entry.question, entry.course, entry.correctTexts, entry.currentLabels]);
+ if (signature !== saved.signature) { updateRow(saved.node, entry); saved.signature = signature; }
+ const expectedNode = previousNode ? previousNode.nextSibling : container.firstChild;
+ if (expectedNode !== saved.node) container.insertBefore(saved.node, expectedNode);
+ previousNode = saved.node;
+ });
+ nodeCache.forEach(function (saved, key) {
+ if (!present.has(key)) { saved.node.remove(); nodeCache.delete(key); }
+ });
+ }
+ function refreshMetrics() {
+ const progress = state.metrics || {};
+ Object.keys(metricLabels).forEach(function (key) {
+ let node = metricNodes.get(key);
+ if (progress[key] != null && !node) { node = el(doc, 'span'); metricNodes.set(key, node); metrics.appendChild(node); }
+ if (!node) return;
+ node.hidden = progress[key] == null;
+ if (!node.hidden) setText(node, metricLabels[key] + ' ' + Number(progress[key] || 0));
+ });
+ const details = Array.isArray(progress.details) ? progress.details : [];
+ const selected = details.filter(function (item) { return item.status === 'selected'; });
+ answerCheck.hidden = !selected.length;
+ setText(answerCheckSummary, '查看答案核对(' + selected.length + ')');
+ syncRows(answerCheckList, selected, answerCheckNodes, function () {
+ const row = el(doc, 'article', 'answer-check-item');
+ row.append(el(doc, 'p', 'answer-check-question'), el(doc, 'p', 'answer-check-course'), el(doc, 'p', 'answer-check-label', '题库正确答案全文 → 当前选项'), el(doc, 'ul', 'answer-check-options'), el(doc, 'p', 'answer-check-note'));
+ return row;
+ }, function (row, item) {
+ setText(row.children[0], '第 ' + plain(item.number) + ' 题' + (item.question ? ' · ' + plain(item.question) : ''));
+ const course = plain(item.course);
+ setText(row.children[1], course ? '课程:' + course : '课程来源未记录');
+ const texts = Array.isArray(item.correctTexts) ? item.correctTexts : [];
+ const labels = Array.isArray(item.currentLabels) ? item.currentLabels : [];
+ const list = row.children[3];
+ texts.forEach(function (text, index) {
+ let option = list.children[index];
+ if (!option) { option = el(doc, 'li'); list.appendChild(option); }
+ setText(option, (labels[index] ? plain(labels[index]) + '. ' : '当前字母未记录 · ') + plain(text));
+ });
+ if (!texts.length) {
+ let message = list.firstElementChild;
+ if (!message) { message = el(doc, 'li'); list.appendChild(message); }
+ setText(message, '题库答案文字未记录,请核对网页。');
+ }
+ const count = Math.max(1, texts.length);
+ while (list.children.length > count) list.lastElementChild.remove();
+ setText(row.children[4], item.reason || '');
+ row.children[4].hidden = !item.reason;
+ });
+ const notProcessed = details.filter(function (item) { return item.status !== 'selected'; });
+ unprocessed.hidden = !notProcessed.length;
+ setText(unprocessedSummary, '查看未处理题目(' + notProcessed.length + ')');
+ syncRows(unprocessedList, notProcessed, unprocessedNodes, function () { return el(doc, 'div'); }, function (row, item) {
+ setText(row, '第 ' + plain(item.number) + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。'));
+ });
+ }
+
let courseSignature = '';
function refresh() {
if (destroyed) return;
@@ -282,7 +364,7 @@
autoSwitch.input.disabled = !state.autoAnswer && (Boolean(unavailable) || Boolean(state.loading));
autoSwitch.label.title = unavailable;
copySwitch.input.checked = Boolean(state.allowCopy);
- answerHint.textContent = unavailable || (state.autoAnswer ? '正在自动选择;关闭即可停止后续操作。' : '开启后选择已匹配的答案,交卷由你操作。');
+ answerHint.textContent = unavailable || (state.autoAnswer ? '正在按题干和选项全文匹配答案;关闭即可停止后续操作。' : '支持练习和考试,按题干和选项全文匹配答案;交卷由你操作。');
bankInfo.textContent = state.loading ? '正在读取题库…' : '已加载 ' + Number(state.bankTotal || 0).toLocaleString('zh-CN') + ' 条' + (state.role ? ' · ' + state.role + ' ' + Number(state.roleCounts && state.roleCounts[state.role] || 0).toLocaleString('zh-CN') + ' 条' : '');
bankInfo.classList.toggle('busy', Boolean(state.loading));
importButton.disabled = Boolean(state.loading);
@@ -310,19 +392,9 @@
courseSelect.disabled = !state.role || Boolean(state.loading);
searchButton.disabled = Boolean(state.loading);
selectionButton.disabled = Boolean(state.loading);
- pageStatus.textContent = state.pageStatus || '等待识别练习页面';
- metrics.replaceChildren();
- const metricLabels = { selected: '已选择', pending: '待确认', missing: '未匹配', conflict: '冲突', locked: '已锁定', unsupported: '需人工核对', failed: '失败', skipped: '已跳过' };
- Object.keys(metricLabels).forEach(function (key) {
- if (state.metrics && state.metrics[key] != null) metrics.appendChild(el(doc, 'span', '', metricLabels[key] + ' ' + Number(state.metrics[key] || 0)));
- });
- const notProcessed = (state.metrics && state.metrics.details || []).filter(function (item) { return item.status !== 'selected'; });
- unprocessed.hidden = !notProcessed.length;
- unprocessedSummary.textContent = '查看未处理题目(' + notProcessed.length + ')';
- unprocessedList.replaceChildren();
- notProcessed.forEach(function (item) {
- unprocessedList.appendChild(el(doc, 'div', '', '第 ' + item.number + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。')));
- });
+ setText(pageTitle, state.pageMode === 'test' ? '当前考试' : state.pageMode === 'exercise' ? '当前练习' : '当前页面');
+ setText(pageStatus, state.pageStatus || '等待识别题目页面');
+ refreshMetrics();
exportSwitch.input.checked = Boolean(state.exportEnabled);
exportContent.hidden = !state.exportEnabled;
exportButtons.forEach(function (button) { button.disabled = !state.exportEnabled || !state.canExport; });
diff --git a/bank-store.js b/bank-store.js
index e0f3081..cacd28e 100644
--- a/bank-store.js
+++ b/bank-store.js
@@ -10,9 +10,11 @@
let importQueue = Promise.resolve();
function normalize(value) {
- return String(value == null ? '' : value).normalize('NFKC')
- .replace(/[−﹣]/g, '-').replace(/\u00a0/g, ' ')
- .replace(/[\s\u200b\uFEFF]+/g, ' ').trim().toLowerCase();
+ // Answer identity must retain case, unit spelling, superscripts and punctuation.
+ // Only presentation whitespace and invisible layout characters are cleaned.
+ return String(value == null ? '' : value)
+ .replace(/[\u200b-\u200d\u2060\uFEFF]/g, '')
+ .replace(/\s+/g, ' ').trim();
}
function normalizeType(value) {
@@ -109,7 +111,7 @@
}
function boolValue(value) {
- const text = normalize(value);
+ const text = normalize(value).toLowerCase();
if (/^(正确|对|是|true|√|✓)$/.test(text)) return 'true';
if (/^(错误|错|否|false|×|✗)$/.test(text)) return 'false';
return '';
diff --git a/build-userscript.mjs b/build-userscript.mjs
index 7b09f51..a2884e2 100644
--- a/build-userscript.mjs
+++ b/build-userscript.mjs
@@ -3,8 +3,8 @@ import { readFileSync, writeFileSync } from 'node:fs';
const metadata = `// ==UserScript==
// @name 谊安学助手 2.0
// @namespace yianxue-question-bank
-// @version 2.0.1
-// @description 不绑定网址的题库助手:岗位题库搜索、允许复制、练习自动选择与导出。
+// @version 2.0.2
+// @description 不绑定网址的题库助手:岗位搜索、答案全文核对、练习及桌面考试自动选择。
// @match *://*/*
// @match file:///*
// @run-at document-idle
diff --git a/package.json b/package.json
index 4eb06a0..b5ca8da 100644
--- a/package.json
+++ b/package.json
@@ -1,14 +1,15 @@
{
"name": "yianxue-question-bank-exporter",
- "version": "2.0.1",
+ "version": "2.0.2",
"private": true,
- "description": "不绑定网址的谊安学题库助手:岗位搜索、允许复制和练习自动选择",
+ "description": "不绑定网址的谊安学题库助手:岗位搜索、答案全文核对、练习及桌面考试自动选择",
"scripts": {
"build": "node build-userscript.mjs",
"merge:banks": "node scripts/merge-question-banks.cjs",
"pretest": "node build-userscript.mjs",
"test": "node tests/run.cjs",
- "test:assistant": "node tests/assistant.cjs"
+ "test:assistant": "node tests/assistant.cjs",
+ "test:exam": "node tests/exam-assistant.cjs"
},
"devDependencies": {
"playwright": "1.62.1"
diff --git a/practice-exporter.js b/practice-exporter.js
index b69bbb6..dd83701 100644
--- a/practice-exporter.js
+++ b/practice-exporter.js
@@ -14,6 +14,12 @@
const emptyMetrics = () => ({ selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: 0 });
let metrics = emptyMetrics();
+ 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);
@@ -23,11 +29,24 @@
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: !!root && (mode ? mode === 'exercise' : !!marker),
+ 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' ? params.get('title') || '' : '', mode };
+ expectedTitle: mode === 'exercise' || mode === 'test' ? params.get('title') || '' : '', mode };
}
function structureSignature(bank) {
@@ -42,7 +61,7 @@
const signature = route.root ? structureSignature(bank) : '';
return { ready: !!page && page.ready && !loading && page.key === route.key
&& page.root === route.root && page.signature === signature,
- practice: route.practice, local: route.local, role: config.role, course: bank.title,
+ 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 };
}
@@ -84,8 +103,8 @@
}
function setAutoAnswer(enabled) {
const context = getContext();
- if (enabled && (!context.ready || !context.practice || context.local || !config.role || !snapshot.counts[config.role])) {
- panel.setNotice('请先导入所选岗位题库,并进入已加载完成的兼容练习页面。', 'warning'); refreshPanel(); return false;
+ if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !config.role || !snapshot.counts[config.role])) {
+ panel.setNotice('请先导入所选岗位题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
}
config.autoAnswer = !!enabled; runKey = '';
if (!enabled) runner.stop('自动选择已关闭,已生效的选择保留。');
@@ -140,7 +159,7 @@
if (runner.running) runner.stop('当前页面未识别到练习题目');
if (page) previous = page;
page = null;
- refreshPanel({ canAnswer: false, canExport: false, pageStatus: '当前网页可搜索题库和复制文字;未识别到支持的练习题目。' }); return;
+ 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) {
@@ -148,27 +167,38 @@
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 = bank.expectedTotal === null || bank.expectedTotal === bank.total;
+ 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.practice) 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.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。';
+ else if (!countComplete) status = bank.expectedTotal === null ? '等待考试答题卡总题数,确认完整试卷后才能自动选择。'
+ : '已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。';
+ else if (route.exam) status = bank.title + ':' + bank.total + ' 题。按所选岗位题库严格核对题干、选项和正确答案全文。'
+ + (route.local ? ' 保存网页只用于核对,自动选择需要在线考试页面。' : '完成后请检查结果并自行交卷。');
else status = bank.title + ':' + bank.total + ' 题,' + (bank.missingAnswers.length
? '缺少 ' + bank.missingAnswers.length + ' 个网页答案;可用导入题库选择。' : '网页答案齐全。')
+ (route.local ? ' 保存网页支持搜索与导出;自动选择需要在线练习页面。' : '');
- refreshPanel({ pageStatus: status, canExport: route.practice && !!bank.total,
- canAnswer: route.practice && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] });
- if (!route.practice || !page.ready || loading) { if (runner.running) runner.stop('等待兼容练习页面准备完成'); return; }
- if (config.exportEnabled && bank.complete) {
+ 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 && !!config.role && !!snapshot.counts[config.role] });
+ 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); }
diff --git a/question-bank-core.js b/question-bank-core.js
index 4377955..a236e10 100644
--- a/question-bank-core.js
+++ b/question-bank-core.js
@@ -11,7 +11,8 @@
explanation: '.pc_examtest_content_resolve',
card: '.examtestcard_count_defult'
};
- const CONTROL = '.pc_examtest_content_question_showanswer,script,style,button,input,select,textarea,iframe,object,embed,link';
+ // 考试页的分数和难度属于界面标记;只按 DOM class 去除,保留题干中的括号和数字。
+ const CONTROL = '.pc_examtest_content_question_showanswer,.pc_examtest_content_question_value,.pc_examtest_content_question_difficulty,script,style,button,input,select,textarea,iframe,object,embed,link';
const TYPE_RE = /^\s*[\[【](单选题?|多选题?|判断题?|填空题?|简答题?|问答题?)[\]】]\s*/;
const NUMBER_RE = /^\s*(\d+)\s*[..、]\s*/;
diff --git a/tests/assistant.cjs b/tests/assistant.cjs
index d4bc885..c2d174f 100644
--- a/tests/assistant.cjs
+++ b/tests/assistant.cjs
@@ -167,7 +167,7 @@ function documentHTML(practice) {
await second.evaluate(url => history.pushState({}, '', url), modeURL);
await second.waitForTimeout(800); assert(await second.getByRole('switch', { name: '自动选择答案' }).isDisabled());
assert.equal(await second.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
- console.log('PASS 面板关闭立即停止后续点击、重新开启续作、明确考试模式禁用');
+ console.log('PASS 面板关闭立即停止后续点击、重新开启续作、未适配的考试结构禁用');
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: '复制题目与答案' }).first().click();
diff --git a/tests/bank-engine.cjs b/tests/bank-engine.cjs
index 9fb81dc..17a86a8 100644
--- a/tests/bank-engine.cjs
+++ b/tests/bank-engine.cjs
@@ -60,7 +60,65 @@ function csv(rows) {
assert.throws(() => store.parseCSV('a,b\n未"转义,b'), /未转义/);
assert.notEqual(matcher.normalize('不应在 -50.5℃ 作业'), matcher.normalize('应在 50.5℃ 作业'));
assert.notEqual(matcher.normalize('50.5'), matcher.normalize('505'));
- assert.equal(matcher.normalize('−50.5 ℃'), '-50.5 °c');
+ assert.equal(matcher.normalize('−50.5 ℃'), '−50.5 ℃');
+ assert.equal(matcher.normalize(' M\u200bPa\t\n 10\u00a0℃\uFEFF '), 'MPa 10 ℃');
+ for (const [left, right] of [['MPa', 'mPa'], ['m²', 'm2'], ['Ⅰ', 'I'], ['不是', '是'], ['−50', '-50'], ['1.0', '10'], ['A,B', 'A,B']]) {
+ assert.notEqual(matcher.normalize(left), matcher.normalize(right), '自动匹配必须区分 ' + left + ' / ' + right);
+ assert.equal(store.normalize(left), matcher.normalize(left), '存储与匹配的严格规范化必须一致');
+ }
+ assert.equal(matcher.searchNormalize('−50.5 ℃'), '-50.5 °c');
+ assert.equal(matcher.searchNormalize('MPA'), matcher.searchNormalize('MPa'));
+
+ // Loose search may find similar units, but cannot merge or authorize a different unit.
+ const semanticRecords = [
+ store.validateRecord(record({ question: '压力单位 MPa 应怎样使用?', options: [{ label: 'A', text: 'MPa' }, { label: 'B', text: 'mPa' }], answer: 'A' })),
+ store.validateRecord(record({ question: '压力单位 mPa 应怎样使用?', options: [{ label: 'A', text: 'MPa' }, { label: 'B', text: 'mPa' }], answer: 'B' }))
+ ];
+ const semanticState = { records: semanticRecords, revision: 1 };
+ assert.equal(matcher.search('mpa', { role: '主操' }, semanticState).total, 2);
+ const pressurePage = { ...semanticRecords[0], options: [{ label: 'A', text: 'mPa' }, { label: 'B', text: 'MPa' }] };
+ const pressureAnswer = matcher.match(pressurePage, { role: '主操' }, semanticState);
+ assert.equal(pressureAnswer.status, 'matched');
+ assert.deepEqual(Array.from(pressureAnswer.correctLabels), ['B']);
+ assert.deepEqual(Array.from(pressureAnswer.correctTexts), ['MPa']);
+ assert.throws(() => store.validateRecord({ ...semanticRecords[0], correctTexts: ['mPa'] }), /不一致/, '答案校验不能混淆单位大小写');
+ for (const [bankText, pageText] of [['MPa', 'mPa'], ['m²', 'm2'], ['Ⅰ', 'I'], ['不应', '应'], ['50.5', '505']]) {
+ const evidence = store.validateRecord(record({ question: '语义不能被自动改写。', options: [{ label: 'A', text: bankText }, { label: 'B', text: '其他选项' }], answer: 'A' }));
+ const modifiedPage = { ...evidence, options: [{ label: 'A', text: pageText }, { label: 'B', text: '其他选项' }] };
+ assert.equal(matcher.match(modifiedPage, { role: '主操' }, { records: [evidence], revision: 1 }).status, 'missing', bankText + ' 不得误匹配 ' + pageText);
+ assert.notEqual(store.optionKey(evidence.options), store.optionKey(modifiedPage.options));
+ }
+ const evidenceRecord = store.validateRecord(record({ options: [{ label: 'A', text: 'MPa 10\n设备' }, { label: 'B', text: '备用设备' }], answer: 'A' }));
+ const evidenceAnswer = matcher.match({ ...evidenceRecord, options: [{ label: 'A', text: '备用设备' }, { label: 'B', text: 'MPa 10 设备' }] }, { role: '主操' }, { records: [evidenceRecord], revision: 1 });
+ assert.equal(evidenceAnswer.status, 'matched');
+ assert.deepEqual(Array.from(evidenceAnswer.correctLabels), ['B']);
+ assert.deepEqual(Array.from(evidenceAnswer.correctTexts), ['MPa 10\n设备'], '返回正确文字须保留题库证据原文');
+ evidenceAnswer.correctTexts[0] = '不能改写题库';
+ assert.equal(evidenceRecord.correctTexts[0], 'MPa 10\n设备', '结果数组不应直接暴露题库答案数组');
+ const multiEvidence = store.validateRecord(record({ type: '多选', options: [{ label: 'A', text: 'MPa 10\n设备' }, { label: 'B', text: '备用\n设备' }, { label: 'C', text: '无关设备' }], answer: 'AB' }));
+ const multiEvidenceAnswer = matcher.match({ ...multiEvidence, options: [{ label: 'A', text: '备用 设备' }, { label: 'B', text: '无关设备' }, { label: 'C', text: 'MPa 10 设备' }] }, { role: '主操' }, { records: [multiEvidence], revision: 1 });
+ assert.equal(multiEvidenceAnswer.status, 'matched');
+ assert.deepEqual(Array.from(multiEvidenceAnswer.correctLabels), ['A', 'C']);
+ assert.deepEqual(Array.from(multiEvidenceAnswer.correctTexts), ['备用\n设备', 'MPa 10\n设备'], '多选映射后逐项对应字母,保留每个选项的题库原文空白');
+ const sameStemDifferentAnswers = [semanticRecords[0], store.validateRecord({ ...semanticRecords[0], course: '另一课程', answer: 'B', correctTexts: undefined })];
+ assert.equal(matcher.match(pressurePage, { role: '主操' }, { records: sameStemDifferentAnswers, revision: 1 }).status, 'conflict', '不同大小写的正确文本应保留为答案冲突');
+
+ // Existing v2 data retains its source texts; recompute stale loose keys in memory.
+ const oldMemory = new Map([['yianxue.assistant.bank.v2', {
+ records: semanticRecords.map(item => ({ ...item, questionKey: 'old-loose-key', optionKey: 'old-loose-options', id: 'old-id' })),
+ revision: 7, updatedAt: '2026-10-10T00:00:00.000Z'
+ }]]);
+ const upgraded = await runtime('modern', oldMemory).store.load();
+ assert.equal(upgraded.total, 2, '回读旧库不丢失语义不同的记录');
+ assert.equal(upgraded.revision, 7);
+ assert.equal(upgraded.updatedAt, '2026-10-10T00:00:00.000Z');
+ upgraded.records.forEach(item => {
+ assert.equal(item.questionKey, matcher.questionKey(item));
+ assert.equal(item.optionKey, matcher.optionKey(item.options));
+ assert.notEqual(item.id, 'old-id');
+ });
+ assert.notEqual(upgraded.records[0].questionKey, upgraded.records[1].questionKey);
+ assert.equal(oldMemory.size, 1, '不更换稳定存储键或清空旧库');
const escaped = record({ options: [{ label: 'A', text: "'-50" }, { label: 'B', text: "'引用" }, { label: 'C', text: "''-50" }] });
let state = await store.importFiles([file('风险管理_题库_1题.csv', csv([escaped]))], { role: '主操' });
@@ -130,6 +188,7 @@ function csv(rows) {
const multipleResult = matcher.match({ ...q, type: '多选' }, { role: '主操' }, { records: [multiple], revision: 1 });
assert.equal(multipleResult.status, 'matched');
assert.deepEqual(Array.from(multipleResult.correctLabels), ['B', 'C']);
+ assert.deepEqual(Array.from(multipleResult.correctTexts), ['作业丙', '作业甲'], '多选字母与题库原答案文字按当前网页顺序逐项对应');
const quotaRuntime = runtime();
await quotaRuntime.store.importFiles([file('风险管理.csv', csv([record()]))], { role: '主操' });
quotaRuntime.environment.GM_setValue = () => { throw new Error('quota exceeded'); };
@@ -157,7 +216,7 @@ function csv(rows) {
await assert.rejects(store.importFiles([file('损坏.zip', await invalidZip.generateAsync({ type: 'nodebuffer' }))]), /不存在的选项/);
assert.equal((await store.load()).total, 1);
await assert.rejects(store.importFiles([file('题库.zip', await zip.generateAsync({ type: 'nodebuffer' })), file('一份.csv', csv([record()]))]), /只选择一个/);
- console.log('PASS 本地存储、原子导入、CSV引号/换行/转义、岗位隔离、精确换序匹配、答案冲突、来源消歧与搜索分页');
+ console.log('PASS 严格语义匹配与宽松搜索分离、原答案证据、旧库重算键、本地存储和原子导入、岗位隔离、换序/冲突/搜索分页');
const realZip = path.join(rootDir, '题库.zip');
if (fs.existsSync(realZip)) {
@@ -176,4 +235,45 @@ function csv(rows) {
assert.equal(new Set(imported.records.map(item => item.sourceFile)).size, 28);
console.log('PASS 实际题库 ZIP:28 CSV,5520 题,岗位数量 ' + JSON.stringify(imported.counts) + ';导入耗时 ' + Math.round(performance.now() - started) + ' ms');
}
+
+ // Parse real DOM instead of stripping bracketed numbers/difficulty with a regex.
+ const { chromium } = require('playwright');
+ const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
+ try {
+ const page = await browser.newPage();
+ const requests = [];
+ page.on('request', request => requests.push(request.url()));
+ const optionHTML = (label, text) => `
${label}${text}
`;
+ const questionHTML = (stem, options, answer) => `${stem}
${options.map((text, index) => optionHTML(String.fromCharCode(65 + index), text)).join('')}
参考答案:${answer}
`;
+ await page.setContent('DOM 元数据验证
' +
+ questionHTML('[单选] 7. 压力(2 MPa)达到(3)时,应保持Ⅰ型设备。(2分)
难度低
', ['MPa', 'mPa'], 'A') +
+ questionHTML('[判断] 8. 设备(2分)仍须检查难度系数(3)。(3分)难度中
', ['对', '错'], 'B') +
+ questionHTML('[多选] 9. m² 和 m2 是不同写法。', ['m²', 'm2'], 'AB'));
+ await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, 'question-bank-core.js'), 'utf8') });
+ const extracted = await page.evaluate(() => QuestionBankCore.extract(document));
+ assert.deepEqual(extracted.questions.map(item => item.number), [7, 8, 9]);
+ assert.deepEqual(extracted.questions.map(item => item.type), ['单选', '判断', '多选']);
+ assert.equal(extracted.questions[0].question, '压力(2 MPa)达到(3)时,应保持Ⅰ型设备。');
+ assert.equal(extracted.questions[1].question, '设备(2分)仍须检查难度系数(3)。', '真正的题干括号数字及难度字样须保留');
+ assert.equal(extracted.questions[2].question, 'm² 和 m2 是不同写法。', '旧版无 p 和无元数据的结构仍可解析');
+ assert.equal(extracted.questions[0].options[0].text, 'MPa');
+ assert.equal(extracted.questions[0].options[1].text, 'mPa');
+ assert(!extracted.questions[0].questionHTML.includes('pc_examtest_content_question_value'));
+ assert.equal(await page.locator('.pc_examtest_content_question_value').count(), 2, '提取只处理克隆,不能更改网页原 DOM');
+ const sampleDirectory = path.join(rootDir, '网页样本', '谊安学考试');
+ const sampleHTML = path.join(sampleDirectory, '谊安学.html');
+ const structurePath = path.join(sampleDirectory, '题目结构.json');
+ if (fs.existsSync(sampleHTML) && fs.existsSync(structurePath)) {
+ const expected = JSON.parse(fs.readFileSync(structurePath, 'utf8'));
+ const actual = await page.evaluate(html => QuestionBankCore.extract(QuestionBankCore.parseHTML(html)), fs.readFileSync(sampleHTML, 'utf8'));
+ assert.equal(actual.total, expected.total);
+ assert.equal(actual.answered, 0);
+ assert.equal(actual.title, expected.title);
+ assert.deepEqual(actual.questions.map(item => ({ number: item.number, type: item.type, question: item.question, options: item.options.map(option => ({ label: option.label, text: option.text })) })),
+ expected.questions.map(item => ({ number: item.number, type: item.type, question: item.question, options: item.options.map(option => ({ label: option.label, text: option.text })) })));
+ console.log('PASS 新考试样本 40 题:类型/题号/题干/选项与人工结构一致,分数和难度按 DOM 排除');
+ }
+ assert.equal(requests.length, 0, '核心提取和保存网页解析不得请求原网站');
+ console.log('PASS DOM 元数据清理、括号数字保留、旧无 p 结构与原页面不变');
+ } finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/tests/exam-assistant.cjs b/tests/exam-assistant.cjs
new file mode 100644
index 0000000..7b2c059
--- /dev/null
+++ b/tests/exam-assistant.cjs
@@ -0,0 +1,338 @@
+/* 桌面考试端到端验证:只载入中和后的保存 DOM,原站脚本及业务请求均不运行。 */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const http = require('node:http');
+const { pathToFileURL } = require('node:url');
+const { chromium } = require('playwright');
+
+const workspace = path.resolve(__dirname, '..');
+const scriptPath = path.join(workspace, '谊安学助手 2.0.user.js');
+const script = fs.readFileSync(scriptPath, 'utf8');
+const coreScript = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
+assert(script.includes('// @name 谊安学助手 2.0'));
+assert(script.includes('// @match *://*/*'));
+const qaDir = process.env.YIAN_EXAM_QA_DIR || path.join(os.tmpdir(), 'yianxue-exam-qa');
+fs.mkdirSync(qaDir, { recursive: true });
+const roles = ['主操', '班长', '副操'];
+const bankPaths = roles.map(role => path.join(workspace, '题库', role + '_合并题库.csv'));
+const samplePath = path.join(workspace, '网页样本', '谊安学考试', '谊安学.html');
+const realSample = fs.existsSync(samplePath) && bankPaths.every(file => fs.existsSync(file))
+ && process.env.YIAN_TEST_SYNTHETIC_EXAM !== '1';
+const normalize = text => String(text == null ? '' : text).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/\s+/g, ' ').trim();
+const sortedTexts = texts => texts.map(normalize).sort();
+const escapeHTML = text => String(text).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+
+function syntheticFixture() {
+ const course = '考试自动选择验证';
+ const records = [];
+ const questions = Array.from({ length: 40 }, (_, index) => {
+ const type = index < 15 ? '单选' : index < 30 ? '多选' : '判断';
+ const texts = type === '判断' ? ['对', '错'] : type === '多选'
+ ? ['正确甲' + index, '正确乙' + index, '错误丙' + index, '错误丁' + index]
+ : ['正确甲' + index, '错误乙' + index, '错误丙' + index, '错误丁' + index];
+ const question = '考试核对题 ' + (index + 1) + ':请选择符合要求的选项。';
+ roles.forEach(role => records.push({ role, course, number: index + 1, type, question,
+ options: texts.map((text, i) => ({ label: String.fromCharCode(65 + i), text })), answer: type === '多选' ? 'AB' : 'A' }));
+ return { type, question, texts: index < 20 ? texts.slice(1).concat(texts[0]) : texts };
+ });
+ const html = `${course}
${questions.map((q, index) =>
+ `
[${q.type}] ${index + 1}. ${q.question}(2分)
难度低
${q.texts.map((text, i) =>
+ `
${String.fromCharCode(65 + i)}${text}
`).join('')}
`).join('')}
倒计时:00:29:24
0
40
${questions.map((_, i) => `
${i + 1}
`).join('')}
交卷
`;
+ return { html, title: course, records };
+}
+
+function documentHTML(fixture) {
+ return `助手考试验证考试答案全文核对
本地验证:40 道保存题目,交卷由用户操作。
${fixture.html}`;
+}
+
+// 模拟题目组件自己的作答状态。点击先更新独立模型,再根据模型渲染 class 与答题卡。
+// 不加载原站 Angular,也不实现提交接口。
+function installExamModel() {
+ const CHECKED = 'examtest_content_list_des_checked';
+ const root = document.querySelector('app-examtest');
+ const extracted = QuestionBankCore.extract(document);
+ const boxes = Array.from(root.querySelectorAll('.pc_examtest_content'));
+ const cards = Array.from(root.querySelectorAll('.examtestcard_count_defult'));
+ document.querySelectorAll('.qa-confirm-dialog').forEach(node => node.remove());
+ const confirmation = document.createElement('div');
+ confirmation.className = 'modal dialog_box qa-confirm-dialog';
+ confirmation.setAttribute('aria-hidden', 'true');
+ const message = document.createElement('div'); message.className = 'modal-body'; message.textContent = '是否确认交卷?';
+ confirmation.appendChild(message); document.body.appendChild(confirmation);
+ const model = { questions: extracted.questions.map(q => ({ number: q.number, type: q.type,
+ options: q.options.map(option => ({ label: option.label, text: option.text })) })), userAnswer: [], clicks: [], commitClicks: 0 };
+ model.userAnswer = boxes.map(box => Array.from(box.querySelectorAll('.pc_examtest_content_list_des')).filter(node => node.classList.contains(CHECKED)).map(node => node.querySelector('.examtest_content_list_des_optionsort').textContent.trim()));
+ const render = index => {
+ const selected = new Set(model.userAnswer[index]);
+ Array.from(boxes[index].querySelectorAll('.pc_examtest_content_list_des')).forEach((node, optionIndex) => node.classList.toggle(CHECKED, selected.has(model.questions[index].options[optionIndex].label)));
+ if (cards[index]) cards[index].classList.toggle('examtestcard_count_assignment', selected.size > 0);
+ const count = model.userAnswer.filter(answer => answer.length > 0).length;
+ const counters = root.querySelectorAll('.pc_examtest_right_col h4');
+ if (counters[0]) counters[0].textContent = String(count);
+ if (counters[1]) counters[1].textContent = String(boxes.length - count);
+ };
+ boxes.forEach((box, index) => {
+ const question = model.questions[index];
+ Array.from(box.querySelectorAll('.pc_examtest_content_list_des')).forEach((node, optionIndex) => {
+ node.addEventListener('click', () => {
+ const option = question.options[optionIndex];
+ model.clicks.push({ number: question.number, label: option.label, text: option.text });
+ if (question.type === '多选') {
+ const next = new Set(model.userAnswer[index]);
+ if (next.has(option.label)) next.delete(option.label); else next.add(option.label);
+ model.userAnswer[index] = Array.from(next);
+ } else model.userAnswer[index] = [option.label];
+ render(index);
+ });
+ });
+ render(index);
+ });
+ const commit = root.querySelector('.pc_examtest_commit');
+ if (commit) commit.addEventListener('click', () => {
+ model.commitClicks++;
+ confirmation.classList.add('show'); confirmation.setAttribute('aria-hidden', 'false');
+ });
+ window.examModel = model;
+}
+
+(async () => {
+ const startedAt = Date.now();
+ const memory = new Map();
+ const errors = [], externalRequests = [], downloads = [];
+ let browser, server;
+ try {
+ browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
+ const context = await browser.newContext({ viewport: { width: 1366, height: 900 }, acceptDownloads: true });
+ await context.exposeBinding('__gmRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
+ await context.exposeBinding('__gmWrite', (_, key, value) => { memory.set(key, structuredClone(value)); });
+ const sandboxScript = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);const GM_setValue=(key,value)=>window.__gmWrite(key,value);\n' + script + '\n})();';
+ let origin = '';
+ await context.route('**/*', route => {
+ const url = route.request().url();
+ if (url.startsWith(origin + '/') && origin || url.startsWith(pathToFileURL(qaDir).href + '/')) return route.continue();
+ externalRequests.push(url); return route.abort();
+ });
+ const capture = page => {
+ page.on('pageerror', error => errors.push(error.message));
+ page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
+ page.on('download', download => downloads.push(download.suggestedFilename()));
+ };
+ const prepare = await context.newPage(); capture(prepare);
+ await prepare.addScriptTag({ content: coreScript });
+ let fixture;
+ if (realSample) {
+ fixture = await prepare.evaluate(raw => {
+ const inert = QuestionBankCore.parseHTML(raw);
+ const root = inert.querySelector('app-examtest');
+ if (!root) throw new Error('真实考试样本缺少 app-examtest');
+ const bank = QuestionBankCore.extract(inert);
+ if (root.querySelector('script,[src],[srcset],iframe[src],link[href]')) throw new Error('样本中存在未中和的可加载资源');
+ return { html: root.innerHTML, title: bank.title, count: bank.total };
+ }, fs.readFileSync(samplePath, 'utf8'));
+ assert.equal(fixture.count, 40, '真实考试应有 40 题');
+ } else fixture = syntheticFixture();
+ await prepare.close();
+ const pageHTML = documentHTML(fixture);
+ server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); res.end(pageHTML); });
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ origin = 'http://127.0.0.1:' + server.address().port;
+ const examURL = origin + '/unfixed/exam-path?flag=test&title=' + encodeURIComponent(fixture.title);
+ const page = await context.newPage(); capture(page);
+ async function open(page, url) {
+ await page.goto(url);
+ await page.addScriptTag({ content: sandboxScript });
+ await page.evaluate(installExamModel);
+ await page.evaluate(() => YianAssistant.ready);
+ }
+ await open(page, examURL);
+ assert.equal(await page.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
+ assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
+ assert.equal(await page.locator('#yian-question-assistant .section-title').filter({ hasText: '当前考试' }).count(), 1);
+ const extracted = await page.evaluate(() => YianAssistant.collectCurrent().questions);
+ assert.equal(extracted.length, 40);
+ extracted.forEach(question => {
+ assert(!/[((]\s*\d+(?:\.\d+)?\s*分\s*[))]/.test(question.question), '题干应去除分值');
+ assert(!/(?:难度低|难度中|难度高)/.test(question.question), '题干应去除难度');
+ });
+ const bankFiles = realSample ? bankPaths : { name: '三岗位考试题库.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(fixture.records)) };
+ await page.locator('#yian-question-assistant input[type=file]').setInputFiles(bankFiles);
+ await page.waitForFunction(() => YianAssistant.getSnapshot().counts.主操 > 0 && YianAssistant.getSnapshot().counts.班长 > 0 && YianAssistant.getSnapshot().counts.副操 > 0);
+ console.log('PASS ' + (realSample ? '真实保存考试 DOM 与仓库三岗位 CSV 导入' : '可独立运行的 40 题 / 三岗位内部 fixture') + '、考试识别、默认关闭、分值与难度去除');
+
+ const pages = [page, await context.newPage(), await context.newPage()];
+ pages.slice(1).forEach(capture);
+ await Promise.all(pages.map(async (target, index) => {
+ if (index) await open(target, examURL + '&rolecase=' + index);
+ await target.getByRole('radio', { name: roles[index], exact: true }).check();
+ await target.waitForFunction(() => YianAssistant.getContext().ready);
+ const expectations = await target.evaluate(() => {
+ const context = YianAssistant.getContext(), snapshot = YianAssistant.getSnapshot();
+ return YianAssistant.collectCurrent().questions.map(question => {
+ const matched = YianAnswerMatcher.match(question, context, snapshot);
+ return { number: question.number, type: question.type, status: matched.status, reason: matched.reason,
+ originalAnswer: matched.records[0] && matched.records[0].answer,
+ bankTexts: matched.records[0] && matched.records[0].correctTexts,
+ currentLabels: matched.correctLabels, evidenceTexts: matched.correctTexts };
+ });
+ });
+ assert.equal(expectations.filter(item => item.status === 'matched').length, 40, roles[index] + '应匹配 40 / 40;' + JSON.stringify(expectations.filter(item => item.status !== 'matched')));
+ assert.equal(expectations.filter(item => item.currentLabels.join('') !== item.originalAnswer).length, 20, '应有 20 题需要按全文重新映射字母');
+ target.__expectations = expectations;
+ await target.getByRole('switch', { name: '自动选择答案' }).check();
+ await target.waitForFunction(() => YianAssistant.getState().metrics.selected === 40, undefined, { timeout: 35000 });
+ const actual = await target.evaluate(() => {
+ const bank = YianAssistant.collectCurrent();
+ const rows = Array.from(document.querySelectorAll('.pc_examtest_content'));
+ return { state: YianAssistant.getState(), commitClicks: examModel.commitClicks,
+ cards: document.querySelectorAll('.examtestcard_count_assignment').length,
+ selectedTexts: rows.map((box, index) => Array.from(box.querySelectorAll('.pc_examtest_content_list_des')).flatMap((node, i) => node.classList.contains('examtest_content_list_des_checked') ? [bank.questions[index].options[i].text] : [])),
+ modelTexts: examModel.questions.map((question, index) => question.options.filter(option => examModel.userAnswer[index].includes(option.label)).map(option => option.text)),
+ evidence: Array.from(document.querySelector('#yian-question-assistant').shadowRoot.querySelectorAll('.answer-check-item')).map(node => node.textContent) };
+ });
+ assert.equal(actual.state.metrics.pending, 0, '考试多选不应显示待练习确认');
+ assert.equal(actual.state.metrics.failed + actual.state.metrics.locked + actual.state.metrics.missing + actual.state.metrics.conflict + actual.state.metrics.unsupported, 0);
+ assert.equal(actual.commitClicks, 0, '脚本不得点击交卷');
+ assert.equal(actual.cards, 40, '独立模型应更新全部答题卡');
+ assert.equal(actual.evidence.length, 40);
+ expectations.forEach((expected, qIndex) => {
+ assert.deepEqual(sortedTexts(actual.selectedTexts[qIndex]), sortedTexts(expected.bankTexts), roles[index] + '第 ' + expected.number + ' 题 DOM 应等于题库全文');
+ assert.deepEqual(sortedTexts(actual.modelTexts[qIndex]), sortedTexts(expected.bankTexts), roles[index] + '第 ' + expected.number + ' 题独立作答模型应等于题库全文');
+ expected.evidenceTexts.forEach((text, optionIndex) => assert(normalize(actual.evidence[qIndex]).includes(normalize(expected.currentLabels[optionIndex] + '. ' + text)), '核对面板应显示原库全文与对应当前字母'));
+ });
+ await target.getByRole('switch', { name: '自动选择答案' }).uncheck();
+ console.log('PASS ' + roles[index] + ':40 / 40 已选择,20 题字母变化;所有 DOM、独立模型及核对面板均与原题库正确答案全文一致');
+ }));
+ await Promise.all(pages.slice(1).map(target => target.close()));
+ const evidenceDetails = page.locator('#yian-question-assistant .answer-check');
+ await evidenceDetails.locator('summary').click();
+ await page.locator('#yian-question-assistant .answer-check-item').first().evaluate(node => { node.dataset.qaPersistent = '1'; });
+ await page.waitForTimeout(650);
+ assert.equal(await evidenceDetails.evaluate(node => node.open), true);
+ assert.equal(await page.locator('#yian-question-assistant .answer-check-item').first().getAttribute('data-qa-persistent'), '1', '刷新不应重建核对节点');
+ await page.screenshot({ path: path.join(qaDir, 'exam-desktop.png') });
+ await page.locator('#yian-question-assistant').screenshot({ path: path.join(qaDir, 'exam-answer-panel.png') });
+ await page.setViewportSize({ width: 390, height: 844 });
+ assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), '小屏页面不得横向溢出');
+ await page.screenshot({ path: path.join(qaDir, 'exam-mobile.png') });
+ await page.setViewportSize({ width: 1366, height: 900 });
+ await page.evaluate(() => YianAssistant.setExportEnabled(true));
+ assert.equal(await page.getByRole('button', { name: '导出 CSV', exact: true }).isDisabled(), true);
+ assert.equal(await page.evaluate(() => { try { YianAssistant.exportCurrent('csv'); return ''; } catch (error) { return error.message; } }), '请先进入支持的练习页面');
+ await page.evaluate(() => YianAssistant.setExportEnabled(false));
+ console.log('PASS 答案核对展开状态 / 节点复用、桌面与小屏截图、考试禁用导出');
+
+ let sequence = 0;
+ async function reset(mode = 'test', mutate, waitReady = true) {
+ await page.evaluate(({ html, mode, title, sequence }) => {
+ YianAssistant.setAutoAnswer(false);
+ const root = document.createElement('app-examtest'); root.innerHTML = html;
+ document.querySelector('app-examtest').replaceWith(root);
+ history.replaceState({}, '', '/unfixed/case-' + sequence + '?flag=' + mode + '&title=' + encodeURIComponent(title));
+ if (mode === 'exercise') {
+ root.querySelector('.pc_examtest_commit').textContent = '重新练习';
+ root.querySelectorAll('.pc_examtest_content_question p').forEach(p => { const button = document.createElement('span'); button.className = 'pc_examtest_content_question_showanswer'; button.textContent = '查看答案'; p.appendChild(button); });
+ }
+ }, { html: fixture.html, mode, title: fixture.title, sequence: ++sequence });
+ if (mutate) await page.evaluate(mutate);
+ await page.evaluate(installExamModel);
+ if (waitReady) await page.waitForFunction(() => YianAssistant.getContext().ready);
+ else await page.waitForTimeout(1600);
+ }
+ await reset();
+ await page.getByRole('switch', { name: '自动选择答案' }).check();
+ await page.waitForFunction(() => examModel.clicks.length > 0);
+ await page.evaluate(() => YianAssistant.setAutoAnswer(false));
+ const stoppedAt = await page.evaluate(() => examModel.clicks.length);
+ await page.waitForTimeout(400);
+ assert.equal(await page.evaluate(() => examModel.clicks.length), stoppedAt);
+ assert(stoppedAt < 40, '应在后续题目之前关闭');
+ // 考试的单选可以改选:关闭后手动改第一题,再开启应重新核对已答题。
+ const wrongIndex = await page.evaluate(() => {
+ const q = YianAssistant.collectCurrent().questions[0];
+ const matched = YianAnswerMatcher.match(q, YianAssistant.getContext(), YianAssistant.getSnapshot());
+ return q.options.findIndex(option => !matched.correctLabels.includes(option.label));
+ });
+ await page.locator('.pc_examtest_content').first().locator('.pc_examtest_content_list_des').nth(wrongIndex).click();
+ await page.getByRole('switch', { name: '自动选择答案' }).check();
+ await page.waitForFunction(() => {
+ const q = YianAssistant.collectCurrent().questions[0];
+ const matched = YianAnswerMatcher.match(q, YianAssistant.getContext(), YianAssistant.getSnapshot());
+ return examModel.userAnswer[0].length === matched.correctLabels.length && matched.correctLabels.every(label => examModel.userAnswer[0].includes(label));
+ });
+ await page.evaluate(() => YianAssistant.setAutoAnswer(false));
+ console.log('PASS 关闭立即停止后续点击,恢复后重新核对人工改过的单选');
+
+ await reset();
+ await page.getByRole('switch', { name: '自动选择答案' }).check();
+ await page.waitForFunction(() => examModel.clicks.length > 0);
+ await page.getByRole('button', { name: '收起题库助手' }).click();
+ await page.locator('.pc_examtest_commit').click();
+ const modalClicks = await page.evaluate(() => examModel.clicks.length);
+ await page.waitForFunction(() => YianAssistant.getState().autoAnswer === false);
+ await page.waitForTimeout(250);
+ assert.equal(await page.evaluate(() => examModel.clicks.length), modalClicks, '交卷确认打开后不得继续选择');
+ assert.equal(await page.evaluate(() => examModel.commitClicks), 1, '只有测试模拟的用户点击了一次交卷');
+ assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
+ console.log('PASS 用户打开交卷确认后停止,确认期间拒绝重新开启');
+
+ await reset('test', () => { const blocker = document.createElement('div'); blocker.id = 'examtestDisabled'; document.querySelector('app-examtest').appendChild(blocker); });
+ assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
+ assert.equal(await page.evaluate(() => examModel.clicks.length), 0);
+ await reset('test', () => { const blocker = document.createElement('div'); blocker.setAttribute('beingsubmitted', ''); blocker.textContent = '正在提交'; document.querySelector('app-examtest').appendChild(blocker); });
+ assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
+ assert.equal(await page.evaluate(() => examModel.clicks.length), 0);
+ await reset();
+ await page.getByRole('switch', { name: '自动选择答案' }).check();
+ await page.waitForFunction(() => examModel.clicks.length > 0);
+ await page.evaluate(() => { document.querySelector('.pc_examtest_countdown .time').textContent = '00:00:00'; });
+ const expiredAt = await page.evaluate(() => examModel.clicks.length);
+ await page.waitForFunction(() => YianAssistant.getState().autoAnswer === false);
+ assert.equal(await page.evaluate(() => examModel.clicks.length), expiredAt);
+ assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
+ console.log('PASS 提交 disabled 状态拒绝开启,倒计时 00 停止');
+
+ await reset('unknown');
+ assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
+ await reset('test', () => { document.querySelector('.pc_examtest_left').className = 'mobile_examtest_left'; });
+ assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
+ await reset('test', () => { document.querySelectorAll('.examtestcard_count_defult').forEach(node => node.remove()); }, false);
+ assert.equal(await page.evaluate(() => YianAssistant.getState().pageReady), false, '空答题卡不能确认完整试卷');
+ assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
+ const filePath = path.join(qaDir, 'saved-exam.html');
+ fs.writeFileSync(filePath, '\n' + pageHTML);
+ const saved = await context.newPage(); capture(saved);
+ await open(saved, pathToFileURL(filePath).href);
+ await saved.waitForFunction(() => YianAssistant.getState().pageReady);
+ assert.equal(await saved.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
+ assert.equal(await saved.evaluate(() => examModel.clicks.length), 0);
+ await saved.close();
+ await reset('exercise');
+ const navigation = await page.evaluate(({ html, title }) => {
+ const enabled = YianAssistant.setAutoAnswer(true);
+ const root = document.createElement('app-examtest'); root.innerHTML = html;
+ document.querySelector('app-examtest').replaceWith(root);
+ history.pushState({}, '', '/unfixed/final-exam?flag=test&title=' + encodeURIComponent(title));
+ dispatchEvent(new PopStateEvent('popstate'));
+ return { enabled, afterNavigation: YianAssistant.getState().autoAnswer };
+ }, { html: fixture.html, title: fixture.title });
+ assert.equal(navigation.enabled, true);
+ assert.equal(navigation.afterNavigation, false);
+ await page.waitForTimeout(250);
+ assert.equal(await page.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
+ console.log('PASS 未知 flag、移动页面、file 保存考试拒绝自动选择;练习转考试恢复关闭');
+
+ assert.deepEqual(externalRequests, [], '测试不允许任何外部业务请求');
+ assert.deepEqual(downloads, [], '考试不得自动或手动发起题库下载');
+ assert.deepEqual(errors, [], '页面和控制台不应出现错误');
+ console.log('PASS 零外部请求、零交卷接口调用、零导出下载、无页面 / 控制台错误');
+ console.log('截图:' + qaDir);
+ console.log('总耗时:' + ((Date.now() - startedAt) / 1000).toFixed(1) + ' 秒');
+ } finally {
+ if (browser) await browser.close();
+ if (server) await new Promise(resolve => server.close(resolve));
+ }
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/tests/run.cjs b/tests/run.cjs
index 025cfd8..bab6bf2 100644
--- a/tests/run.cjs
+++ b/tests/run.cjs
@@ -1,6 +1,6 @@
const { spawnSync } = require('node:child_process');
const path = require('node:path');
-for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs']) {
+for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs', 'exam-assistant.cjs']) {
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
if (result.status !== 0) process.exit(result.status || 1);
}
diff --git a/tests/runner-browser.cjs b/tests/runner-browser.cjs
index 5560325..9c171da 100644
--- a/tests/runner-browser.cjs
+++ b/tests/runner-browser.cjs
@@ -9,9 +9,9 @@ const workspace = path.resolve(__dirname, '..');
const coreSource = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
const runnerSource = fs.readFileSync(path.join(workspace, 'answer-runner.js'), 'utf8');
-function fixture(items) {
- const option = (question, label) => ``;
- return `测试练习
${items.map((question, index) => `[${question.type}] ${index + 1}. 测试题${index + 1}${question.locked ? '' : '查看答案'}
${['A', 'B', 'C', 'D', 'E'].slice(0, question.type === '判断' ? 2 : 5).map(label => option(question, label)).join('')}
${question.locked ? '
' : ''}
`).join('')}`;
+function fixture(items, exam = false) {
+ const option = (question, label) => `${label}${question.texts && question.texts[label] || '选项' + label}
`;
+ return `测试练习
${items.map((question, index) => `[${question.type}] ${index + 1}. 测试题${index + 1}${question.locked || exam ? '' : '查看答案'}
${['A', 'B', 'C', 'D', 'E'].slice(0, question.type === '判断' ? 2 : 5).map(label => option(question, label)).join('')}
${question.locked ? '
' : ''}${exam ? '' : '
'}
`).join('')}${exam ? '交卷
' : ''}`;
}
async function main() {
@@ -30,8 +30,8 @@ async function main() {
await page.addScriptTag({ content: coreSource });
await page.addScriptTag({ content: runnerSource });
- async function setup(items, mode = 'normal') {
- await page.evaluate(({ html, mode }) => {
+ async function setup(items, mode = 'normal', exam = false) {
+ await page.evaluate(({ html, mode, exam }) => {
window.qaRunner?.destroy();
document.body.innerHTML = html;
window.qaClicks = [];
@@ -39,7 +39,12 @@ async function main() {
window.qaMatchStarted = false;
window.qaReleaseMatch = null;
window.qaMode = mode;
- window.qaContext = { ready: true, practice: true, local: false, role: '主操', course: '测试练习', sourceUrl: location.href, routeKey: 'practice', root: document.querySelector('app-examtest'), pageSignature: 'stable', revision: 1 };
+ window.qaExam = exam;
+ window.qaSubmitted = 0;
+ window.qaMatchCalls = 0;
+ history.replaceState(null, '', '/practice?flag=' + (exam ? 'test' : 'exercise'));
+ window.qaContext = { ready: true, practice: !exam, exam: exam, mode: exam ? 'test' : 'exercise', local: false, role: '主操', course: '测试练习', sourceUrl: location.href, routeKey: 'practice', root: document.querySelector('app-examtest'), pageSignature: 'stable', revision: 1 };
+ window.submitClicked = () => { window.qaSubmitted++; window.qaContext.exam = false; };
// 只在真实 onclick 里更新状态,runner 必须点击选项才会发生选择或锁定。
window.optionClicked = node => {
const box = node.closest('.pc_examtest_content');
@@ -50,20 +55,24 @@ async function main() {
} else {
box.querySelectorAll('.pc_examtest_content_list_des').forEach(option => option.classList.remove('examtest_content_list_des_checked'));
node.classList.add('examtest_content_list_des_checked');
- box.querySelector('.pc_examtest_content_question_showanswer')?.remove();
- const marker = document.createElement('div');
- marker.className = 'hidden pc_examtest_content_form_disabled';
- box.append(marker);
+ if (!window.qaExam) {
+ box.querySelector('.pc_examtest_content_question_showanswer')?.remove();
+ const marker = document.createElement('div');
+ marker.className = 'hidden pc_examtest_content_form_disabled';
+ box.append(marker);
+ }
}
};
window.qaRunner = YianAnswerRunner.create({
getContext: () => window.qaContext,
match: async question => {
window.qaMatchStarted = true;
+ window.qaMatchCalls++;
if (window.qaMode === 'delayed') await new Promise(resolve => { window.qaReleaseMatch = resolve; });
if (window.qaMode === 'title') document.querySelector('.pc_examtest_title').textContent = '新练习';
- const labels = question.type === '多选' ? ['B', 'E'] : ['B'];
- return { status: 'matched', correctLabels: labels, correctTexts: labels.map(label => '选项' + label) };
+ const labels = question.type === '多选' ? window.qaMode === 'reverse-evidence' ? ['E', 'B'] : ['B', 'E'] : ['B'];
+ const texts = window.qaMode === 'empty-evidence' ? [] : window.qaMode === 'wrong-evidence' ? ['选项B的一部分'] : window.qaMode === 'case-evidence' ? ['5 mPa'] : labels.map(label => '选项' + label);
+ return { status: 'matched', correctLabels: labels, correctTexts: texts, records: [{ course: '题库来源课程' }] };
},
onProgress: counts => {
window.qaProgress = counts;
@@ -71,7 +80,7 @@ async function main() {
},
onState: (state, reason) => window.qaStates.push([state, reason])
});
- }, { html: fixture(items), mode });
+ }, { html: fixture(items, exam), mode, exam });
}
async function delayedStart() {
@@ -165,8 +174,120 @@ async function main() {
assert.equal(await page.evaluate(() => qaClicks.length), 0);
await page.evaluate(() => { qaContext.local = false; qaContext.practice = false; return qaRunner.start(); });
assert.equal(await page.evaluate(() => qaClicks.length), 0);
+ console.log('PASS local/nonpractice pages blocked');
+
+ await setup([{ type: '单选', checked: ['A'] }, { type: '判断', checked: ['A'] }, { type: '多选', checked: ['A', 'E'] }], 'reverse-evidence', true);
+ assert.equal(await page.locator('.pc_examtest_content_question_showanswer').count(), 0);
+ assert.equal(await page.locator('.examtest_content_answer_left').count(), 0);
+ await page.evaluate(() => qaRunner.start());
+ const examResult = await page.evaluate(() => ({ counts: qaProgress, clicks: qaClicks, submitted: qaSubmitted }));
+ assert.equal(examResult.counts.selected, 3);
+ assert.equal(examResult.counts.pending, 0);
+ assert.equal(examResult.counts.locked, 0);
+ assert.deepEqual(examResult.clicks, [['单选', 'B'], ['判断', 'B'], ['多选', 'A'], ['多选', 'B']]);
+ assert.equal(examResult.submitted, 0);
+ const multiEvidence = examResult.counts.details.find(detail => detail.number === 3);
+ assert.equal(multiEvidence.question, '测试题3');
+ assert.equal(multiEvidence.course, '题库来源课程');
+ assert.equal(multiEvidence.pageCourse, '测试练习');
+ assert.deepEqual(multiEvidence.correctTexts, ['选项B', '选项E']);
+ assert.deepEqual(multiEvidence.currentLabels, ['B', 'E']);
+ assert.deepEqual(multiEvidence.currentTexts, ['选项B', '选项E']);
+ console.log('PASS exam corrects single/judgment/multiple answers without analysis controls; evidence aligned');
+
+ await page.evaluate(() => qaRunner.stop('测试暂停后修改已选答案'));
+ await page.locator('[data-kind="单选"] [data-label="A"]').click();
+ await page.locator('[data-kind="多选"] [data-label="B"]').click();
+ await page.locator('[data-kind="多选"] [data-label="C"]').click();
+ const callsBeforeResume = await page.evaluate(() => qaMatchCalls);
+ const clicksBeforeResume = await page.evaluate(() => qaClicks.length);
+ await page.evaluate(() => qaRunner.start());
+ assert.equal(await page.evaluate(() => qaMatchCalls), callsBeforeResume + 3, '已 selected 题必须重新匹配');
+ assert.deepEqual(await page.evaluate(count => qaClicks.slice(count), clicksBeforeResume), [['单选', 'B'], ['多选', 'C'], ['多选', 'B']]);
+ assert.equal(await page.evaluate(() => qaProgress.selected), 3);
+ const correctedClicks = await page.evaluate(() => qaClicks.length);
+ await page.evaluate(() => qaRunner.start());
+ assert.equal(await page.evaluate(() => qaClicks.length), correctedClicks);
+ console.log('PASS exam stop/resume rechecks previously selected questions and corrects manual changes');
+
+ await setup([{ type: '单选' }, { type: '多选' }], 'normal', true);
+ await page.evaluate(() => qaRunner.start());
+ assert.equal(await page.evaluate(() => qaRunner.running), false);
+ assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'complete');
+ await page.locator('[data-kind="单选"] [data-label="A"]').click();
+ const invalidated = await page.evaluate(() => ({ counts: qaProgress, state: qaStates.at(-1) }));
+ assert.equal(invalidated.state[0], 'paused');
+ assert.equal(invalidated.counts.selected, 1);
+ assert.equal(invalidated.counts.unsupported, 1);
+ const invalidEvidence = invalidated.counts.details.find(detail => detail.number === 1);
+ assert.equal(invalidEvidence.status, 'unsupported');
+ assert.equal(invalidEvidence.reason, '手动作答后需重新核对');
+ assert.deepEqual(invalidEvidence.currentLabels, [], '捕获阶段不能继续展示原先的 B 作为当前答案');
+ assert.deepEqual(invalidEvidence.currentTexts, []);
+ assert.deepEqual(invalidEvidence.correctTexts, []);
+ assert.deepEqual(await page.evaluate(() => Array.from(document.querySelectorAll('[data-kind="单选"] .examtest_content_list_des_checked')).map(node => node.dataset.label)), ['A']);
+ // 首次改选已经关闭自动选择,另一道已完成题后续手动改选也不能留下旧证据。
+ await page.locator('[data-kind="多选"] [data-label="A"]').click();
+ assert.equal(await page.evaluate(() => qaProgress.selected), 0);
+ assert.equal(await page.evaluate(() => qaProgress.unsupported), 2);
+ const matchesBeforeRecheck = await page.evaluate(() => qaMatchCalls);
+ const clicksBeforeRecheck = await page.evaluate(() => qaClicks.length);
+ await page.evaluate(() => qaRunner.start());
+ assert.equal(await page.evaluate(() => qaMatchCalls), matchesBeforeRecheck + 2);
+ assert.deepEqual(await page.evaluate(count => qaClicks.slice(count), clicksBeforeRecheck), [['单选', 'B'], ['多选', 'A']]);
+ const rechecked = await page.evaluate(() => qaProgress);
+ assert.equal(rechecked.selected, 2);
+ assert.equal(rechecked.unsupported, 0);
+ assert.deepEqual(rechecked.details.find(detail => detail.number === 1).currentLabels, ['B']);
+ assert.deepEqual(rechecked.details.find(detail => detail.number === 1).correctTexts, ['选项B']);
+
+ await setup([{ type: '单选' }, { type: '判断' }], 'stop-after-first', true);
+ await page.evaluate(() => qaRunner.start());
+ assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'stopped');
+ assert.equal(await page.evaluate(() => qaProgress.selected), 1);
+ await page.locator('[data-kind="单选"] [data-label="A"]').click();
+ assert.equal(await page.evaluate(() => qaProgress.selected), 0);
+ assert.equal(await page.evaluate(() => qaProgress.unsupported), 1);
+ assert.deepEqual(await page.evaluate(() => qaProgress.details[0].currentLabels), []);
+ await page.evaluate(() => { window.qaMode = 'normal'; return qaRunner.start(); });
+ assert.equal(await page.evaluate(() => qaProgress.selected), 2);
+ assert.equal(await page.evaluate(() => qaProgress.unsupported), 0);
+ console.log('PASS manual change after completion or partial stop invalidates evidence; reopening rechecks/corrects it');
+
+ await setup([{ type: '单选', checked: ['A'], locked: true }, { type: '判断' }], 'normal', true);
+ await page.evaluate(() => { document.querySelector('[data-kind="判断"]').setAttribute('aria-disabled', 'true'); return qaRunner.start(); });
+ assert.equal(await page.evaluate(() => qaClicks.length), 0);
+ assert.equal(await page.evaluate(() => qaProgress.locked), 2);
+ for (const field of ['exam', 'mode']) {
+ await setup([{ type: '单选' }], 'delayed', true);
+ await delayedStart();
+ await page.evaluate(field => { qaContext[field] = field === 'exam' ? false : 'submitted'; }, field);
+ await finishMatch();
+ assert.equal(await page.evaluate(() => qaClicks.length), 0, field + ' 变化后必须停止');
+ assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'stopped');
+ }
+ await setup([{ type: '单选' }], 'normal', true);
+ await page.evaluate(() => { qaContext.exam = false; return qaRunner.start(); });
+ assert.equal(await page.evaluate(() => qaClicks.length), 0);
+ console.log('PASS exam disabled questions and submitted/time-expired context prevent clicks');
+
+ for (const mode of ['empty-evidence', 'wrong-evidence', 'case-evidence']) {
+ await setup([{ type: '单选', texts: mode === 'case-evidence' ? { B: '5 MPa' } : undefined }], mode, true);
+ await page.evaluate(() => qaRunner.start());
+ assert.equal(await page.evaluate(() => qaClicks.length), 0, '必须核验题库答案全文:' + mode);
+ assert.equal(await page.evaluate(() => qaProgress.unsupported), 1);
+ }
+ console.log('PASS missing/full-text-mismatched/case-sensitive answer evidence never authorizes a click');
+
+ await setup([{ type: '单选' }], 'delayed', true);
+ await delayedStart();
+ await page.locator('.pc_examtest_commit').click();
+ await finishMatch();
+ assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'paused');
+ assert.equal(await page.evaluate(() => qaSubmitted), 1, '保留真人交卷点击,runner 不交卷');
+ assert.equal(await page.evaluate(() => qaClicks.length), 0);
assert.deepEqual(errors, []);
- console.log('PASS local/nonpractice pages blocked; no browser errors');
+ console.log('PASS trusted manual submit pauses immediately; no browser errors');
} finally {
if (browser) await browser.close();
await new Promise(resolve => server.close(resolve));
diff --git a/tests/verify.cjs b/tests/verify.cjs
index 135518d..2d8f596 100644
--- a/tests/verify.cjs
+++ b/tests/verify.cjs
@@ -150,7 +150,7 @@ function parseCSV(text) {
await page.evaluate(() => history.pushState({}, '', location.href.replace('flag=exercise', 'flag=exam')));
await page.waitForTimeout(1100); assert(await page.getByRole('button', { name: '导出 CSV', exact: true }).isDisabled());
assert.equal(await page.evaluate(() => window.answerClicks), 0);
- console.log('PASS 缺题等待、考试页面不触发、不改变答题状态');
+ console.log('PASS 缺题等待、未知考试模式不触发、不改变答题状态');
await page.getByRole('switch', { name: '导出题库', exact: true }).uncheck();
assert(await page.getByRole('button', { name: '导出 CSV', exact: true, includeHidden: true }).isHidden());
diff --git a/谊安学助手 2.0.user.js b/谊安学助手 2.0.user.js
index 7efefc3..02caa41 100644
--- a/谊安学助手 2.0.user.js
+++ b/谊安学助手 2.0.user.js
@@ -1,8 +1,8 @@
// ==UserScript==
// @name 谊安学助手 2.0
// @namespace yianxue-question-bank
-// @version 2.0.1
-// @description 不绑定网址的题库助手:岗位题库搜索、允许复制、练习自动选择与导出。
+// @version 2.0.2
+// @description 不绑定网址的题库助手:岗位搜索、答案全文核对、练习及桌面考试自动选择。
// @match *://*/*
// @match file:///*
// @run-at document-idle
@@ -92,7 +92,8 @@ window.JSZip = module.exports;
explanation: '.pc_examtest_content_resolve',
card: '.examtestcard_count_defult'
};
- const CONTROL = '.pc_examtest_content_question_showanswer,script,style,button,input,select,textarea,iframe,object,embed,link';
+ // 考试页的分数和难度属于界面标记;只按 DOM class 去除,保留题干中的括号和数字。
+ const CONTROL = '.pc_examtest_content_question_showanswer,.pc_examtest_content_question_value,.pc_examtest_content_question_difficulty,script,style,button,input,select,textarea,iframe,object,embed,link';
const TYPE_RE = /^\s*[\[【](单选题?|多选题?|判断题?|填空题?|简答题?|问答题?)[\]】]\s*/;
const NUMBER_RE = /^\s*(\d+)\s*[..、]\s*/;
@@ -382,9 +383,11 @@ window.JSZip = module.exports;
let importQueue = Promise.resolve();
function normalize(value) {
- return String(value == null ? '' : value).normalize('NFKC')
- .replace(/[−﹣]/g, '-').replace(/\u00a0/g, ' ')
- .replace(/[\s\u200b\uFEFF]+/g, ' ').trim().toLowerCase();
+ // Answer identity must retain case, unit spelling, superscripts and punctuation.
+ // Only presentation whitespace and invisible layout characters are cleaned.
+ return String(value == null ? '' : value)
+ .replace(/[\u200b-\u200d\u2060\uFEFF]/g, '')
+ .replace(/\s+/g, ' ').trim();
}
function normalizeType(value) {
@@ -481,7 +484,7 @@ window.JSZip = module.exports;
}
function boolValue(value) {
- const text = normalize(value);
+ const text = normalize(value).toLowerCase();
if (/^(正确|对|是|true|√|✓)$/.test(text)) return 'true';
if (/^(错误|错|否|false|×|✗)$/.test(text)) return 'false';
return '';
@@ -736,11 +739,19 @@ window.JSZip = module.exports;
const cache = new WeakMap();
function normalize(value) {
- return String(value == null ? '' : value).normalize('NFKC')
- .replace(/[−﹣]/g, '-').replace(/\u00a0/g, ' ')
- .replace(/[\s\u200b\uFEFF]+/g, ' ').trim().toLowerCase();
+ // Strict answer identity: MPa/mPa, m²/m2 and Ⅰ/I are distinct.
+ return String(value == null ? '' : value)
+ .replace(/[\u200b-\u200d\u2060\uFEFF]/g, '')
+ .replace(/\s+/g, ' ').trim();
}
+ // Search is intentionally more tolerant; these keys never authorize clicks.
+ function searchNormalize(value) {
+ return normalize(value).normalize('NFKC').replace(/[−﹣]/g, '-').toLowerCase();
+ }
+
+ function courseNormalize(value) { return searchNormalize(value); }
+
function normalizeType(value) {
const type = String(value || '').trim().replace(/^[\[【]|[\]】]$/g, '').replace(/题$/, '');
return ({ 单选: '单选', 单项选择: '单选', single: '单选', radio: '单选', 多选: '多选', 多项选择: '多选', multiple: '多选', checkbox: '多选', 判断: '判断', 判断正误: '判断', boolean: '判断' })[type.toLowerCase()] || type;
@@ -808,9 +819,9 @@ window.JSZip = module.exports;
const exactSource = records.filter(record => record.sourceUrl && sourceIdentity(record.sourceUrl) === identity);
if (exactSource.length) { records = exactSource; sourceMatched = true; }
}
- const course = normalize(context.course);
+ const course = courseNormalize(context.course);
if (course) {
- const exactCourse = records.filter(record => normalize(record.course) === course);
+ const exactCourse = records.filter(record => courseNormalize(record.course) === course);
if (exactCourse.length) { records = exactCourse; sourceMatched = true; }
}
const answers = new Set(records.map(correctKey));
@@ -820,7 +831,9 @@ window.JSZip = module.exports;
if (!wantedKeys.length || new Set(wantedKeys).size !== wantedKeys.length || wantedKeys.some(text => !currentTexts.includes(text))) return result('unsupported', '题库参考答案无法唯一对应当前选项', records);
const selected = question.options.filter(option => wantedKeys.includes(normalize(option.text)));
if (normalizeType(question.type) !== '多选' && selected.length !== 1) return result('unsupported', '单选/判断题的参考答案数量不正确', records);
- return result('matched', sourceMatched ? '岗位、课程、题干及选项完全匹配' : '岗位、题干及选项完全匹配,所有来源答案一致', records, selected.map(option => normalizedLabel(option.label)), selected.map(option => option.text));
+ // Keep label/text pairs in current-page order, but obtain each text from bank evidence.
+ const evidenceTexts = selected.map(option => wanted.find(text => normalize(text) === normalize(option.text)));
+ return result('matched', sourceMatched ? '岗位、课程、题干及选项完全匹配' : '岗位、题干及选项完全匹配,所有来源答案一致', records, selected.map(option => normalizedLabel(option.label)), evidenceTexts);
}
function search(query, settings, snapshot) {
@@ -828,14 +841,14 @@ window.JSZip = module.exports;
const role = settings.role;
const empty = reason => ({ items: [], total: 0, reason, role: role || '', query: String(query || ''), hasMore: false });
if (!ROLES.includes(role)) return empty('请先选择主操、班长或副操');
- const fullQuery = normalize(query);
+ const fullQuery = searchNormalize(query);
if (!fullQuery) return empty('请输入题干、关键词或选项文字');
const tokens = fullQuery.split(/\s+/).filter(Boolean);
- const course = normalize(settings.course);
+ const course = courseNormalize(settings.course);
const records = snapshot && Array.isArray(snapshot.records) ? snapshot.records : [];
const groups = new Map(), conflicts = new Map();
records.forEach(record => {
- if (record.role !== role || (course && normalize(record.course) !== course)) return;
+ if (record.role !== role || (course && courseNormalize(record.course) !== course)) return;
const baseKey = groupKey(record), answerKey = correctKey(record), key = baseKey + '\n' + answerKey;
if (!conflicts.has(baseKey)) conflicts.set(baseKey, new Set());
conflicts.get(baseKey).add(answerKey);
@@ -847,9 +860,9 @@ window.JSZip = module.exports;
});
const items = [];
groups.forEach(group => {
- const stem = normalize(group.question);
- const optionText = (group.options || []).map(option => normalize(option.text)).join('\n');
- const answerText = (group.correctTexts || []).map(normalize).join('\n');
+ const stem = searchNormalize(group.question);
+ const optionText = (group.options || []).map(option => searchNormalize(option.text)).join('\n');
+ const answerText = (group.correctTexts || []).map(searchNormalize).join('\n');
const haystack = stem + '\n' + optionText + '\n' + answerText;
if (!tokens.every(token => haystack.includes(token))) return;
let score = stem === fullQuery ? 10000 : stem.includes(fullQuery) ? 5000 : 0;
@@ -865,7 +878,7 @@ window.JSZip = module.exports;
return { items: items.slice(offset, offset + limit), total, role, query: String(query), reason: total ? '' : '当前岗位未找到符合条件的题目', hasMore: offset + limit < total };
}
- const api = Object.freeze({ version: '2.0.0', normalize, normalizeType, optionKey, questionKey, sourceIdentity, match, search });
+ const api = Object.freeze({ version: '2.0.0', normalize, searchNormalize, normalizeType, optionKey, questionKey, sourceIdentity, match, search });
root.YianAnswerMatcher = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
@@ -1069,7 +1082,7 @@ window.JSZip = module.exports;
const doc = global.document;
const state = {
autoAnswer: false, allowCopy: true, role: '', course: '', courses: [],
- bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别练习页面',
+ bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false
};
let destroyed = false;
@@ -1087,6 +1100,7 @@ window.JSZip = module.exports;
*,*::before,*::after{box-sizing:border-box}button,input,select{font:inherit}button,input,select,label{-webkit-user-select:none;user-select:none}button{cursor:pointer}button:disabled,input:disabled,select:disabled{cursor:not-allowed;opacity:.5}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #b8d1ff;outline-offset:2px}button{border:1px solid var(--line);background:white;color:#273953;border-radius:7px;padding:6px 10px}button:hover:enabled{background:#f2f6ff;border-color:#b6caf1}input[type=text],select{width:100%;min-width:0;border:1px solid #cfd8e7;border-radius:7px;background:#fff;color:#17253d;padding:8px}select{padding:6px 8px}input[type=checkbox],input[type=radio]{accent-color:var(--accent)}
.panel{width:354px;max-width:calc(100vw - 24px);background:white;border:1px solid #dce3ee;border-radius:13px;box-shadow:0 8px 36px #14264528;overflow:hidden}.header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:#172a49;color:white}.brand{font-size:15px;font-weight:700;letter-spacing:.2px}.toggle{border:0;background:#ffffff17;color:white;padding:3px 9px;border-radius:5px;min-width:30px}.toggle:hover:enabled{background:#ffffff2c;color:white;border-color:transparent}.body{padding:13px;max-height:calc(100vh - 102px);max-height:calc(100dvh - 102px);overflow:auto;overscroll-behavior:contain}.body[hidden]{display:none}.panel.collapsed{width:auto;min-width:140px}.collapsed .header{padding:10px 12px;gap:16px}.switches{display:grid;gap:9px;padding-bottom:12px;border-bottom:1px solid var(--line)}.switch-row{display:flex;align-items:center;justify-content:space-between;position:relative;gap:12px;cursor:pointer;font-weight:600}.switch{appearance:none;-webkit-appearance:none;width:35px;height:21px;margin:0;border:0!important;border-radius:20px;background:#b7c0ce;position:relative;transition:background .15s}.switch::after{content:"";position:absolute;width:15px;height:15px;top:3px;left:3px;border-radius:50%;background:white;box-shadow:0 1px 3px #0002;transition:transform .15s}.switch:checked{background:var(--accent)}.switch:checked::after{transform:translateX(14px)}.hint{font-size:12px;color:var(--muted);margin:6px 0 0;overflow-wrap:anywhere}.section{padding-top:12px}.section-title{font-size:13px;font-weight:700;margin:0 0 7px}.import-row{display:flex;align-items:center;gap:10px}.import-row button{white-space:nowrap}.bank-info{font-size:12px;color:var(--muted);overflow-wrap:anywhere}.storage{color:#97702e;margin:4px 0 0;font-size:11px}.roles{display:flex;gap:6px;flex-wrap:wrap}.role{display:flex;align-items:center;gap:4px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;cursor:pointer;background:white;flex:1;justify-content:center;white-space:nowrap}.role.active{background:#edf3ff;border-color:#abc5fa;color:#194dab}.role input{margin:0}.role-count{font-size:10px;color:var(--muted)}.course-row{display:grid;grid-template-columns:36px 1fr;gap:7px;align-items:center;margin-top:8px}.search-row{display:flex;gap:6px;margin-top:9px}.search-row input{flex:1}.primary{background:var(--accent);border-color:var(--accent);color:white;white-space:nowrap}.primary:hover:enabled{background:#184baf;color:white;border-color:#184baf}.selection-search{margin-top:7px;font-size:12px;width:100%}.notice{padding:8px 9px;border-radius:7px;margin:10px 0 0;font-size:12px;overflow-wrap:anywhere;background:#edf3ff;color:#254e8b}.notice.warning{background:#fff5df;color:#8a5d11}.notice.error{background:#fff0ed;color:#a23a2c}.notice.success{background:#eaf7f0;color:#1e754c}.notice[hidden]{display:none}.results-title{font-size:12px;color:var(--muted);margin:11px 0 7px}.empty{background:var(--surface);border-radius:8px;padding:13px 10px;color:var(--muted);font-size:12px}.result{border:1px solid var(--line);border-radius:9px;padding:10px;margin-bottom:9px;-webkit-user-select:text;user-select:text}.question{font-weight:600;line-height:1.6;overflow-wrap:anywhere;margin:0 0 7px;white-space:pre-wrap}.type{display:inline-block;font-size:10px;font-weight:500;color:#556984;background:#eff3f8;padding:1px 5px;border-radius:4px;margin-right:5px;vertical-align:1px}.answer{font-weight:600;color:#1b714a;background:#edf8f1;border-radius:5px;padding:6px 7px;overflow-wrap:anywhere}.options{padding:0;margin:7px 0;list-style:none;font-size:12px;display:grid;gap:4px}.option{padding:3px 6px;border-radius:4px;overflow-wrap:anywhere;white-space:pre-wrap}.option.correct{color:#196442;background:#edf8f1;font-weight:600}.sources{font-size:11px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere;margin-top:5px}.conflict{color:#9d6012;background:#fff5df;border-radius:5px;font-size:11px;padding:6px;margin:6px 0}.copy-result{margin-top:8px;font-size:11px}.pagination{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted);margin:5px 0 0}.pagination button{font-size:11px;padding:4px 8px}.page-section{border-top:1px solid var(--line);margin-top:13px;padding-top:11px}.page-status{font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted)}.metrics{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11px;color:var(--muted);margin-top:6px}.unprocessed{font-size:11px;color:#8b621c;margin-top:7px}.unprocessed summary{cursor:pointer}.unprocessed-list{max-height:155px;overflow:auto;padding:7px;border-radius:6px;background:#fff7e8;margin-top:4px;display:grid;gap:5px;overflow-wrap:anywhere}.exports{display:flex;gap:6px;margin-top:9px}.exports button{flex:1;font-size:12px}.auto-export{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);margin-top:8px}.auto-export input{margin:0}.busy{color:#83692c}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){.body{padding:11px}.panel{width:350px}.role{padding:6px 5px}}
`;
+ style.textContent += '.answer-check{font-size:11px;color:#235c43;margin-top:7px}.answer-check summary{cursor:pointer}.answer-check-list{max-height:210px;overflow:auto;overscroll-behavior:contain;margin-top:5px;display:grid;gap:7px}.answer-check-item{border:1px solid #d9eade;border-radius:6px;background:#f0f8f3;padding:8px;overflow-wrap:anywhere;-webkit-user-select:text;user-select:text}.answer-check-question{font-weight:600;margin:0 0 4px;white-space:pre-wrap}.answer-check-course{color:var(--muted);margin:0 0 5px}.answer-check-label{font-weight:600;margin:0 0 3px}.answer-check-options{margin:0;padding:0;list-style:none;display:grid;gap:4px;white-space:pre-wrap}.answer-check-note{font-size:10px;color:var(--muted);margin:5px 0 0}';
root.appendChild(style);
const visibilityStyle = doc.createElement('style');
visibilityStyle.textContent = '[hidden]{display:none!important}';
@@ -1268,15 +1282,21 @@ window.JSZip = module.exports;
});
const pageSection = el(doc, 'div', 'page-section');
- pageSection.appendChild(el(doc, 'h2', 'section-title', '当前练习'));
+ const pageTitle = el(doc, 'h2', 'section-title', '当前页面');
+ pageSection.appendChild(pageTitle);
const pageStatus = el(doc, 'div', 'page-status');
const metrics = el(doc, 'div', 'metrics');
+ const answerCheck = el(doc, 'details', 'answer-check');
+ const answerCheckSummary = el(doc, 'summary', '', '查看答案核对');
+ const answerCheckList = el(doc, 'div', 'answer-check-list');
+ answerCheck.append(answerCheckSummary, answerCheckList);
+ answerCheck.hidden = true;
const unprocessed = el(doc, 'details', 'unprocessed');
const unprocessedSummary = el(doc, 'summary', '', '查看未处理题目');
const unprocessedList = el(doc, 'div', 'unprocessed-list');
unprocessed.append(unprocessedSummary, unprocessedList);
unprocessed.hidden = true;
- pageSection.append(pageStatus, metrics, unprocessed);
+ pageSection.append(pageStatus, metrics, answerCheck, unprocessed);
body.appendChild(pageSection);
const exportSection = el(doc, 'section', 'page-section export-section');
@@ -1290,7 +1310,7 @@ window.JSZip = module.exports;
exportContent.id = 'yian-export-content';
exportContent.hidden = true;
exportSwitch.input.setAttribute('aria-controls', exportContent.id);
- exportContent.appendChild(el(doc, 'p', 'hint', '开启后,题目和答案完整时自动导出 CSV;也可点击下方按钮重新导出。关闭后停止后续导出。'));
+ exportContent.appendChild(el(doc, 'p', 'hint', '仅在练习页面导出。开启后,练习题目和答案完整时自动导出 CSV;也可点击下方按钮重新导出。关闭后停止后续导出。'));
const exportRow = el(doc, 'div', 'exports');
const exportButtons = [];
['csv', 'json', 'txt'].forEach(function (format) {
@@ -1309,7 +1329,7 @@ window.JSZip = module.exports;
function autoUnavailableReason() {
if (!state.role) return '请选择岗位后再开启自动选择。';
if (!(Number(state.roleCounts && state.roleCounts[state.role]) > 0)) return '请导入包含' + state.role + '题目的题库。';
- if (!state.canAnswer) return state.pageStatus || '当前页面未识别到支持的练习题目。';
+ if (!state.canAnswer) return state.pageStatus || '当前页面未识别到支持的练习或考试题目。';
return '';
}
autoSwitch.label.addEventListener('pointerdown', function () {
@@ -1317,6 +1337,81 @@ window.JSZip = module.exports;
if (!state.autoAnswer && reason) setNotice(reason, 'warning');
});
+ const metricLabels = { selected: '已选择', pending: '待确认', missing: '未匹配', conflict: '冲突', locked: '已锁定', unsupported: '需人工核对', failed: '失败', skipped: '已跳过' };
+ const metricNodes = new Map();
+ const answerCheckNodes = new Map();
+ const unprocessedNodes = new Map();
+
+ function setText(node, value) {
+ value = plain(value);
+ if (node.textContent !== value) node.textContent = value;
+ }
+ // 每次进度更新复用相同题目的节点,保留列表滚动与 details 的展开状态。
+ function syncRows(container, entries, nodeCache, makeRow, updateRow) {
+ const present = new Set();
+ let previousNode = null;
+ entries.forEach(function (entry, index) {
+ const key = index + '\u0000' + plain(entry.number) + '\u0000' + plain(entry.question);
+ present.add(key);
+ let saved = nodeCache.get(key);
+ if (!saved) { saved = { node: makeRow(), signature: '' }; nodeCache.set(key, saved); }
+ const signature = JSON.stringify([entry.number, entry.status, entry.reason, entry.question, entry.course, entry.correctTexts, entry.currentLabels]);
+ if (signature !== saved.signature) { updateRow(saved.node, entry); saved.signature = signature; }
+ const expectedNode = previousNode ? previousNode.nextSibling : container.firstChild;
+ if (expectedNode !== saved.node) container.insertBefore(saved.node, expectedNode);
+ previousNode = saved.node;
+ });
+ nodeCache.forEach(function (saved, key) {
+ if (!present.has(key)) { saved.node.remove(); nodeCache.delete(key); }
+ });
+ }
+ function refreshMetrics() {
+ const progress = state.metrics || {};
+ Object.keys(metricLabels).forEach(function (key) {
+ let node = metricNodes.get(key);
+ if (progress[key] != null && !node) { node = el(doc, 'span'); metricNodes.set(key, node); metrics.appendChild(node); }
+ if (!node) return;
+ node.hidden = progress[key] == null;
+ if (!node.hidden) setText(node, metricLabels[key] + ' ' + Number(progress[key] || 0));
+ });
+ const details = Array.isArray(progress.details) ? progress.details : [];
+ const selected = details.filter(function (item) { return item.status === 'selected'; });
+ answerCheck.hidden = !selected.length;
+ setText(answerCheckSummary, '查看答案核对(' + selected.length + ')');
+ syncRows(answerCheckList, selected, answerCheckNodes, function () {
+ const row = el(doc, 'article', 'answer-check-item');
+ row.append(el(doc, 'p', 'answer-check-question'), el(doc, 'p', 'answer-check-course'), el(doc, 'p', 'answer-check-label', '题库正确答案全文 → 当前选项'), el(doc, 'ul', 'answer-check-options'), el(doc, 'p', 'answer-check-note'));
+ return row;
+ }, function (row, item) {
+ setText(row.children[0], '第 ' + plain(item.number) + ' 题' + (item.question ? ' · ' + plain(item.question) : ''));
+ const course = plain(item.course);
+ setText(row.children[1], course ? '课程:' + course : '课程来源未记录');
+ const texts = Array.isArray(item.correctTexts) ? item.correctTexts : [];
+ const labels = Array.isArray(item.currentLabels) ? item.currentLabels : [];
+ const list = row.children[3];
+ texts.forEach(function (text, index) {
+ let option = list.children[index];
+ if (!option) { option = el(doc, 'li'); list.appendChild(option); }
+ setText(option, (labels[index] ? plain(labels[index]) + '. ' : '当前字母未记录 · ') + plain(text));
+ });
+ if (!texts.length) {
+ let message = list.firstElementChild;
+ if (!message) { message = el(doc, 'li'); list.appendChild(message); }
+ setText(message, '题库答案文字未记录,请核对网页。');
+ }
+ const count = Math.max(1, texts.length);
+ while (list.children.length > count) list.lastElementChild.remove();
+ setText(row.children[4], item.reason || '');
+ row.children[4].hidden = !item.reason;
+ });
+ const notProcessed = details.filter(function (item) { return item.status !== 'selected'; });
+ unprocessed.hidden = !notProcessed.length;
+ setText(unprocessedSummary, '查看未处理题目(' + notProcessed.length + ')');
+ syncRows(unprocessedList, notProcessed, unprocessedNodes, function () { return el(doc, 'div'); }, function (row, item) {
+ setText(row, '第 ' + plain(item.number) + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。'));
+ });
+ }
+
let courseSignature = '';
function refresh() {
if (destroyed) return;
@@ -1331,7 +1426,7 @@ window.JSZip = module.exports;
autoSwitch.input.disabled = !state.autoAnswer && (Boolean(unavailable) || Boolean(state.loading));
autoSwitch.label.title = unavailable;
copySwitch.input.checked = Boolean(state.allowCopy);
- answerHint.textContent = unavailable || (state.autoAnswer ? '正在自动选择;关闭即可停止后续操作。' : '开启后选择已匹配的答案,交卷由你操作。');
+ answerHint.textContent = unavailable || (state.autoAnswer ? '正在按题干和选项全文匹配答案;关闭即可停止后续操作。' : '支持练习和考试,按题干和选项全文匹配答案;交卷由你操作。');
bankInfo.textContent = state.loading ? '正在读取题库…' : '已加载 ' + Number(state.bankTotal || 0).toLocaleString('zh-CN') + ' 条' + (state.role ? ' · ' + state.role + ' ' + Number(state.roleCounts && state.roleCounts[state.role] || 0).toLocaleString('zh-CN') + ' 条' : '');
bankInfo.classList.toggle('busy', Boolean(state.loading));
importButton.disabled = Boolean(state.loading);
@@ -1359,19 +1454,9 @@ window.JSZip = module.exports;
courseSelect.disabled = !state.role || Boolean(state.loading);
searchButton.disabled = Boolean(state.loading);
selectionButton.disabled = Boolean(state.loading);
- pageStatus.textContent = state.pageStatus || '等待识别练习页面';
- metrics.replaceChildren();
- const metricLabels = { selected: '已选择', pending: '待确认', missing: '未匹配', conflict: '冲突', locked: '已锁定', unsupported: '需人工核对', failed: '失败', skipped: '已跳过' };
- Object.keys(metricLabels).forEach(function (key) {
- if (state.metrics && state.metrics[key] != null) metrics.appendChild(el(doc, 'span', '', metricLabels[key] + ' ' + Number(state.metrics[key] || 0)));
- });
- const notProcessed = (state.metrics && state.metrics.details || []).filter(function (item) { return item.status !== 'selected'; });
- unprocessed.hidden = !notProcessed.length;
- unprocessedSummary.textContent = '查看未处理题目(' + notProcessed.length + ')';
- unprocessedList.replaceChildren();
- notProcessed.forEach(function (item) {
- unprocessedList.appendChild(el(doc, 'div', '', '第 ' + item.number + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。')));
- });
+ setText(pageTitle, state.pageMode === 'test' ? '当前考试' : state.pageMode === 'exercise' ? '当前练习' : '当前页面');
+ setText(pageStatus, state.pageStatus || '等待识别题目页面');
+ refreshMetrics();
exportSwitch.input.checked = Boolean(state.exportEnabled);
exportContent.hidden = !state.exportEnabled;
exportButtons.forEach(function (button) { button.disabled = !state.exportEnabled || !state.canExport; });
@@ -1480,7 +1565,7 @@ window.JSZip = module.exports;
global.YianAssistantPanel = { create: create };
})(typeof window !== 'undefined' ? window : globalThis);
-/* YianAnswerRunner: 只在已识别的练习页面串行点击真实选项。 */
+/* YianAnswerRunner: 只在已明确识别的练习或考试页面串行点击真实选项。 */
(function (root) {
'use strict';
@@ -1489,10 +1574,11 @@ window.JSZip = module.exports;
const WAIT_LIMIT = 1200;
const CLICK_GAP = 150;
const COUNT_KEYS = ['selected', 'pending', 'missing', 'conflict', 'locked', 'failed', 'unsupported'];
- const CONTEXT_KEYS = ['role', 'course', 'sourceUrl', 'routeKey', 'pageSignature', 'revision'];
+ const CONTEXT_KEYS = ['role', 'course', 'sourceUrl', 'routeKey', 'pageSignature', 'revision', 'exam', 'mode'];
function normalize(value) {
- return String(value == null ? '' : value).replace(/\u00a0/g, ' ').replace(/[\t\n\r ]+/g, ' ').trim();
+ // 仅中和排版空白和不可见分隔符;不折叠大小写、数字、单位或标点。
+ return String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/\s+/g, ' ').trim();
}
function questionSignature(question) {
@@ -1506,8 +1592,13 @@ window.JSZip = module.exports;
return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(questionSignature)]);
}
- function isLocked(box, selectors) {
+ function isLocked(box, selectors, context) {
if (box.querySelector('.pc_examtest_content_form_disabled')) return true;
+ if (box.closest('[disabled],[aria-disabled="true"]')) return true;
+ if (Array.from(box.querySelectorAll(selectors.option)).some(function (node) { return Boolean(node.closest('[disabled],[aria-disabled="true"]')); })) return true;
+ // 考试正常作答时没有查看答案控件,单选/判断也允许改选。
+ // 已提交、到时或出现提交对话框由 getContext 将 exam 置 false,每一步都会复核。
+ if (context && context.exam === true) return false;
const component = box.closest('[app-exam-question],app-exam-question');
const flags = [box, component].filter(Boolean);
if (flags.some(function (element) {
@@ -1521,12 +1612,12 @@ window.JSZip = module.exports;
});
}
- function optionState(box, selectors) {
+ function optionState(box, selectors, context) {
const nodes = Array.from(box.querySelectorAll(selectors.option));
return {
nodes: nodes,
selected: nodes.map(function (node) { return node.classList.contains(CHECKED); }),
- locked: isLocked(box, selectors)
+ locked: isLocked(box, selectors, context)
};
}
@@ -1558,7 +1649,14 @@ window.JSZip = module.exports;
records.forEach(function (record) {
if (COUNT_KEYS.includes(record.status)) counts[record.status]++;
if (record.pending) counts.pending++;
- details.push({ number: record.number, status: record.status, reason: record.reason || '' });
+ details.push({
+ number: record.number, question: record.question, course: record.course,
+ pageCourse: record.pageCourse, sourceCourses: record.sourceCourses.slice(),
+ status: record.status, reason: record.reason || '',
+ correctTexts: record.correctTexts.slice(), correctLabels: record.correctLabels.slice(),
+ currentLabels: record.currentLabels.slice(), currentTexts: record.currentTexts.slice(),
+ currentAnswerText: record.currentAnswerText
+ });
});
counts.details = details;
return counts;
@@ -1571,7 +1669,19 @@ window.JSZip = module.exports;
}
function setRecord(item, status, reason, pending) {
- records.set(item.key, { number: item.question.number, status: status, reason: reason || '', pending: Boolean(pending) });
+ const nodes = Array.from(item.box.querySelectorAll(selectors.option));
+ const selected = item.question.options.filter(function (_, index) { return nodes[index] && nodes[index].classList.contains(CHECKED); });
+ const sourceCourses = (item.sourceCourses || []).slice();
+ records.set(item.key, {
+ index: item.index, number: item.question.number, question: item.question.question,
+ course: sourceCourses.length ? sourceCourses.join('、') : item.course,
+ pageCourse: item.course, sourceCourses: sourceCourses,
+ status: status, reason: reason || '', pending: Boolean(pending),
+ correctTexts: (item.correctTexts || []).slice(), correctLabels: (item.correctLabels || []).slice(),
+ currentLabels: selected.map(function (option) { return option.label; }),
+ currentTexts: selected.map(function (option) { return option.text; }),
+ currentAnswerText: selected.map(function (option) { return option.label + '. ' + option.text; }).join(';')
+ });
notifyProgress();
}
@@ -1586,11 +1696,11 @@ window.JSZip = module.exports;
}
function initialReason(context) {
- if (!context || context.ready !== true) return '等待练习页面就绪。';
+ if (!context || context.ready !== true) return '等待答题页面就绪。';
if (context.local === true || root.location && root.location.protocol === 'file:') return '保存的静态网页可搜索和导出,不能记录自动选择。';
- if (context.practice !== true) return '当前页面未识别为练习,自动选择已停止。';
+ if (context.practice !== true && context.exam !== true) return '当前页面未识别为可作答的练习或考试,自动选择已停止。';
if (!context.role) return '请先选择主操、班长或副操。';
- if (!context.root || !context.root.isConnected || context.root.ownerDocument !== doc) return '练习内容已切换,等待新页面。';
+ if (!context.root || !context.root.isConnected || context.root.ownerDocument !== doc) return '答题内容已切换,等待新页面。';
return '';
}
@@ -1647,7 +1757,7 @@ window.JSZip = module.exports;
const items = boxes.map(function (box, index) {
const question = bank.questions[index];
const signature = questionSignature(question);
- return { box: box, index: index, question: question, signature: signature, key: index + '\u0000' + signature };
+ return { box: box, index: index, question: question, course: context.course || '', signature: signature, key: index + '\u0000' + signature };
}).filter(function (item) { return context.root === item.box || context.root.contains(item.box); });
return { items: items, signature: bankSignature(bank) };
}
@@ -1660,10 +1770,10 @@ window.JSZip = module.exports;
if (boxes[item.index] !== item.box) return { reason: '题目顺序已变化,已停止本轮自动选择。', cancelled: true };
const bank = core.extract(doc);
// 父面板的 MutationObserver 尚未运行时,也直接阻断标题或其他题目的变化。
- if (bankSignature(bank) !== run.bankSignature) return { reason: '练习题目或课程已更新,已停止本轮自动选择。', cancelled: true };
+ if (bankSignature(bank) !== run.bankSignature) return { reason: '题目或课程已更新,已停止本轮自动选择。', cancelled: true };
const current = bank.questions[item.index];
if (!current || questionSignature(current) !== item.signature) return { reason: '题干或选项已变化,已停止本轮自动选择。', cancelled: true };
- const state = optionState(item.box, selectors);
+ const state = optionState(item.box, selectors, run.context);
if (state.nodes.length !== current.options.length) return { reason: '选项尚未完整加载。' };
return { question: current, state: state };
}
@@ -1716,12 +1826,18 @@ window.JSZip = module.exports;
const actualLabels = question.options.map(function (option) { return normalize(option.label); });
if (new Set(actualLabels).size !== actualLabels.length || labels.some(function (label) { return !actualLabels.includes(label); })) return { reason: '答案不能唯一对应网页选项,已跳过。' };
if (question.type !== '多选' && labels.length !== 1) return { reason: '单选或判断题匹配到多个答案,已跳过。' };
- if (Array.isArray(matched.correctTexts) && matched.correctTexts.length) {
- const expectedTexts = matched.correctTexts.map(normalize).sort();
- const actualTexts = question.options.filter(function (option) { return labels.includes(normalize(option.label)); }).map(function (option) { return normalize(option.text); }).sort();
- if (JSON.stringify(expectedTexts) !== JSON.stringify(actualTexts)) return { reason: '匹配答案文字与网页选项不一致,已跳过。' };
- }
- return { wanted: actualLabels.map(function (label) { return labels.includes(label); }) };
+ if (!Array.isArray(matched.correctTexts) || matched.correctTexts.length !== labels.length) return { reason: '题库缺少完整正确答案文字,不能仅凭答案字母自动选择。' };
+ const expectedTexts = matched.correctTexts.map(normalize);
+ if (expectedTexts.some(function (text) { return !text; }) || new Set(expectedTexts).size !== expectedTexts.length) return { reason: '正确答案文字为空或重复,无法唯一核验。' };
+ const selected = question.options.filter(function (option) { return labels.includes(normalize(option.label)); });
+ const actualTexts = selected.map(function (option) { return normalize(option.text); });
+ if (JSON.stringify(expectedTexts.slice().sort()) !== JSON.stringify(actualTexts.slice().sort())) return { reason: '题库正确答案全文与网页选项不一致,已跳过。' };
+ return {
+ wanted: actualLabels.map(function (label) { return labels.includes(label); }),
+ correctLabels: selected.map(function (option) { return option.label; }),
+ // 原始题库文本按当页选项顺序存入证据,方便与 currentLabels 逐项核对。
+ correctTexts: actualTexts.map(function (text) { return matched.correctTexts[expectedTexts.indexOf(text)]; })
+ };
}
async function processQuestion(run, item) {
@@ -1729,15 +1845,19 @@ window.JSZip = module.exports;
let current = readCurrent(run, item);
if (current.cancelled) return abortForChange(run, current.reason);
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
- if (records.has(item.key)) return true;
- if (!item.box.closest('[app-exam-question],app-exam-question')) { setRecord(item, 'unsupported', '题目缺少已适配的练习组件,已跳过。'); return true; }
+ const previous = records.get(item.key);
+ if (previous && previous.status !== 'selected' && !previous.manualInvalid) return true;
+ if (!item.box.closest('[app-exam-question],app-exam-question')) { setRecord(item, 'unsupported', '题目缺少已适配的答题组件,已跳过。'); return true; }
if (!['单选', '多选', '判断'].includes(current.question.type)) { setRecord(item, 'unsupported', '暂不支持该题型自动选择。'); return true; }
- if (current.state.locked) { setRecord(item, 'locked', '该题已判分或锁定,保留原作答。'); return true; }
+ if (current.state.locked && !previous) { setRecord(item, 'locked', '该题已判分或锁定,保留原作答。'); return true; }
const matched = await options.match(current.question, options.getContext());
current = readCurrent(run, item);
if (current.cancelled) return abortForChange(run, current.reason);
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
- if (current.state.locked) { setRecord(item, 'locked', '匹配期间该题已锁定,保留原作答。'); return true; }
+ item.correctTexts = matched && Array.isArray(matched.correctTexts) ? matched.correctTexts.slice() : [];
+ item.correctLabels = matched && Array.isArray(matched.correctLabels) ? matched.correctLabels.slice() : [];
+ item.sourceCourses = Array.from(new Set((matched && Array.isArray(matched.records) ? matched.records : []).map(function (record) { return normalize(record.course); }).filter(Boolean)));
+ if (current.state.locked && !previous) { setRecord(item, 'locked', '匹配期间该题已锁定,保留原作答。'); return true; }
if (!matched || matched.status !== 'matched') {
const status = matched && ['missing', 'conflict', 'unsupported'].includes(matched.status) ? matched.status : 'unsupported';
setRecord(item, status, matched && matched.reason || '未获得可自动选择的唯一答案。');
@@ -1745,6 +1865,13 @@ window.JSZip = module.exports;
}
const target = resolveTargets(current.question, matched);
if (target.reason) { setRecord(item, 'unsupported', target.reason); return true; }
+ item.correctTexts = target.correctTexts;
+ item.correctLabels = target.correctLabels;
+ if (current.state.locked) {
+ if (sameSelection(current.state.selected, target.wanted)) setRecord(item, 'selected', '已重新核验原作答与题库答案一致。');
+ else setRecord(item, 'locked', '该题已锁定且当前作答不同于题库答案,保留原作答。');
+ return true;
+ }
let changed = false;
if (current.question.type === '多选') {
// 每次按当前集合核对;先去掉错误选项,随后补选目标选项,不重复 toggle。
@@ -1771,7 +1898,7 @@ window.JSZip = module.exports;
if (current.cancelled) return abortForChange(run, current.reason);
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
if (!sameSelection(current.state.selected, target.wanted)) { setRecord(item, 'failed', '网页当前选中集合与答案不一致,已停止修改该题。'); return true; }
- const pending = current.question.type === '多选' && !current.state.locked;
+ const pending = run.context.exam !== true && current.question.type === '多选' && !current.state.locked;
setRecord(item, 'selected', pending ? '已选择,待网页确认。' : changed ? '已选择匹配答案。' : '当前已选中匹配答案。', pending);
return true;
}
@@ -1816,7 +1943,7 @@ window.JSZip = module.exports;
const currentKeys = new Set(items.map(function (item) { return item.key; }));
records.forEach(function (_, key) { if (!currentKeys.has(key)) records.delete(key); });
notifyProgress();
- if (!items.length) { notifyState('idle', '当前没有可处理的练习题目。'); return Promise.resolve(progress()); }
+ if (!items.length) { notifyState('idle', '当前没有可处理的答题题目。'); return Promise.resolve(progress()); }
const run = { id: ++generation, context: captured, bankSignature: extracted.signature, cancelled: false, waiters: new Set(), currentBox: null, promise: null };
active = run;
// 先写入 promise,再通知或执行,使同步 UI 回调中的 start 也不会并发运行。
@@ -1829,11 +1956,43 @@ window.JSZip = module.exports;
}
function manualClick(event) {
- if (!event.isTrusted || !active || active.cancelled) return;
+ if (!event.isTrusted || destroyed) return;
const target = event.target && event.target.nodeType === 1 ? event.target : event.target && event.target.parentElement;
if (!target) return;
+ const run = active && !active.cancelled ? active : null;
+ let currentScope = run && run.context;
+ if (!currentScope && scope && records.size) {
+ const context = options.getContext();
+ if (!initialReason(context) && sameContext(scope, captureContext(context))) currentScope = scope;
+ }
+ if (!currentScope) return;
+ const submit = target.closest('.pc_examtest_commit,button[type="submit"],input[type="submit"]');
+ if (submit && currentScope.root.contains(submit)) {
+ pause('检测到手动交卷,已暂停自动选择。');
+ return;
+ }
const box = target.closest(selectors.question);
- if (box && (active.context.root === box || active.context.root.contains(box))) pause('检测到手动作答,已暂停自动选择;可重新开启继续。');
+ if (!box || currentScope.root !== box && !currentScope.root.contains(box)) return;
+ let invalidated = false;
+ if (target.closest(selectors.option)) {
+ const index = Array.from(doc.querySelectorAll(selectors.question)).indexOf(box);
+ records.forEach(function (record) {
+ if (record.index !== index || record.status !== 'selected') return;
+ // 捕获阶段网页 onclick 还未执行,不能把此前的 checked 集合当成改选后的证据。
+ record.status = 'unsupported';
+ record.reason = '手动作答后需重新核对';
+ record.pending = false;
+ record.manualInvalid = true;
+ record.correctTexts = [];
+ record.correctLabels = [];
+ record.currentLabels = [];
+ record.currentTexts = [];
+ record.currentAnswerText = '';
+ invalidated = true;
+ });
+ }
+ if (invalidated) notifyProgress();
+ if (run || invalidated) pause('检测到手动作答,已暂停自动选择;可重新开启继续。');
}
doc.addEventListener('click', manualClick, true);
@@ -1857,7 +2016,7 @@ window.JSZip = module.exports;
return { start: start, stop: stop, reset: reset, destroy: destroy, get running() { return Boolean(active && !active.cancelled); } };
}
- root.YianAnswerRunner = { version: '1.0.0', create: create };
+ root.YianAnswerRunner = { version: '1.1.0', create: create };
})(typeof window !== 'undefined' ? window : globalThis);
(function () {
@@ -1876,6 +2035,12 @@ window.JSZip = module.exports;
const emptyMetrics = () => ({ selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: 0 });
let metrics = emptyMetrics();
+ 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);
@@ -1885,11 +2050,24 @@ window.JSZip = module.exports;
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: !!root && (mode ? mode === 'exercise' : !!marker),
+ 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' ? params.get('title') || '' : '', mode };
+ expectedTitle: mode === 'exercise' || mode === 'test' ? params.get('title') || '' : '', mode };
}
function structureSignature(bank) {
@@ -1904,7 +2082,7 @@ window.JSZip = module.exports;
const signature = route.root ? structureSignature(bank) : '';
return { ready: !!page && page.ready && !loading && page.key === route.key
&& page.root === route.root && page.signature === signature,
- practice: route.practice, local: route.local, role: config.role, course: bank.title,
+ 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 };
}
@@ -1946,8 +2124,8 @@ window.JSZip = module.exports;
}
function setAutoAnswer(enabled) {
const context = getContext();
- if (enabled && (!context.ready || !context.practice || context.local || !config.role || !snapshot.counts[config.role])) {
- panel.setNotice('请先导入所选岗位题库,并进入已加载完成的兼容练习页面。', 'warning'); refreshPanel(); return false;
+ if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !config.role || !snapshot.counts[config.role])) {
+ panel.setNotice('请先导入所选岗位题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
}
config.autoAnswer = !!enabled; runKey = '';
if (!enabled) runner.stop('自动选择已关闭,已生效的选择保留。');
@@ -2002,7 +2180,7 @@ window.JSZip = module.exports;
if (runner.running) runner.stop('当前页面未识别到练习题目');
if (page) previous = page;
page = null;
- refreshPanel({ canAnswer: false, canExport: false, pageStatus: '当前网页可搜索题库和复制文字;未识别到支持的练习题目。' }); return;
+ 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) {
@@ -2010,27 +2188,38 @@ window.JSZip = module.exports;
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 = bank.expectedTotal === null || bank.expectedTotal === bank.total;
+ 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.practice) 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.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。';
+ else if (!countComplete) status = bank.expectedTotal === null ? '等待考试答题卡总题数,确认完整试卷后才能自动选择。'
+ : '已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。';
+ else if (route.exam) status = bank.title + ':' + bank.total + ' 题。按所选岗位题库严格核对题干、选项和正确答案全文。'
+ + (route.local ? ' 保存网页只用于核对,自动选择需要在线考试页面。' : '完成后请检查结果并自行交卷。');
else status = bank.title + ':' + bank.total + ' 题,' + (bank.missingAnswers.length
? '缺少 ' + bank.missingAnswers.length + ' 个网页答案;可用导入题库选择。' : '网页答案齐全。')
+ (route.local ? ' 保存网页支持搜索与导出;自动选择需要在线练习页面。' : '');
- refreshPanel({ pageStatus: status, canExport: route.practice && !!bank.total,
- canAnswer: route.practice && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] });
- if (!route.practice || !page.ready || loading) { if (runner.running) runner.stop('等待兼容练习页面准备完成'); return; }
- if (config.exportEnabled && bank.complete) {
+ 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 && !!config.role && !!snapshot.counts[config.role] });
+ 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); }