feat: collect judgment standards through opt-in normal practice

This commit is contained in:
suanyuit-cyber
2026-10-30 04:32:48 +08:00
parent 6ba4905a7d
commit fcc8390faa
9 changed files with 1153 additions and 24 deletions
+20 -5
View File
@@ -1,6 +1,8 @@
# 华谊题库助手
支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.6**。
支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.7**。
1.2.7 新增默认关闭的“补齐当前判断题库”。手动启动后会通过正常练习控件逐题作答,等待网站标准反馈与同题可信入库记录一致后再进入下一题;这会改变练习记录。只支持本站500题或232题的正常判断练习,不支持普通网页、正式考试或错题重做。该功能与普通自动选择互斥,详见下方使用说明。当前发布题库仍为1261题已确认、623题待补,功能发布不代表剩余答案已经采集完成。
**2026-10-30 答案来源更正:此前“1884题标准答案全部确认”的结论错误。** Chrome 实际练习和页面业务代码均证实,`/upm/start`、`/upm/wrong/start` 的未作答判断题也可能带有默认 `judgeAnswer: 0`,该值不能视为标准“错误”;旧值为 `1` 且没有提交或标准反馈证据时也不能确认。1.2.6 将这类旧判断题答案改为“待补”,清除仅由列表占位值及其继承 DOM 观察造成的冲突,并保留原值备份。两个可信标准答案不一致时仍保留冲突。
@@ -47,7 +49,9 @@
## 自动答题
旧判断题库中未获可信反馈的题目显示“待补”,不会自动选择。已有候选结果与“当前题对应”分别展示;相似搜索结果或候选记录不能代替当前题的完整题干、选项及身份核对。
### 普通自动选择
普通自动选择模式只使用本地已确认答案。旧判断题库中未获可信反馈的题目显示“待补”,在此模式下跳过。已有候选结果与“当前题对应”分别展示;相似搜索结果或候选记录不能代替当前题的完整题干、选项及身份核对。
打开“自动答题”页签,先导入包含标准答案的题库,然后选择:
@@ -56,10 +60,20 @@
单选按答案内容选择当前选项;多选同步完整正确集合,取消已经选中的错误项;判断题按“正确 / 错误”对应。候选答案冲突、题干或选项不完整、重复文字无法唯一对应、选项不可操作时跳过,显示原因。
选择完成后由你检查并提交或进入下一题。脚本不会点击提交、交卷、下一题按钮。华谊练习中的选项点击可能触发网站自身的作答响应,具体行为由练习页面决定。
普通自动选择完成后由你检查并提交或进入下一题;此模式不会点击提交、交卷或下一题按钮。华谊练习中的选项点击可能触发网站自身的作答响应,具体行为由练习页面决定。
自动选择支持华谊题目 DOM,以及标准 `fieldset + legend + label + radio/checkbox` 和明确题目容器结构。全网页显示不代表能自动识别任意网站的自定义答题控件;无法识别时可继续使用文字搜索。题库仍保存在同一个油猴脚本存储中,可在不同网站访问。
### 手动启动判断题库补齐
**“补齐当前判断题库”会真实进行正常练习作答,增加累计答题及对错记录。** 它默认关闭,不会因打开网页、导入题库或显示面板自动启动。请先打开本站500题或232题的正常判断练习,保持采集开启并等待当前题库身份及完整题目加载,再进入“自动答题”页签点击“补齐当前判断题库”。普通网页、正式考试和错题重做不能使用。
启动后从第1题逐题核对题库、位置、题目ID、完整题干和选项。已有可信标准答案的题目会跳过作答;待补题通过正常选项控件选择“正确”一次,以触发网站返回标准反馈。**这个选择只是取得反馈的练习作答,不能当作正确答案保存**;网站给出的标准可能是“正确”或“错误”。只有当前网页标准反馈与同题可信入库记录一致后,才点击正常“下一题”。身份、来源或答案不一致,或者反馈迟迟不到时停止,保留当前结果供核对。
可随时点击“停止补齐”。暂停采集、切换题库、离开页面、导入题库或发生存储异常时也会停止。隐藏或收起面板后仍会继续;隐藏后按 **Shift + Alt + Q** 恢复面板,收起后点击“展开”,再点击“停止补齐”。刷新后保持关闭,不会自动恢复。启动补齐会先停止普通自动选择,启用连续自动选择或点击“答当前题”也会先停止补齐,避免两种模式同时操作。
补齐模式会按上述核验结果点击“下一题”,但不会点击结束、交卷或末题提交按钮。只有全部标准题目位置均有可信答案时才显示完成;当前发布的623道待补题仍需实际取得网站反馈,不能由脚本版本号或进度推定已补齐。
## 本次方案:按题库分别统计、搜索和导出
旧版会累积的原因是:默认选择“全部题库”,主统计也是所有本地记录的总数,导出文件统一叫“华谊题库”。保存多个题库本身是正常行为,但当前库的展示与导出范围应该明确区分。
@@ -96,7 +110,7 @@
| **Shift + Alt + Q** | 在显示与隐藏之间切换 | 再按一次 |
| 油猴菜单“显示题库助手” | 手动显示面板 | 右上角隐藏或快捷键 |
复制开关等设置会保留;面板在每次加载后均重置为隐藏,连续自动选择也会关闭。隐藏时划选题干仍会更新查询,但不会重新弹出面板。隐藏、采集和连续自动选择是独立操作;隐藏面板不会停止你在当前页已开启的连续选择,停止时请先关闭相应开关。
复制开关等设置会保留;面板在每次加载后均重置为隐藏,连续自动选择和判断题补齐均关闭。隐藏时划选题干仍会更新查询,但不会重新弹出面板。隐藏面板不会停止当前页已开启的连续选择或判断题补齐;请恢复面板后关闭连续选择开关,或点击“停止补齐”。暂停采集会停止判断题补齐。
## 正确答案如何防止错位
@@ -130,7 +144,7 @@ JSON 备份包含 `schemaVersion: 1` 和 `records` 数组,保留完整选项
## 采集范围与当前验证边界
脚本在当前账号正常练习时,观察已收到的题目 / 答案响应,并补充当前页面 DOM。自动选择只操作对应选项,网站可能按自身规则保存作答。接口没有提供标准答案时,保留待补状态;仅安装脚本不会凭空补出答案。
脚本在当前账号正常练习时,观察已收到的题目 / 答案响应,并补充当前页面 DOM。普通自动选择只操作对应选项,网站可能按自身规则保存作答;手动启动的判断题补齐按上述流程进行正常练习及换题。接口没有提供标准答案时,保留待补状态;仅安装脚本不会凭空补出答案。
目前适配了保存页面中的真实题目结构,以及业务脚本中可确认的练习接口:`/upm/start`、`/upm/wrong/start`、`/pra/{id}/questions` 和对应单题作答结果。接口适配限制在本站及学习平台域名。脚本不发送题库到外部服务器,来源 URL 不保存查询参数。
@@ -160,6 +174,7 @@ npm run merge:banks -- --manifest 题库/页面题数核对.json --allow-partial
- `src/core.js`:答案规范化、去重与冲突、检索、字母映射、CSV。
- `src/adapter.js`:本站响应观察、题目 DOM 读取、复制恢复。
- `src/autofill.js`:精确答案匹配、标准选项操作与连续选择。
- `src/collector.js`:手动启动当前判断练习的逐题作答、反馈核验、换题与停止。
- `src/app.js`:面板、隐藏恢复、存储、导入导出。
```powershell
+2 -2
View File
@@ -4,7 +4,7 @@ const root = __dirname;
const header = `// ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank
// @version 1.2.6
// @version 1.2.7
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
// @match *://*/*
// @run-at document-start
@@ -16,7 +16,7 @@ const header = `// ==UserScript==
// @grant GM_addValueChangeListener
// ==/UserScript==
`;
const sources = ['src/core.js', 'src/adapter.js', 'src/autofill.js', 'src/app.js'];
const sources = ['src/core.js', 'src/adapter.js', 'src/autofill.js', 'src/collector.js', 'src/app.js'];
const body = sources.map(file => `\n// ---- ${file} ----\n${fs.readFileSync(path.join(root, file), 'utf8')}`).join('\n');
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
const target = path.join(root, 'dist/huayi-question-bank.user.js');
+326 -7
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank
// @version 1.2.6
// @version 1.2.7
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
// @match *://*/*
// @run-at document-start
@@ -2097,6 +2097,271 @@ const QBAuto = (() => {
if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
// ---- src/collector.js ----
/* Opt-in completion of one Huayi judgment practice, through ordinary DOM clicks. */
const QBCollector = (() => {
'use strict';
const MAIN = '.yxtulcdsdk-pc-marking-main';
const FORM = `${MAIN} .yxtulcdsdk-form[practice]`;
const EVIDENCE = new Set(['submitted-list', 'answer-response', 'bound-dom', 'explicit-standard']);
const trim = value => String(value == null ? '' : value).replace(/\u00a0/g, ' ').trim();
const flat = value => trim(value).replace(/\s+/gu, '');
const optionsKey = question => JSON.stringify((question.options || []).map(option => [trim(option.id), flat(option.text)]).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))));
const identity = question => JSON.stringify([question.bankId, question.questionId, question.questionIndex, flat(question.stem), optionsKey(question)]);
const evidence = record => EVIDENCE.has(record.answerEvidence) || (record.answerSource || record.source)?.kind === 'network' &&
/\/upm\/[^/]+\/users\/[^/]+\/(?:wrong\/)?answer(?:[?#]|$)/u.test((record.answerSource || record.source)?.url || '');
function create({ window: win, document: doc, core, getRecords = () => [], getQuestion = () => null,
getBank = () => null, isCollecting = () => false, onChange = () => {} }) {
let state = { active: false, phase: 'idle', message: '练习补齐未启动。', reason: '', answered: 0, skipped: 0,
current: 0, total: 0, bankId: '', bankName: '', taskId: '' };
let destroyed = false, timer = null, generation = 0, binding = null, pending = null, navigation = null;
let indexSource = null, indexLength = -1, recordIndex = null, idIndex = null;
const visited = new Map();
const now = () => win.Date?.now ? win.Date.now() : Date.now();
const getState = () => ({ ...state });
function update(patch) {
const old = state;
state = { ...state, ...patch };
if (old.phase !== state.phase || old.active !== state.active) {
try { onChange(getState()); } catch (_) { /* UI code cannot grant further actions. */ }
}
}
function stop(reason = '已停止练习补齐。') {
generation++;
if (timer !== null) win.clearTimeout(timer);
timer = null; pending = null; navigation = null;
update({ active: false, phase: 'stopped', reason: trim(reason), message: trim(reason) || '已停止练习补齐。' });
return getState();
}
function visible(node) {
if (!node || node.isConnected === false || node.closest?.('#huayi-qb-root')) return false;
for (let current = node; current && current.nodeType === 1; current = current.parentElement) {
if (current.hidden || current.getAttribute?.('aria-hidden') === 'true') return false;
const style = win.getComputedStyle?.(current);
if (style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse') return false;
}
return true;
}
function context() {
const url = new URL(String(win.location?.href || ''));
if (!['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(url.hostname) ||
url.pathname !== '/o2o/' || !/^#\/playinfo(?:\?|$)/u.test(url.hash)) throw new Error('仅支持华谊培训大纲中的判断练习页面。');
const params = new URLSearchParams(url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : '');
if (params.get('wq') === '1' || url.searchParams.get('wq') === '1' || /wrong/iu.test(url.hash)) throw new Error('错题重做不属于本次完整题库补齐范围。');
if (!isCollecting()) throw new Error('采集已关闭或题库存储不可用,练习补齐已停止。');
const selected = doc.querySelector('.task-content-active');
if (selected && (!/练习/u.test(selected.textContent || '') || /考试/u.test(selected.textContent || ''))) throw new Error('当前培训任务不是明确的练习,不能启动。');
const forms = [...doc.querySelectorAll(FORM)].filter(visible);
if (forms.length !== 1) throw new Error('当前页面不是唯一的正常练习题,正式考试或不完整页面不能补齐。');
const form = forms[0], main = form.closest(MAIN);
if (!main || !visible(main)) throw new Error('练习题目区域尚未完整显示。');
const heading = trim(main.querySelector('.standard-size-14 span')?.textContent);
if (heading !== '判断题') throw new Error('本功能只支持判断题练习。');
const bank = getBank();
const taskId = trim(bank?.taskId || params.get('taskId'));
const total = bank?.questionCount;
if (!bank || bank.loading || !trim(bank.bankId) || !taskId || ![500, 232].includes(total)) throw new Error('当前题库身份或页面总题数未确认,请等待题库加载。');
const progress = [...doc.querySelectorAll('.yxtulcdsdk-answer-card__top')].filter(visible);
const counts = progress.map(node => trim(node.textContent).match(/(\d+)\s*\/\s*(\d+)(?!\d)/u)).filter(Boolean);
if (counts.length !== 1 || Number(counts[0][2]) !== total || Number(counts[0][1]) > total) throw new Error('页面答题卡总数与当前题库不一致,已停止。');
const route = `${url.pathname}${url.search}${url.hash}`;
if (binding && (bank.bankId !== binding.bankId || taskId !== binding.taskId || route !== binding.route || total !== binding.total)) {
throw new Error('题库、任务或地址已变化,练习补齐已停止。');
}
return { bank, taskId, total, route, form, main };
}
function readView(ctx) {
const question = getQuestion();
const number = trim(ctx.form.querySelector('.yxtulcdsdk-form__sub')?.textContent).match(/^(\d+)\s*[..、]?$/u);
if (!number || !question || question.type !== 'judge' || question.bankId !== ctx.bank.bankId || !trim(question.questionId) ||
!Number.isSafeInteger(question.questionIndex) || question.questionIndex !== Number(number[1]) - 1 ||
question.questionIndex < 0 || question.questionIndex >= ctx.total || question.options?.length !== 2 || !trim(question.stem)) return null;
const labels = [...ctx.form.querySelectorAll('.yxtulcdsdk-ques-radio')].filter(visible);
if (labels.length !== 2) return null;
const controls = labels.map(label => ({ label, input: label.querySelector('input[type="radio"]'),
text: trim(label.querySelector('.yxtf-radio__label')?.textContent || label.textContent) }));
if (controls.some(control => !control.input || !control.input.isConnected || !control.input.value) ||
controls.some((control, position) => control.text !== trim(question.options[position].text) || trim(control.input.value) !== trim(question.options[position].id)) ||
new Set(controls.map(control => control.text)).size !== 2 || !controls.some(control => control.text === '正确') || !controls.some(control => control.text === '错误')) return null;
const feedback = [...ctx.main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible).filter(row =>
['正确答案', '标准答案'].includes(trim(row.querySelector('.yxtulcdsdk-examing__result-line-left')?.textContent)));
if (feedback.length > 1) throw new Error('当前题出现多个标准答案展示,已停止核验。');
let standard = '';
if (feedback.length) {
const row = feedback[0];
const rowId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id'].map(name => trim(row.getAttribute(name))).find(Boolean);
if (rowId && rowId !== question.questionId) throw new Error('页面标准答案仍属于另一道题,已停止。');
standard = trim(row.querySelector('.yxtulcdsdk-examing__result-line-right')?.textContent);
if (!['正确', '错误'].includes(standard)) throw new Error('网站标准答案尚不明确,已停止。');
}
return { ...ctx, question, controls, standard, feedbackPresent: feedback.length === 1,
checked: controls.some(control => control.input.checked), mark: identity(question) };
}
function recordsAt(index) {
const records = getRecords();
if (!Array.isArray(records)) throw new Error('本地题库不可读取,已停止。');
if (indexSource !== records || indexLength !== records.length) {
indexSource = records; indexLength = records.length; recordIndex = new Map(); idIndex = new Map();
for (const record of records) {
if (!record || record.bankId !== binding.bankId || !Number.isSafeInteger(record.questionIndex)) continue;
if (!recordIndex.has(record.questionIndex)) recordIndex.set(record.questionIndex, []);
recordIndex.get(record.questionIndex).push(record);
for (const id of new Set([record.questionId, ...(record.questionIdAliases || [])].filter(Boolean))) {
if (!idIndex.has(id)) idIndex.set(id, []);
idIndex.get(id).push(record);
}
}
}
return recordIndex.get(index) || [];
}
function matchingRecord(question) {
const candidates = recordsAt(question.questionIndex).filter(record => record.type === 'judge' && flat(record.stem) === flat(question.stem) && optionsKey(record) === optionsKey(question) &&
[record.questionId, ...(record.questionIdAliases || [])].includes(question.questionId));
if (candidates.length > 1) throw new Error('同一题目位置存在多个匹配记录,已停止避免重复提交。');
const record = candidates[0];
if (!record) {
throw new Error('当前题不能与本地题库的位置、完整内容及题目ID唯一对应,已停止。');
}
if ((idIndex.get(question.questionId) || []).length !== 1) throw new Error('当前题目ID或别名对应多个原题位置,已停止。');
if (record.answerStatus === 'conflict' || record.conflicts?.length) throw new Error('本地当前题标准答案存在冲突,已停止。');
if (record.answerStatus === 'missing') return { kind: 'missing', record };
if (record.answerStatus !== 'confirmed' || !evidence(record)) throw new Error('当前题的已保存答案缺少可核验来源,已停止。');
const mapped = core.mapAnswers(record, question);
if (mapped.status !== 'matched' || mapped.answerTexts.length !== 1 || !['正确', '错误'].includes(mapped.answerTexts[0])) throw new Error('标准答案不能唯一对应当前题目,已停止。');
return { kind: 'confirmed', record, standard: mapped.answerTexts[0] };
}
function clickable(element) {
return visible(element) && typeof element.click === 'function' && !element.disabled && element.getAttribute?.('aria-disabled') !== 'true' &&
!element.closest?.('[aria-disabled="true"]');
}
function assertBeforeClick(view) {
if (!state.active || destroyed) return false;
const fresh = readView(context());
if (!fresh || fresh.mark !== view.mark || fresh.form !== view.form) throw new Error('点击前题目身份发生变化,已停止。');
return true;
}
function schedule() {
if (!state.active || destroyed || timer !== null) return;
const token = generation;
timer = win.setTimeout(() => { timer = null; if (state.active && !destroyed && token === generation) tick(); }, 300);
}
function beginNavigation(view, target) {
let button;
if (target === 0 && view.question.questionIndex !== 0) {
const matches = [...doc.querySelectorAll('.yxtulcdsdk-pc-card-list li')].filter(node => visible(node) && trim(node.textContent) === '1');
if (matches.length !== 1) throw new Error('答题卡第1题入口不唯一,不能安全从头核验。');
button = matches[0];
} else {
const matches = [...view.main.querySelectorAll('.yxtulcdsdk-examing__btns button')].filter(node => visible(node) && trim(node.textContent) === '下一题');
if (matches.length !== 1) throw new Error('没有唯一的下一题按钮;不会点击结束、提交或交卷。');
button = matches[0];
}
if (!clickable(button)) throw new Error('换题按钮暂不可用,已停止保留当前结果。');
navigation = { from: view.question.questionIndex, target, deadline: now() + 15000 };
update({ phase: 'navigating', message: `正在核对第 ${target + 1} 题。` });
if (!assertBeforeClick(view)) return;
button.click();
}
function finish() {
if (!state.active) return;
if (visited.size !== binding.total) throw new Error('本次尚未核对完整题库,不能标记完成。');
for (let index = 0; index < binding.total; index++) {
const question = visited.get(index);
if (!question || matchingRecord(question).kind !== 'confirmed') throw new Error(`第 ${index + 1} 题标准答案尚未完整核验,已停止。`);
}
generation++;
if (timer !== null) win.clearTimeout(timer);
timer = null; pending = null; navigation = null;
update({ active: false, phase: 'completed', reason: '', message: `已核验 ${binding.total} 道判断题。本次提交 ${state.answered} 题,跳过 ${state.skipped} 道已有标准答案的题目。` });
}
function advance(view, skipped) {
visited.set(view.question.questionIndex, { ...view.question, options: view.question.options.map(option => ({ ...option })) });
if (skipped) state = { ...state, skipped: state.skipped + 1 };
pending = null;
if (view.question.questionIndex === binding.total - 1) return finish();
beginNavigation(view, view.question.questionIndex + 1);
}
function decide(view) {
update({ phase: 'checking', current: view.question.questionIndex + 1, message: `正在核验第 ${view.question.questionIndex + 1} / ${binding.total} 题。` });
if (!state.active) return;
const local = matchingRecord(view.question);
if (local.kind === 'confirmed') {
if (view.feedbackPresent && view.standard !== local.standard) throw new Error('网页反馈与本地标准答案不一致,已停止保留冲突。');
return advance(view, true);
}
pending = { mark: view.mark, questionIndex: view.question.questionIndex, deadline: now() + 15000, clicked: false };
if (view.feedbackPresent || view.checked) {
update({ phase: 'waiting-answer', message: '当前题已有选择或反馈,正在等待同题标准答案入库,不会重复提交。' });
return;
}
const target = view.controls.find(control => control.text === '正确');
if (!target || !clickable(target.label) || target.input.disabled) throw new Error('当前题“正确”选项不可正常操作,已停止。');
update({ phase: 'selecting', message: `正在通过正常练习提交第 ${view.question.questionIndex + 1} 题,以取得网站标准反馈。` });
if (!assertBeforeClick(view)) return;
target.label.click();
if (!state.active) return;
pending.clicked = true;
update({ phase: 'waiting-answer', answered: state.answered + 1, message: '已提交一次,等待网站标准反馈及同题可信记录;不会重复点击。' });
}
function tick() {
try {
const ctx = context(), view = readView(ctx);
if (state.phase === 'navigating') {
if (now() >= navigation.deadline) throw new Error('换题等待超过15秒,已停止。');
if (!view || view.question.questionIndex === navigation.from) return;
if (view.question.questionIndex !== navigation.target) throw new Error('页面跳到了其他题目,已停止。');
navigation = null;
decide(view);
} else if (state.phase === 'waiting-answer') {
if (!view || view.mark !== pending.mark) throw new Error('等待反馈时题目或选项已变化,已停止。');
if (now() >= pending.deadline) throw new Error('标准反馈或可信入库等待超过15秒,已停止;请人工核对当前题。');
const local = matchingRecord(view.question);
if (!view.feedbackPresent || local.kind !== 'confirmed') return;
if (local.standard !== view.standard) throw new Error('网站标准反馈与本地入库答案不一致,已停止。');
advance(view, !pending.clicked);
} else {
if (!view) throw new Error('当前题目或选项尚不完整,已停止。');
if (view.question.questionIndex !== 0 && visited.size === 0) beginNavigation(view, 0);
else decide(view);
}
} catch (error) { stop(error.message || '练习补齐遇到异常,已停止。'); }
finally { schedule(); }
}
function start() {
if (destroyed || state.active) return getState();
try {
if (!core?.mapAnswers) throw new Error('题库核验模块不可用。');
binding = null;
const ctx = context(), view = readView(ctx);
if (!view) throw new Error('当前判断练习题目未完整加载,不能启动。');
binding = { bankId: ctx.bank.bankId, bankName: ctx.bank.bankName, taskId: ctx.taskId, total: ctx.total, route: ctx.route };
visited.clear(); pending = null; navigation = null; indexSource = null; indexLength = -1; recordIndex = null; idIndex = null;
generation++;
update({ active: true, phase: 'checking', reason: '', answered: 0, skipped: 0, current: view.question.questionIndex + 1,
total: ctx.total, bankId: binding.bankId, bankName: binding.bankName, taskId: binding.taskId,
message: '练习补齐已手动启动,将从第1题核验本题库;仅使用正常练习控件。' });
schedule();
} catch (error) { stop(error.message || '不能启动练习补齐。'); }
return getState();
}
const routeChanged = () => { if (state.active) stop('页面地址已变化,练习补齐已停止。'); };
win.addEventListener?.('hashchange', routeChanged);
win.addEventListener?.('popstate', routeChanged);
win.addEventListener?.('pagehide', routeChanged);
function destroy() {
stop('练习补齐已关闭。'); destroyed = true;
win.removeEventListener?.('hashchange', routeChanged);
win.removeEventListener?.('popstate', routeChanged);
win.removeEventListener?.('pagehide', routeChanged);
}
return { start, stop, getState, destroy };
}
return { create };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBCollector;
// ---- src/app.js ----
// UI and persistence stay inside the userscript sandbox. Page data is rendered as text.
(() => {
@@ -2273,10 +2538,46 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
let status = loadError || cleanupNotice || '可输入文字或划选题干检索。';
let storageError = false;
let mounted = false;
let collector = null, collectorInitError = '';
const createAdapter = () => QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged, observeNetwork: networkEnabled });
let adapter = createAdapter();
const autoAnswer = QBAuto.create({ window: pageWindow, document, core: QBCore, getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank,
onStatus: message => { tell(message); }, onChange: () => { renderAuto(); } });
try {
if (typeof QBCollector !== 'undefined') collector = QBCollector.create({ window: pageWindow, document, core: QBCore,
getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank,
isCollecting: () => networkEnabled && config.collecting && !loadError && !storageError && !importing,
onChange: () => { if (mounted && config.mode === 'open' && config.tab === 'auto') renderAuto(); } });
} catch (error) { collectorInitError = `练习补齐模块加载失败:${error.message}`; }
function stopCollector(message) {
if (!collector) return;
try { if (collector.getState().active) collector.stop(message); }
catch (error) { tell(`停止练习补齐失败:${error.message}`, true); }
}
function collectorBlockReason() {
if (!collector) return collectorInitError || '练习补齐模块未加载,请更新完整脚本。';
if (!networkEnabled || !['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(window.location.hostname)) return '仅支持华谊网站当前判断题练习。';
if (!config.collecting) return '请先继续题库采集,再启动练习补齐。';
if (loadError || storageError || importing) return '题库读取、保存或导入尚未就绪,练习补齐已停用。';
const bank = currentBank();
if (!bank?.bankId || bank.loading || currentQuestion()?.type !== 'judge' ||
!document.querySelector('.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form[practice]')) return '请先打开当前判断题的正常练习页面。';
return '';
}
function startCollector() {
const blocked = collectorBlockReason();
if (blocked) { tell(blocked, true); renderAuto(); return; }
try {
autoAnswer.setEnabled(false);
collector.start();
renderAuto();
} catch (error) {
stopCollector(`练习补齐失败:${error.message}`);
tell(`练习补齐失败:${error.message}`, true);
renderAuto();
}
}
function activateKnownPage() {
if (!networkEnabled && document.querySelector('.yxtulcdsdk-pc-marking-main')) {
@@ -2292,6 +2593,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
function bankChanged(bank) {
if (!bank || (!bank.bankName && !bank.bankId)) {
stopCollector('当前练习题库已离开,练习补齐已停止。');
if (activeBank) {
activeBank = null; scope = ''; searchScope = '';
renderVisible();
@@ -2303,6 +2605,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
const identityChanged = previous && (!sameName || (previous.taskId && bank.taskId && previous.taskId !== bank.taskId) || (previous.bankId && bank.bankId && previous.bankId !== bank.bankId));
const changed = JSON.stringify(previous) !== JSON.stringify(bank);
activeBank = { ...bank };
if (identityChanged || bank.loading) stopCollector('题库正在切换,练习补齐已停止。');
if (identityChanged) { scope = '__current__'; searchScope = '__current__'; }
if (changed) renderVisible();
}
@@ -2340,6 +2643,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
storageError = false;
} catch (error) {
storageError = true;
stopCollector('题库保存失败,练习补齐已停止,请先备份数据。');
tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true);
}
}
@@ -2408,7 +2712,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
recordsDirty = true;
schedulePersist();
renderVisible();
} catch (error) { tell(`这批题目未保存:${error.message}`, true); }
} catch (error) { stopCollector('题目未能保存,练习补齐已停止。'); tell(`这批题目未保存:${error.message}`, true); }
}
function tell(message, error = false) {
status = message;
@@ -2575,12 +2879,18 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
shadow.getElementById('qb-current-bank').textContent = name;
}
function renderAuto() {
if (!shadow || config.mode !== 'open' || config.tab !== 'auto') return;
if (!mounted || !shadow || config.mode !== 'open' || config.tab !== 'auto') return;
const state = autoAnswer.getState();
shadow.getElementById('qb-auto-enabled').checked = state.enabled;
shadow.getElementById('qb-auto-status').textContent = state.message || '自动答题尚未开启。可先点击“答当前题”。';
shadow.getElementById('qb-auto-count').textContent = `本页已选择 ${state.answeredCount || 0} 题 · 已跳过 ${state.skippedCount || 0} 题`;
shadow.querySelector('[data-action="auto-answer-current"]').disabled = Boolean(state.busy);
const collection = collector?.getState() || { active: false, answered: 0, skipped: 0, current: 0, total: 0 };
const blocked = collectorBlockReason();
shadow.getElementById('qb-collector-status').textContent = collection.message || blocked || '练习补齐默认关闭;仅在点击启动后运行。';
shadow.getElementById('qb-collector-count').textContent = `本次已作答 ${collection.answered || 0} 题 · 已跳过 ${collection.skipped || 0} 题 · 当前 ${collection.current || 0}/${collection.total || 0}`;
shadow.querySelector('[data-action="start-collector"]').disabled = Boolean(collection.active || blocked);
shadow.querySelector('[data-action="stop-collector"]').disabled = !collection.active;
}
function matchesCurrentQuestion(record, current) {
if (!current || record.type !== current.type) return false;
@@ -2799,6 +3109,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
</div>
<div class="view" id="qb-view-auto" data-view="auto" role="tabpanel" hidden>
<div class="section"><h3>自动选择正确答案</h3><p>根据本地已确认答案核对题干、题型和完整选项,按当前顺序选择。没有答案、答案冲突或无法唯一匹配的题目会跳过。</p><button type="button" class="btn primary" data-action="auto-answer-current">答当前题</button><label class="check"><input id="qb-auto-enabled" type="checkbox"><span>连续自动选择<small>开启后随题目变化自动选择,仅本页有效;刷新或打开新页面后关闭。</small></span></label><p id="qb-auto-status" role="status"></p><p id="qb-auto-count"></p><p>选择后按页面流程检查、提交或换题;部分网站会自动保存作答。</p></div>
<div class="section"><h3>补齐判断题标准答案</h3><p>会逐题进行正常练习作答并改变练习记录;以网站标准反馈保存。只限当前判断练习,刷新默认关闭。隐藏面板后仍会继续;需要停止时请点击“停止补齐”。</p><div class="row"><button type="button" class="btn primary" data-action="start-collector">补齐当前判断题库</button><button type="button" class="btn" data-action="stop-collector" disabled>停止补齐</button></div><p id="qb-collector-status" role="status">练习补齐默认关闭。</p><p id="qb-collector-count"></p></div>
<div class="section"><h3>适配范围</h3><p>支持华谊练习页面,以及使用标准单选框/复选框和明确题干容器的网页。网页未识别时可继续使用文字搜索。</p></div>
</div>
<div class="view" id="qb-view-settings" data-view="settings" role="tabpanel" hidden>
@@ -2830,7 +3141,9 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
case 'collapse': setMode(config.mode === 'collapsed' ? 'open' : 'collapsed'); break;
case 'search': runSearch(shadow.getElementById('qb-search').value); break;
case 'clear-search': clearTimeout(searchTimer); runSearch(''); shadow.getElementById('qb-search').focus(); break;
case 'auto-answer-current': Promise.resolve(autoAnswer.answerCurrent()).catch(error => tell(`自动选择失败:${error.message}`, true)); break;
case 'auto-answer-current': stopCollector('已切换为答当前题,练习补齐已停止。'); Promise.resolve(autoAnswer.answerCurrent()).catch(error => tell(`自动选择失败:${error.message}`, true)); break;
case 'start-collector': startCollector(); break;
case 'stop-collector': stopCollector('已手动停止练习补齐。'); renderAuto(); break;
case 'capture-current': {
const captured = adapter.captureCurrent();
renderCurrent();
@@ -2839,6 +3152,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
}
case 'toggle-capture':
config.collecting = !config.collecting;
if (!config.collecting) stopCollector('题库采集已暂停,练习补齐已停止。');
config.collecting ? adapter.start() : adapter.stop();
adapter.setCopyEnabled(Boolean(config.copyEnabled));
persistConfig(); renderStats();
@@ -2864,13 +3178,18 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
shadow.getElementById('qb-scope').addEventListener('change', event => { scope = event.target.value; renderStats(); });
shadow.getElementById('qb-search-scope').addEventListener('change', event => { searchScope = event.target.value; renderResults(); });
shadow.getElementById('qb-search-field').addEventListener('change', event => { searchField = event.target.value; renderResults(); });
shadow.getElementById('qb-auto-enabled').addEventListener('change', event => { autoAnswer.setEnabled(event.target.checked); renderAuto(); });
shadow.getElementById('qb-auto-enabled').addEventListener('change', event => {
const enabled = event.target.checked;
if (enabled) stopCollector('已启用连续自动选择,练习补齐已停止。');
autoAnswer.setEnabled(enabled); renderAuto();
});
shadow.getElementById('qb-copy-enabled').addEventListener('change', event => { config.copyEnabled = event.target.checked; adapter.setCopyEnabled(config.copyEnabled); persistConfig(); });
shadow.getElementById('qb-import-file').addEventListener('change', async event => {
const input = event.currentTarget;
if (importing) return;
const files = Array.from(input.files || []);
importing = true;
stopCollector('开始导入题库,练习补齐已停止。');
const button = shadow.querySelector('[data-action="import"]');
button.disabled = true;
try { await importFiles(files); }
@@ -2902,9 +3221,9 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden');
}
}, true);
window.addEventListener('pagehide', () => { autoAnswer.setEnabled(false); persistRecords(); });
window.addEventListener('pagehide', () => { stopCollector('页面已离开,练习补齐已停止。'); autoAnswer.setEnabled(false); persistRecords(); });
window.addEventListener('pageshow', event => {
if (event.persisted) { autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); }
if (event.persisted) { stopCollector('页面已恢复,练习补齐保持关闭。'); autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); }
});
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "huayi-question-bank",
"version": "1.2.6",
"version": "1.2.7",
"private": true,
"description": "华谊练习题库本地采集、CSV 导出与划词检索油猴脚本",
"scripts": {
"build": "node build.cjs",
"merge:banks": "node scripts/merge-question-banks.cjs",
"select:snapshots": "node scripts/select-bank-snapshots.cjs",
"test": "node --test tests/core.test.cjs tests/adapter.test.cjs tests/autofill.test.cjs tests/select-bank-snapshots.test.cjs",
"test": "node --test tests/core.test.cjs tests/adapter.test.cjs tests/autofill.test.cjs tests/collector.test.cjs tests/select-bank-snapshots.test.cjs",
"test:browser": "node --test tests/integration.test.cjs",
"test:performance": "node --test tests/performance-browser.test.cjs",
"check": "node build.cjs && node --check dist/huayi-question-bank.user.js"
+60 -6
View File
@@ -173,10 +173,46 @@
let status = loadError || cleanupNotice || '可输入文字或划选题干检索。';
let storageError = false;
let mounted = false;
let collector = null, collectorInitError = '';
const createAdapter = () => QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged, observeNetwork: networkEnabled });
let adapter = createAdapter();
const autoAnswer = QBAuto.create({ window: pageWindow, document, core: QBCore, getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank,
onStatus: message => { tell(message); }, onChange: () => { renderAuto(); } });
try {
if (typeof QBCollector !== 'undefined') collector = QBCollector.create({ window: pageWindow, document, core: QBCore,
getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank,
isCollecting: () => networkEnabled && config.collecting && !loadError && !storageError && !importing,
onChange: () => { if (mounted && config.mode === 'open' && config.tab === 'auto') renderAuto(); } });
} catch (error) { collectorInitError = `练习补齐模块加载失败:${error.message}`; }
function stopCollector(message) {
if (!collector) return;
try { if (collector.getState().active) collector.stop(message); }
catch (error) { tell(`停止练习补齐失败:${error.message}`, true); }
}
function collectorBlockReason() {
if (!collector) return collectorInitError || '练习补齐模块未加载,请更新完整脚本。';
if (!networkEnabled || !['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(window.location.hostname)) return '仅支持华谊网站当前判断题练习。';
if (!config.collecting) return '请先继续题库采集,再启动练习补齐。';
if (loadError || storageError || importing) return '题库读取、保存或导入尚未就绪,练习补齐已停用。';
const bank = currentBank();
if (!bank?.bankId || bank.loading || currentQuestion()?.type !== 'judge' ||
!document.querySelector('.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form[practice]')) return '请先打开当前判断题的正常练习页面。';
return '';
}
function startCollector() {
const blocked = collectorBlockReason();
if (blocked) { tell(blocked, true); renderAuto(); return; }
try {
autoAnswer.setEnabled(false);
collector.start();
renderAuto();
} catch (error) {
stopCollector(`练习补齐失败:${error.message}`);
tell(`练习补齐失败:${error.message}`, true);
renderAuto();
}
}
function activateKnownPage() {
if (!networkEnabled && document.querySelector('.yxtulcdsdk-pc-marking-main')) {
@@ -192,6 +228,7 @@
function bankChanged(bank) {
if (!bank || (!bank.bankName && !bank.bankId)) {
stopCollector('当前练习题库已离开,练习补齐已停止。');
if (activeBank) {
activeBank = null; scope = ''; searchScope = '';
renderVisible();
@@ -203,6 +240,7 @@
const identityChanged = previous && (!sameName || (previous.taskId && bank.taskId && previous.taskId !== bank.taskId) || (previous.bankId && bank.bankId && previous.bankId !== bank.bankId));
const changed = JSON.stringify(previous) !== JSON.stringify(bank);
activeBank = { ...bank };
if (identityChanged || bank.loading) stopCollector('题库正在切换,练习补齐已停止。');
if (identityChanged) { scope = '__current__'; searchScope = '__current__'; }
if (changed) renderVisible();
}
@@ -240,6 +278,7 @@
storageError = false;
} catch (error) {
storageError = true;
stopCollector('题库保存失败,练习补齐已停止,请先备份数据。');
tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true);
}
}
@@ -308,7 +347,7 @@
recordsDirty = true;
schedulePersist();
renderVisible();
} catch (error) { tell(`这批题目未保存:${error.message}`, true); }
} catch (error) { stopCollector('题目未能保存,练习补齐已停止。'); tell(`这批题目未保存:${error.message}`, true); }
}
function tell(message, error = false) {
status = message;
@@ -475,12 +514,18 @@
shadow.getElementById('qb-current-bank').textContent = name;
}
function renderAuto() {
if (!shadow || config.mode !== 'open' || config.tab !== 'auto') return;
if (!mounted || !shadow || config.mode !== 'open' || config.tab !== 'auto') return;
const state = autoAnswer.getState();
shadow.getElementById('qb-auto-enabled').checked = state.enabled;
shadow.getElementById('qb-auto-status').textContent = state.message || '自动答题尚未开启。可先点击“答当前题”。';
shadow.getElementById('qb-auto-count').textContent = `本页已选择 ${state.answeredCount || 0} 题 · 已跳过 ${state.skippedCount || 0} 题`;
shadow.querySelector('[data-action="auto-answer-current"]').disabled = Boolean(state.busy);
const collection = collector?.getState() || { active: false, answered: 0, skipped: 0, current: 0, total: 0 };
const blocked = collectorBlockReason();
shadow.getElementById('qb-collector-status').textContent = collection.message || blocked || '练习补齐默认关闭;仅在点击启动后运行。';
shadow.getElementById('qb-collector-count').textContent = `本次已作答 ${collection.answered || 0} 题 · 已跳过 ${collection.skipped || 0} 题 · 当前 ${collection.current || 0}/${collection.total || 0}`;
shadow.querySelector('[data-action="start-collector"]').disabled = Boolean(collection.active || blocked);
shadow.querySelector('[data-action="stop-collector"]').disabled = !collection.active;
}
function matchesCurrentQuestion(record, current) {
if (!current || record.type !== current.type) return false;
@@ -699,6 +744,7 @@
</div>
<div class="view" id="qb-view-auto" data-view="auto" role="tabpanel" hidden>
<div class="section"><h3>自动选择正确答案</h3><p>根据本地已确认答案核对题干、题型和完整选项,按当前顺序选择。没有答案、答案冲突或无法唯一匹配的题目会跳过。</p><button type="button" class="btn primary" data-action="auto-answer-current">答当前题</button><label class="check"><input id="qb-auto-enabled" type="checkbox"><span>连续自动选择<small>开启后随题目变化自动选择,仅本页有效;刷新或打开新页面后关闭。</small></span></label><p id="qb-auto-status" role="status"></p><p id="qb-auto-count"></p><p>选择后按页面流程检查、提交或换题;部分网站会自动保存作答。</p></div>
<div class="section"><h3>补齐判断题标准答案</h3><p>会逐题进行正常练习作答并改变练习记录;以网站标准反馈保存。只限当前判断练习,刷新默认关闭。隐藏面板后仍会继续;需要停止时请点击“停止补齐”。</p><div class="row"><button type="button" class="btn primary" data-action="start-collector">补齐当前判断题库</button><button type="button" class="btn" data-action="stop-collector" disabled>停止补齐</button></div><p id="qb-collector-status" role="status">练习补齐默认关闭。</p><p id="qb-collector-count"></p></div>
<div class="section"><h3>适配范围</h3><p>支持华谊练习页面,以及使用标准单选框/复选框和明确题干容器的网页。网页未识别时可继续使用文字搜索。</p></div>
</div>
<div class="view" id="qb-view-settings" data-view="settings" role="tabpanel" hidden>
@@ -730,7 +776,9 @@
case 'collapse': setMode(config.mode === 'collapsed' ? 'open' : 'collapsed'); break;
case 'search': runSearch(shadow.getElementById('qb-search').value); break;
case 'clear-search': clearTimeout(searchTimer); runSearch(''); shadow.getElementById('qb-search').focus(); break;
case 'auto-answer-current': Promise.resolve(autoAnswer.answerCurrent()).catch(error => tell(`自动选择失败:${error.message}`, true)); break;
case 'auto-answer-current': stopCollector('已切换为答当前题,练习补齐已停止。'); Promise.resolve(autoAnswer.answerCurrent()).catch(error => tell(`自动选择失败:${error.message}`, true)); break;
case 'start-collector': startCollector(); break;
case 'stop-collector': stopCollector('已手动停止练习补齐。'); renderAuto(); break;
case 'capture-current': {
const captured = adapter.captureCurrent();
renderCurrent();
@@ -739,6 +787,7 @@
}
case 'toggle-capture':
config.collecting = !config.collecting;
if (!config.collecting) stopCollector('题库采集已暂停,练习补齐已停止。');
config.collecting ? adapter.start() : adapter.stop();
adapter.setCopyEnabled(Boolean(config.copyEnabled));
persistConfig(); renderStats();
@@ -764,13 +813,18 @@
shadow.getElementById('qb-scope').addEventListener('change', event => { scope = event.target.value; renderStats(); });
shadow.getElementById('qb-search-scope').addEventListener('change', event => { searchScope = event.target.value; renderResults(); });
shadow.getElementById('qb-search-field').addEventListener('change', event => { searchField = event.target.value; renderResults(); });
shadow.getElementById('qb-auto-enabled').addEventListener('change', event => { autoAnswer.setEnabled(event.target.checked); renderAuto(); });
shadow.getElementById('qb-auto-enabled').addEventListener('change', event => {
const enabled = event.target.checked;
if (enabled) stopCollector('已启用连续自动选择,练习补齐已停止。');
autoAnswer.setEnabled(enabled); renderAuto();
});
shadow.getElementById('qb-copy-enabled').addEventListener('change', event => { config.copyEnabled = event.target.checked; adapter.setCopyEnabled(config.copyEnabled); persistConfig(); });
shadow.getElementById('qb-import-file').addEventListener('change', async event => {
const input = event.currentTarget;
if (importing) return;
const files = Array.from(input.files || []);
importing = true;
stopCollector('开始导入题库,练习补齐已停止。');
const button = shadow.querySelector('[data-action="import"]');
button.disabled = true;
try { await importFiles(files); }
@@ -802,9 +856,9 @@
event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden');
}
}, true);
window.addEventListener('pagehide', () => { autoAnswer.setEnabled(false); persistRecords(); });
window.addEventListener('pagehide', () => { stopCollector('页面已离开,练习补齐已停止。'); autoAnswer.setEnabled(false); persistRecords(); });
window.addEventListener('pageshow', event => {
if (event.persisted) { autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); }
if (event.persisted) { stopCollector('页面已恢复,练习补齐保持关闭。'); autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); }
});
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
+262
View File
@@ -0,0 +1,262 @@
/* Opt-in completion of one Huayi judgment practice, through ordinary DOM clicks. */
const QBCollector = (() => {
'use strict';
const MAIN = '.yxtulcdsdk-pc-marking-main';
const FORM = `${MAIN} .yxtulcdsdk-form[practice]`;
const EVIDENCE = new Set(['submitted-list', 'answer-response', 'bound-dom', 'explicit-standard']);
const trim = value => String(value == null ? '' : value).replace(/\u00a0/g, ' ').trim();
const flat = value => trim(value).replace(/\s+/gu, '');
const optionsKey = question => JSON.stringify((question.options || []).map(option => [trim(option.id), flat(option.text)]).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))));
const identity = question => JSON.stringify([question.bankId, question.questionId, question.questionIndex, flat(question.stem), optionsKey(question)]);
const evidence = record => EVIDENCE.has(record.answerEvidence) || (record.answerSource || record.source)?.kind === 'network' &&
/\/upm\/[^/]+\/users\/[^/]+\/(?:wrong\/)?answer(?:[?#]|$)/u.test((record.answerSource || record.source)?.url || '');
function create({ window: win, document: doc, core, getRecords = () => [], getQuestion = () => null,
getBank = () => null, isCollecting = () => false, onChange = () => {} }) {
let state = { active: false, phase: 'idle', message: '练习补齐未启动。', reason: '', answered: 0, skipped: 0,
current: 0, total: 0, bankId: '', bankName: '', taskId: '' };
let destroyed = false, timer = null, generation = 0, binding = null, pending = null, navigation = null;
let indexSource = null, indexLength = -1, recordIndex = null, idIndex = null;
const visited = new Map();
const now = () => win.Date?.now ? win.Date.now() : Date.now();
const getState = () => ({ ...state });
function update(patch) {
const old = state;
state = { ...state, ...patch };
if (old.phase !== state.phase || old.active !== state.active) {
try { onChange(getState()); } catch (_) { /* UI code cannot grant further actions. */ }
}
}
function stop(reason = '已停止练习补齐。') {
generation++;
if (timer !== null) win.clearTimeout(timer);
timer = null; pending = null; navigation = null;
update({ active: false, phase: 'stopped', reason: trim(reason), message: trim(reason) || '已停止练习补齐。' });
return getState();
}
function visible(node) {
if (!node || node.isConnected === false || node.closest?.('#huayi-qb-root')) return false;
for (let current = node; current && current.nodeType === 1; current = current.parentElement) {
if (current.hidden || current.getAttribute?.('aria-hidden') === 'true') return false;
const style = win.getComputedStyle?.(current);
if (style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse') return false;
}
return true;
}
function context() {
const url = new URL(String(win.location?.href || ''));
if (!['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(url.hostname) ||
url.pathname !== '/o2o/' || !/^#\/playinfo(?:\?|$)/u.test(url.hash)) throw new Error('仅支持华谊培训大纲中的判断练习页面。');
const params = new URLSearchParams(url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : '');
if (params.get('wq') === '1' || url.searchParams.get('wq') === '1' || /wrong/iu.test(url.hash)) throw new Error('错题重做不属于本次完整题库补齐范围。');
if (!isCollecting()) throw new Error('采集已关闭或题库存储不可用,练习补齐已停止。');
const selected = doc.querySelector('.task-content-active');
if (selected && (!/练习/u.test(selected.textContent || '') || /考试/u.test(selected.textContent || ''))) throw new Error('当前培训任务不是明确的练习,不能启动。');
const forms = [...doc.querySelectorAll(FORM)].filter(visible);
if (forms.length !== 1) throw new Error('当前页面不是唯一的正常练习题,正式考试或不完整页面不能补齐。');
const form = forms[0], main = form.closest(MAIN);
if (!main || !visible(main)) throw new Error('练习题目区域尚未完整显示。');
const heading = trim(main.querySelector('.standard-size-14 span')?.textContent);
if (heading !== '判断题') throw new Error('本功能只支持判断题练习。');
const bank = getBank();
const taskId = trim(bank?.taskId || params.get('taskId'));
const total = bank?.questionCount;
if (!bank || bank.loading || !trim(bank.bankId) || !taskId || ![500, 232].includes(total)) throw new Error('当前题库身份或页面总题数未确认,请等待题库加载。');
const progress = [...doc.querySelectorAll('.yxtulcdsdk-answer-card__top')].filter(visible);
const counts = progress.map(node => trim(node.textContent).match(/(\d+)\s*\/\s*(\d+)(?!\d)/u)).filter(Boolean);
if (counts.length !== 1 || Number(counts[0][2]) !== total || Number(counts[0][1]) > total) throw new Error('页面答题卡总数与当前题库不一致,已停止。');
const route = `${url.pathname}${url.search}${url.hash}`;
if (binding && (bank.bankId !== binding.bankId || taskId !== binding.taskId || route !== binding.route || total !== binding.total)) {
throw new Error('题库、任务或地址已变化,练习补齐已停止。');
}
return { bank, taskId, total, route, form, main };
}
function readView(ctx) {
const question = getQuestion();
const number = trim(ctx.form.querySelector('.yxtulcdsdk-form__sub')?.textContent).match(/^(\d+)\s*[..、]?$/u);
if (!number || !question || question.type !== 'judge' || question.bankId !== ctx.bank.bankId || !trim(question.questionId) ||
!Number.isSafeInteger(question.questionIndex) || question.questionIndex !== Number(number[1]) - 1 ||
question.questionIndex < 0 || question.questionIndex >= ctx.total || question.options?.length !== 2 || !trim(question.stem)) return null;
const labels = [...ctx.form.querySelectorAll('.yxtulcdsdk-ques-radio')].filter(visible);
if (labels.length !== 2) return null;
const controls = labels.map(label => ({ label, input: label.querySelector('input[type="radio"]'),
text: trim(label.querySelector('.yxtf-radio__label')?.textContent || label.textContent) }));
if (controls.some(control => !control.input || !control.input.isConnected || !control.input.value) ||
controls.some((control, position) => control.text !== trim(question.options[position].text) || trim(control.input.value) !== trim(question.options[position].id)) ||
new Set(controls.map(control => control.text)).size !== 2 || !controls.some(control => control.text === '正确') || !controls.some(control => control.text === '错误')) return null;
const feedback = [...ctx.main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible).filter(row =>
['正确答案', '标准答案'].includes(trim(row.querySelector('.yxtulcdsdk-examing__result-line-left')?.textContent)));
if (feedback.length > 1) throw new Error('当前题出现多个标准答案展示,已停止核验。');
let standard = '';
if (feedback.length) {
const row = feedback[0];
const rowId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id'].map(name => trim(row.getAttribute(name))).find(Boolean);
if (rowId && rowId !== question.questionId) throw new Error('页面标准答案仍属于另一道题,已停止。');
standard = trim(row.querySelector('.yxtulcdsdk-examing__result-line-right')?.textContent);
if (!['正确', '错误'].includes(standard)) throw new Error('网站标准答案尚不明确,已停止。');
}
return { ...ctx, question, controls, standard, feedbackPresent: feedback.length === 1,
checked: controls.some(control => control.input.checked), mark: identity(question) };
}
function recordsAt(index) {
const records = getRecords();
if (!Array.isArray(records)) throw new Error('本地题库不可读取,已停止。');
if (indexSource !== records || indexLength !== records.length) {
indexSource = records; indexLength = records.length; recordIndex = new Map(); idIndex = new Map();
for (const record of records) {
if (!record || record.bankId !== binding.bankId || !Number.isSafeInteger(record.questionIndex)) continue;
if (!recordIndex.has(record.questionIndex)) recordIndex.set(record.questionIndex, []);
recordIndex.get(record.questionIndex).push(record);
for (const id of new Set([record.questionId, ...(record.questionIdAliases || [])].filter(Boolean))) {
if (!idIndex.has(id)) idIndex.set(id, []);
idIndex.get(id).push(record);
}
}
}
return recordIndex.get(index) || [];
}
function matchingRecord(question) {
const candidates = recordsAt(question.questionIndex).filter(record => record.type === 'judge' && flat(record.stem) === flat(question.stem) && optionsKey(record) === optionsKey(question) &&
[record.questionId, ...(record.questionIdAliases || [])].includes(question.questionId));
if (candidates.length > 1) throw new Error('同一题目位置存在多个匹配记录,已停止避免重复提交。');
const record = candidates[0];
if (!record) {
throw new Error('当前题不能与本地题库的位置、完整内容及题目ID唯一对应,已停止。');
}
if ((idIndex.get(question.questionId) || []).length !== 1) throw new Error('当前题目ID或别名对应多个原题位置,已停止。');
if (record.answerStatus === 'conflict' || record.conflicts?.length) throw new Error('本地当前题标准答案存在冲突,已停止。');
if (record.answerStatus === 'missing') return { kind: 'missing', record };
if (record.answerStatus !== 'confirmed' || !evidence(record)) throw new Error('当前题的已保存答案缺少可核验来源,已停止。');
const mapped = core.mapAnswers(record, question);
if (mapped.status !== 'matched' || mapped.answerTexts.length !== 1 || !['正确', '错误'].includes(mapped.answerTexts[0])) throw new Error('标准答案不能唯一对应当前题目,已停止。');
return { kind: 'confirmed', record, standard: mapped.answerTexts[0] };
}
function clickable(element) {
return visible(element) && typeof element.click === 'function' && !element.disabled && element.getAttribute?.('aria-disabled') !== 'true' &&
!element.closest?.('[aria-disabled="true"]');
}
function assertBeforeClick(view) {
if (!state.active || destroyed) return false;
const fresh = readView(context());
if (!fresh || fresh.mark !== view.mark || fresh.form !== view.form) throw new Error('点击前题目身份发生变化,已停止。');
return true;
}
function schedule() {
if (!state.active || destroyed || timer !== null) return;
const token = generation;
timer = win.setTimeout(() => { timer = null; if (state.active && !destroyed && token === generation) tick(); }, 300);
}
function beginNavigation(view, target) {
let button;
if (target === 0 && view.question.questionIndex !== 0) {
const matches = [...doc.querySelectorAll('.yxtulcdsdk-pc-card-list li')].filter(node => visible(node) && trim(node.textContent) === '1');
if (matches.length !== 1) throw new Error('答题卡第1题入口不唯一,不能安全从头核验。');
button = matches[0];
} else {
const matches = [...view.main.querySelectorAll('.yxtulcdsdk-examing__btns button')].filter(node => visible(node) && trim(node.textContent) === '下一题');
if (matches.length !== 1) throw new Error('没有唯一的下一题按钮;不会点击结束、提交或交卷。');
button = matches[0];
}
if (!clickable(button)) throw new Error('换题按钮暂不可用,已停止保留当前结果。');
navigation = { from: view.question.questionIndex, target, deadline: now() + 15000 };
update({ phase: 'navigating', message: `正在核对第 ${target + 1} 题。` });
if (!assertBeforeClick(view)) return;
button.click();
}
function finish() {
if (!state.active) return;
if (visited.size !== binding.total) throw new Error('本次尚未核对完整题库,不能标记完成。');
for (let index = 0; index < binding.total; index++) {
const question = visited.get(index);
if (!question || matchingRecord(question).kind !== 'confirmed') throw new Error(`第 ${index + 1} 题标准答案尚未完整核验,已停止。`);
}
generation++;
if (timer !== null) win.clearTimeout(timer);
timer = null; pending = null; navigation = null;
update({ active: false, phase: 'completed', reason: '', message: `已核验 ${binding.total} 道判断题。本次提交 ${state.answered} 题,跳过 ${state.skipped} 道已有标准答案的题目。` });
}
function advance(view, skipped) {
visited.set(view.question.questionIndex, { ...view.question, options: view.question.options.map(option => ({ ...option })) });
if (skipped) state = { ...state, skipped: state.skipped + 1 };
pending = null;
if (view.question.questionIndex === binding.total - 1) return finish();
beginNavigation(view, view.question.questionIndex + 1);
}
function decide(view) {
update({ phase: 'checking', current: view.question.questionIndex + 1, message: `正在核验第 ${view.question.questionIndex + 1} / ${binding.total} 题。` });
if (!state.active) return;
const local = matchingRecord(view.question);
if (local.kind === 'confirmed') {
if (view.feedbackPresent && view.standard !== local.standard) throw new Error('网页反馈与本地标准答案不一致,已停止保留冲突。');
return advance(view, true);
}
pending = { mark: view.mark, questionIndex: view.question.questionIndex, deadline: now() + 15000, clicked: false };
if (view.feedbackPresent || view.checked) {
update({ phase: 'waiting-answer', message: '当前题已有选择或反馈,正在等待同题标准答案入库,不会重复提交。' });
return;
}
const target = view.controls.find(control => control.text === '正确');
if (!target || !clickable(target.label) || target.input.disabled) throw new Error('当前题“正确”选项不可正常操作,已停止。');
update({ phase: 'selecting', message: `正在通过正常练习提交第 ${view.question.questionIndex + 1} 题,以取得网站标准反馈。` });
if (!assertBeforeClick(view)) return;
target.label.click();
if (!state.active) return;
pending.clicked = true;
update({ phase: 'waiting-answer', answered: state.answered + 1, message: '已提交一次,等待网站标准反馈及同题可信记录;不会重复点击。' });
}
function tick() {
try {
const ctx = context(), view = readView(ctx);
if (state.phase === 'navigating') {
if (now() >= navigation.deadline) throw new Error('换题等待超过15秒,已停止。');
if (!view || view.question.questionIndex === navigation.from) return;
if (view.question.questionIndex !== navigation.target) throw new Error('页面跳到了其他题目,已停止。');
navigation = null;
decide(view);
} else if (state.phase === 'waiting-answer') {
if (!view || view.mark !== pending.mark) throw new Error('等待反馈时题目或选项已变化,已停止。');
if (now() >= pending.deadline) throw new Error('标准反馈或可信入库等待超过15秒,已停止;请人工核对当前题。');
const local = matchingRecord(view.question);
if (!view.feedbackPresent || local.kind !== 'confirmed') return;
if (local.standard !== view.standard) throw new Error('网站标准反馈与本地入库答案不一致,已停止。');
advance(view, !pending.clicked);
} else {
if (!view) throw new Error('当前题目或选项尚不完整,已停止。');
if (view.question.questionIndex !== 0 && visited.size === 0) beginNavigation(view, 0);
else decide(view);
}
} catch (error) { stop(error.message || '练习补齐遇到异常,已停止。'); }
finally { schedule(); }
}
function start() {
if (destroyed || state.active) return getState();
try {
if (!core?.mapAnswers) throw new Error('题库核验模块不可用。');
binding = null;
const ctx = context(), view = readView(ctx);
if (!view) throw new Error('当前判断练习题目未完整加载,不能启动。');
binding = { bankId: ctx.bank.bankId, bankName: ctx.bank.bankName, taskId: ctx.taskId, total: ctx.total, route: ctx.route };
visited.clear(); pending = null; navigation = null; indexSource = null; indexLength = -1; recordIndex = null; idIndex = null;
generation++;
update({ active: true, phase: 'checking', reason: '', answered: 0, skipped: 0, current: view.question.questionIndex + 1,
total: ctx.total, bankId: binding.bankId, bankName: binding.bankName, taskId: binding.taskId,
message: '练习补齐已手动启动,将从第1题核验本题库;仅使用正常练习控件。' });
schedule();
} catch (error) { stop(error.message || '不能启动练习补齐。'); }
return getState();
}
const routeChanged = () => { if (state.active) stop('页面地址已变化,练习补齐已停止。'); };
win.addEventListener?.('hashchange', routeChanged);
win.addEventListener?.('popstate', routeChanged);
win.addEventListener?.('pagehide', routeChanged);
function destroy() {
stop('练习补齐已关闭。'); destroyed = true;
win.removeEventListener?.('hashchange', routeChanged);
win.removeEventListener?.('popstate', routeChanged);
win.removeEventListener?.('pagehide', routeChanged);
}
return { start, stop, getState, destroy };
}
return { create };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBCollector;
+245
View File
@@ -0,0 +1,245 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const core = require('../src/core.js');
const { create } = require('../src/collector.js');
function fixture({ total = 500, current = 0, missing = [], reply = true, replyDelay = 900 } = {}) {
let clock = 0, sequence = 0;
const timers = new Map(), events = new EventTarget(), actions = [], changes = [];
const base = index => ({ bankId: 'bank-1', bankName: '判断练习题库1', questionId: `q-${index}`, questionIndex: index,
type: 'judge', stem: `第${index + 1}项设备检查结论。`, options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }],
answerStatus: 'confirmed', correctOptionIds: [index % 2 ? '1' : '0'], answerTexts: [index % 2 ? '正确' : '错误'],
answerEvidence: 'submitted-list', source: { kind: 'network', url: 'https://api.yunxuetang.cn/ote/upm/start', observedAt: '2026-10-30T00:00:00Z' } });
const state = { current, total, collecting: true, practice: true, heading: '判断题', selected: '判断题库1 练习 | 必修',
bank: { bankId: 'bank-1', bankName: '判断练习题库1', taskId: 'task-1', questionCount: total, loading: false },
records: Array.from({ length: total }, (_, index) => core.normalizeRecord(missing.includes(index)
? { ...base(index), answerStatus: 'missing', answerTexts: [], correctOptionIds: [], answerEvidence: undefined } : base(index))),
checked: false, feedback: '', feedbackId: '', questionOverride: null, numberOverride: null, disabled: false, reply, replyDelay };
const win = {
location: { href: 'https://www.shhuayi-elearning.cn/o2o/#/playinfo?projectid=project-1&taskId=task-1&gwnlCourse=0' },
Date: { now: () => clock }, getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
setTimeout(fn, delay) { const id = ++sequence; timers.set(id, { fn, at: clock + delay }); return id; },
clearTimeout(id) { timers.delete(id); },
addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events),
fetch() { assert.fail('collector不得调用fetch'); }, XMLHttpRequest: class { constructor() { assert.fail('collector不得调用XHR'); } }
};
const question = () => ({ ...base(state.current), answerStatus: 'missing', answerTexts: [], correctOptionIds: [], answerEvidence: undefined, ...state.questionOverride });
function element(overrides = {}) {
return { nodeType: 1, isConnected: true, hidden: false, parentElement: null, disabled: false, textContent: '',
getAttribute: () => null, closest: () => null, ...overrides };
}
function setPosition(index) { state.current = index; state.checked = false; state.feedback = ''; state.feedbackId = ''; state.questionOverride = null; }
const labels = [0, 1].map(position => {
const input = element({ get value() { return question().options[position].id; }, get checked() { return state.checked && position === 0; },
get disabled() { return state.disabled; } });
return element({ get textContent() { return question().options[position].text; }, querySelector(selector) {
if (selector === 'input[type="radio"]') return input;
if (selector === '.yxtf-radio__label') return { textContent: question().options[position].text };
return null;
}, click() {
actions.push({ kind: 'radio', index: state.current, text: this.textContent }); state.checked = true;
if (!state.reply) return;
const submittedIndex = state.current;
win.setTimeout(() => {
if (state.current !== submittedIndex) return;
const record = core.normalizeRecord({ ...base(submittedIndex), answerEvidence: 'answer-response',
answerSource: { kind: 'network', url: 'https://api.yunxuetang.cn/ote/upm/bank-1/users/_/answer', observedAt: '2026-10-30T00:00:01Z' } });
state.records = state.records.map(value => value.questionIndex === submittedIndex ? record : value);
state.feedback = record.answerTexts[0]; state.feedbackId = record.questionId;
}, state.replyDelay);
} });
});
const next = element({ get textContent() { return state.current === total - 1 ? '提交' : '下一题'; }, click() {
if (state.current === total - 1) assert.fail('collector不得点击末题提交');
actions.push({ kind: 'next', index: state.current }); setPosition(state.current + 1);
} });
const main = element({ querySelector(selector) { return selector === '.standard-size-14 span' ? { textContent: state.heading } : null; },
querySelectorAll(selector) {
if (selector === '.yxtulcdsdk-examing__btns button') return [element({ textContent: '上一题', click: () => assert.fail('不应使用上一题') }), next];
if (selector === '.yxtulcdsdk-examing__result-line' && state.feedback) return [element({
getAttribute: name => name === 'data-question-id' ? state.feedbackId || null : null,
querySelector: name => ({ textContent: name === '.yxtulcdsdk-examing__result-line-left' ? '正确答案' : state.feedback })
})];
return [];
} });
const form = element({ closest: selector => selector === '.yxtulcdsdk-pc-marking-main' ? main : null,
querySelector: selector => selector === '.yxtulcdsdk-form__sub' ? { textContent: `${state.numberOverride ?? state.current + 1}.` } : null,
querySelectorAll: selector => selector === '.yxtulcdsdk-ques-radio' ? labels : [] });
const cards = Array.from({ length: total }, (_, index) => element({ textContent: String(index + 1), click() { actions.push({ kind: 'card', index }); setPosition(index); } }));
const doc = { querySelector(selector) { return selector === '.task-content-active' ? { textContent: state.selected } : null; },
querySelectorAll(selector) {
if (selector === '.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form[practice]') return state.practice ? [form] : [];
if (selector === '.yxtulcdsdk-answer-card__top') return [element({ textContent: `进度 0/${total}` })];
if (selector === '.yxtulcdsdk-pc-card-list li') return cards;
return [];
} };
const collector = create({ window: win, document: doc, core, getRecords: () => state.records,
getQuestion: question, getBank: () => state.bank, isCollecting: () => state.collecting, onChange: value => changes.push(value) });
function advance(milliseconds, maxSteps = 15000) {
const end = clock + milliseconds; let steps = 0;
while (true) {
let chosen;
for (const [id, timer] of timers) if (timer.at <= end && (!chosen || timer.at < chosen[1].at)) chosen = [id, timer];
if (!chosen) break;
if (++steps > maxSteps) assert.fail('polling未能停止');
timers.delete(chosen[0]); clock = chosen[1].at; chosen[1].fn();
}
clock = end;
}
return { collector, state, actions, changes, timers, win, doc, events, advance, setPosition, base };
}
test('collector is completely off by default and unsupported pages or exams cannot start', () => {
const f = fixture();
assert.equal(f.collector.getState().active, false); assert.equal(f.timers.size, 0); assert.deepEqual(f.actions, []);
for (const href of ['https://ordinary.example/o2o/#/playinfo?taskId=task-1', 'https://www.shhuayi-elearning.cn/o2o/#/exam?taskId=task-1']) {
f.win.location.href = href; assert.equal(f.collector.start().phase, 'stopped'); assert.deepEqual(f.actions, []);
}
const exam = fixture(); exam.state.practice = false;
assert.equal(exam.collector.start().active, false);
const misleading = fixture(); misleading.state.selected = '化工考试 | 必修';
assert.equal(misleading.collector.start().active, false);
const wrong = fixture(); wrong.win.location.href += '&wq=1';
assert.equal(wrong.collector.start().active, false);
});
test('incomplete identity, nonjudgment type or unconfirmed page count fail without clicks', () => {
for (const mutate of [f => { f.state.bank.bankId = ''; }, f => { f.state.bank.questionCount = 499; },
f => { f.state.heading = '单选题'; }, f => { f.state.questionOverride = { type: 'single' }; },
f => { f.state.numberOverride = 2; }, f => { f.state.collecting = false; }]) {
const f = fixture(); mutate(f);
assert.equal(f.collector.start().phase, 'stopped'); assert.deepEqual(f.actions, []);
}
});
test('only one ordinary radio click is sent and next waits for matching official feedback and trusted storage', () => {
const f = fixture({ missing: [0], reply: false });
f.collector.start(); f.advance(300);
assert.deepEqual(f.actions, [{ kind: 'radio', index: 0, text: '正确' }]);
f.advance(1800); assert.equal(f.actions.length, 1, '等待不能重试提交');
f.state.feedback = '错误'; f.state.feedbackId = 'q-0';
f.advance(600); assert.equal(f.actions.length, 1, '只看到DOM标准、尚未可信入库不能推进');
f.state.records = f.state.records.map((record, index) => index === 0 ? core.normalizeRecord({ ...f.base(0), answerEvidence: 'answer-response' }) : record);
f.advance(300);
assert.equal(f.actions.at(-1).kind, 'next');
assert.equal(f.collector.getState().answered, 1);
f.collector.stop();
});
test('normal feedback can be incorrect for the chosen radio, and the trusted standard is preserved', () => {
const f = fixture({ missing: [0] });
f.collector.start(); f.advance(4000);
assert.equal(f.state.records[0].answerTexts[0], '错误');
assert.equal(f.state.records[0].answerEvidence, 'answer-response');
assert.equal(f.actions.filter(action => action.kind === 'radio').length, 1);
assert.equal(f.collector.getState().active, true); f.collector.stop();
});
test('a missing canonical, wrong alias, stem or options cannot be treated as an unanswered known question', () => {
for (const mutate of [f => { f.state.records = f.state.records.slice(1); },
f => { f.state.questionOverride = { questionId: 'unrelated-id' }; },
f => { f.state.questionOverride = { stem: '另一道题目' }; },
f => { f.state.questionOverride = { options: [{ id: 'new-1', text: '正确', label: '正确' }, { id: 'new-0', text: '错误', label: '错误' }] }; }]) {
const f = fixture({ missing: [0] }); mutate(f); f.collector.start(); f.advance(600);
assert.equal(f.collector.getState().phase, 'stopped'); assert.deepEqual(f.actions, []);
}
});
test('stop cancels queued clicks, duplicate start is single-flight, and destroy cannot restart', () => {
const f = fixture({ missing: [0] });
f.collector.start(); f.collector.start(); assert.equal(f.timers.size, 1);
f.collector.stop('手动停止'); f.advance(2000); assert.deepEqual(f.actions, []);
f.collector.start(); f.collector.destroy(); f.advance(2000); assert.deepEqual(f.actions, []);
assert.equal(f.collector.start().active, false);
});
test('capture pause, bank change and route change each cancel before another click', () => {
for (const mutate of [f => { f.state.collecting = false; }, f => { f.state.bank = { ...f.state.bank, bankId: 'other' }; },
f => { f.win.location.href += '&changed=1'; f.events.dispatchEvent(new Event('hashchange')); }]) {
const f = fixture({ missing: [0] }); f.collector.start(); mutate(f); f.advance(600);
assert.equal(f.collector.getState().active, false); assert.deepEqual(f.actions, []);
}
});
test('feedback from another question and an unexpected DOM transition cannot be used for completion', () => {
const old = fixture({ missing: [0], reply: false });
old.state.feedback = '错误'; old.state.feedbackId = 'q-other'; old.collector.start(); old.advance(600);
assert.equal(old.collector.getState().active, false); assert.deepEqual(old.actions, []);
const switched = fixture({ missing: [0], reply: false });
switched.collector.start(); switched.advance(300); switched.setPosition(1); switched.state.feedback = '正确'; switched.state.feedbackId = 'q-0';
switched.advance(300); assert.equal(switched.collector.getState().active, false);
assert.deepEqual(switched.actions, [{ kind: 'radio', index: 0, text: '正确' }]);
});
test('feedback timeout and genuine storage conflict stop without retries', () => {
const timeout = fixture({ missing: [0], reply: false }); timeout.collector.start(); timeout.advance(16000);
assert.equal(timeout.collector.getState().phase, 'stopped'); assert.match(timeout.collector.getState().reason, /15秒/);
assert.equal(timeout.actions.length, 1);
const conflict = fixture({ missing: [0] }); conflict.state.records[0].answerStatus = 'conflict';
conflict.collector.start(); conflict.advance(600); assert.equal(conflict.collector.getState().active, false); assert.deepEqual(conflict.actions, []);
});
test('already-chosen feedback is not resubmitted and waits for trustworthy matching records', () => {
const f = fixture({ missing: [0], reply: false }); f.state.checked = true; f.state.feedback = '错误'; f.state.feedbackId = 'q-0';
f.collector.start(); f.advance(900); assert.deepEqual(f.actions, []);
f.state.records = f.state.records.map((record, index) => index === 0 ? core.normalizeRecord({ ...f.base(0), answerEvidence: 'bound-dom' }) : record);
f.advance(300); assert.equal(f.actions[0].kind, 'next'); assert.equal(f.collector.getState().answered, 0); assert.equal(f.collector.getState().skipped, 1);
f.collector.stop();
});
test('official feedback conflicting with the newly stored standard stops at that item', () => {
const f = fixture({ missing: [0], reply: false }); f.collector.start(); f.advance(300);
f.state.feedback = '正确'; f.state.feedbackId = 'q-0';
f.state.records = f.state.records.map((record, index) => index === 0 ? core.normalizeRecord({ ...f.base(0), answerEvidence: 'answer-response' }) : record);
f.advance(300); assert.equal(f.collector.getState().active, false); assert.match(f.collector.getState().reason, /不一致/);
assert.equal(f.actions.length, 1);
});
test('known records are skipped, history without positions is ignored and the final submit button is never clicked', () => {
const f = fixture({ current: 66 });
f.state.records.push(core.normalizeRecord({ ...f.base(0), questionId: 'old-history-1', questionIndex: undefined,
answerStatus: 'missing', correctOptionIds: [], answerTexts: [], answerEvidence: undefined }));
f.state.records.push(core.normalizeRecord({ ...f.base(1), questionId: 'old-history-2', questionIndex: undefined,
answerStatus: 'missing', correctOptionIds: [], answerTexts: [], answerEvidence: undefined }));
f.collector.start(); f.advance(200000);
const state = f.collector.getState();
assert.equal(state.phase, 'completed'); assert.equal(state.active, false); assert.equal(state.total, 500); assert.equal(state.current, 500);
assert.equal(state.skipped, 500); assert.equal(state.answered, 0);
assert.equal(f.actions[0].kind, 'card'); assert.equal(f.actions[0].index, 0);
assert.equal(f.actions.filter(action => action.kind === 'next').length, 499);
assert.equal(f.actions.some(action => action.kind === 'radio'), false); assert.equal(f.timers.size, 0);
});
test('the supported 232-question bank completes only after all canonical positions are verified', () => {
const f = fixture({ total: 232, missing: [231] }); f.collector.start(); f.advance(200000);
assert.equal(f.collector.getState().phase, 'completed'); assert.equal(f.collector.getState().answered, 1);
assert.equal(f.actions.filter(action => action.kind === 'radio')[0].index, 231);
assert.equal(f.actions.filter(action => action.kind === 'next').length, 231);
});
test('source evidence is mandatory even if status says confirmed', () => {
const f = fixture(); delete f.state.records[0].answerEvidence;
f.collector.start(); f.advance(300); assert.equal(f.collector.getState().phase, 'stopped'); assert.deepEqual(f.actions, []);
});
test('navigation stalls and duplicate canonical matches fail closed', () => {
const navigation = fixture({ current: 2 });
const original = navigation.doc.querySelectorAll;
navigation.doc.querySelectorAll = selector => selector === '.yxtulcdsdk-pc-card-list li'
? [{ nodeType: 1, isConnected: true, textContent: '1', getAttribute: () => null, closest: () => null, click() { navigation.actions.push({ kind: 'stalled' }); } }]
: original(selector);
navigation.collector.start(); navigation.advance(16000);
assert.equal(navigation.collector.getState().phase, 'stopped'); assert.match(navigation.collector.getState().reason, /15秒/);
const duplicate = fixture({ missing: [0] }); duplicate.state.records.push({ ...duplicate.state.records[0] });
duplicate.collector.start(); duplicate.advance(300); assert.equal(duplicate.collector.getState().phase, 'stopped'); assert.deepEqual(duplicate.actions, []);
});
test('a shared ID alias across positions and retained conflict snapshots both stop selection', () => {
const aliases = fixture({ missing: [0] });
aliases.state.records[1].questionIdAliases = ['q-0', 'q-1'];
aliases.collector.start(); aliases.advance(300);
assert.equal(aliases.collector.getState().phase, 'stopped'); assert.deepEqual(aliases.actions, []);
const conflict = fixture(); conflict.state.records[0].conflicts = [{ reason: '尚未解决的标准冲突' }];
conflict.collector.start(); conflict.advance(300);
assert.equal(conflict.collector.getState().phase, 'stopped'); assert.deepEqual(conflict.actions, []);
});
+224
View File
@@ -25,6 +25,230 @@ function loadPlaywright() {
catch { return require(path.join(BUNDLED, 'playwright')); }
}
test('判断题补齐面板:手动启动、默认关闭、停止和自动选择互斥', { timeout: 30000 }, async () => {
const question = { bankId: 'collector-bank', bankName: '判断题库1', questionId: 'collector-current', type: 'judge',
stem: '判断题补齐界面验收。', questionIndex: 67, contentOccurrence: 0, answerStatus: 'missing',
options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }], answerTexts: [], correctOptionIds: [] };
const bank = { bankId: question.bankId, bankName: question.bankName, taskId: 'collector-task', questionCount: 500, loading: false };
// The collector engine has its own DOM/action tests. This fixture isolates
// application wiring and records every control action without submitting.
const stubs = `window.__qaControls={events:[],starts:0};window.__qaCurrent=${JSON.stringify(question)};window.__qaBank=${JSON.stringify(bank)};
const QBAdapter={create(options){window.__qaReceive=options.onQuestions;window.__qaBankChange=options.onBankChange;return {
start(){},stop(){},setCopyEnabled(){},getCurrentQuestion(){return window.__qaCurrent},getCurrentBank(){return window.__qaBank}}}};
const QBAuto={create(options){let state={enabled:false,busy:false,answeredCount:0,skippedCount:0,message:''};return {
getState(){return {...state}},setEnabled(value){state.enabled=Boolean(value);window.__qaControls.events.push('auto:'+state.enabled);options.onChange?.()},
answerCurrent(){window.__qaControls.events.push('auto:answer');return Promise.resolve()}}}};
const QBCollector={create(options){let state={active:false,phase:'idle',message:'',answered:0,skipped:0,current:0,total:500};
const emit=patch=>{state={...state,...patch};options.onChange?.({...state})};
const api={getState(){return {...state}},start(){window.__qaControls.starts++;window.__qaControls.events.push('collector:start');emit({active:true,phase:'checking',message:'正在补齐当前判断题库',current:68});if(window.__qaFailCollectorStart)throw new Error('fixture collector failure');return {...state}},
stop(reason){window.__qaControls.events.push('collector:stop');emit({active:false,phase:'stopped',message:reason});return {...state}},destroy(){api.stop('destroy')}};
window.__qaCollector=api;window.__qaCollectorOptions=options;window.__qaCollectorEmit=emit;
options.onChange?.({...state});return api}};`;
const appSource = `(function(){${fs.readFileSync(path.join(ROOT, 'src/core.js'), 'utf8')}\n${stubs}\n${fs.readFileSync(path.join(ROOT, 'src/app.js'), 'utf8')}\n})();`;
const { chromium } = loadPlaywright(), browser = await chromium.launch(browserOptions());
const context = await browser.newContext();
const errors = [];
try {
await context.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><html><head><meta charset="utf-8"></head><body><div class="yxtulcdsdk-pc-marking-main"><div class="yxtulcdsdk-form" practice><p>本地判断题练习夹具</p></div></div></body></html>' }));
await context.addInitScript({ content: `(${installGMShim.toString()})();${appSource}` });
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
await page.goto('https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=collector-task');
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await page.locator('#qb-panel').isVisible(), false);
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false);
assert.equal(await page.evaluate(() => window.__qaControls.starts), 0, '加载和首次onChange不能启动练习');
await activate(page, 'auto');
assert.equal(await page.locator('[data-action="start-collector"]').isEnabled(), true);
assert.equal(await page.locator('[data-action="stop-collector"]').isEnabled(), false);
assert.match(await page.locator('#qb-view-auto').innerText(), /改变练习记录/);
assert.match(await page.locator('#qb-view-auto').innerText(), /隐藏面板后仍会继续/);
assert.equal(await page.locator('#qb-auto-enabled').isChecked(), false);
await page.locator('#qb-auto-enabled').check();
await page.locator('[data-action="start-collector"]').click();
assert.equal(await page.locator('#qb-auto-enabled').isChecked(), false);
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), true);
assert.deepEqual(await page.evaluate(() => window.__qaControls.events.slice(-2)), ['auto:false', 'collector:start']);
assert.equal(await page.locator('[data-action="start-collector"]').isEnabled(), false);
assert.equal(await page.locator('[data-action="stop-collector"]').isEnabled(), true);
const oldCount = await page.locator('#qb-collector-count').textContent();
await page.keyboard.press('Alt+Shift+Q');
await page.evaluate(() => window.__qaCollectorEmit({ answered: 7, current: 75 }));
assert.equal(await page.locator('#qb-collector-count').textContent(), oldCount, '隐藏面板时collector变化不重建计数');
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), true, '隐藏与停止独立');
await activate(page, 'auto');
assert.match(await page.locator('#qb-collector-count').innerText(), /已作答 7 题/);
await activate(page, 'search');
await page.evaluate(() => window.__qaCollectorEmit({ answered: 8 }));
assert.match(await page.locator('#qb-collector-count').textContent(), /已作答 7 题/, '其他页签不重建补齐计数');
await activate(page, 'auto');
assert.match(await page.locator('#qb-collector-count').innerText(), /已作答 8 题/);
await page.locator('#qb-auto-enabled').check();
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false);
assert.deepEqual(await page.evaluate(() => window.__qaControls.events.slice(-2)), ['collector:stop', 'auto:true']);
await page.locator('[data-action="start-collector"]').click();
await page.locator('[data-action="auto-answer-current"]').click();
assert.deepEqual(await page.evaluate(() => window.__qaControls.events.slice(-2)), ['collector:stop', 'auto:answer']);
await page.locator('[data-action="start-collector"]').click();
await page.locator('[data-action="stop-collector"]').click();
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false);
assert.match(await page.locator('#qb-collector-status').innerText(), /手动停止/);
await page.evaluate(() => { window.__qaFailCollectorStart = true; });
await page.locator('[data-action="start-collector"]').click();
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false, '模块异常不能继续作答');
assert.match(await page.locator('#qb-status').innerText(), /练习补齐失败/);
await page.evaluate(() => { window.__qaFailCollectorStart = false; });
await page.locator('[data-action="start-collector"]').click();
await activate(page, 'library');
await page.locator('[data-action="toggle-capture"]').click();
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false);
assert.equal(await page.evaluate(() => window.__qaCollectorOptions.isCollecting()), false);
await activate(page, 'auto');
assert.equal(await page.locator('[data-action="start-collector"]').isEnabled(), false);
await activate(page, 'library');
await page.locator('[data-action="toggle-capture"]').click();
await activate(page, 'auto');
await page.locator('[data-action="start-collector"]').click();
await page.evaluate(() => { window.__qaBankChange(window.__qaBank); window.__qaBank = { ...window.__qaBank, bankId: 'another-bank', loading: true }; window.__qaBankChange(window.__qaBank); });
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false, '切库立即停止');
await page.evaluate(value => { window.__qaBank = value; window.__qaBankChange(value); }, bank);
await page.locator('[data-action="start-collector"]').click();
await page.evaluate(key => {
const write = window.GM_setValue;
window.GM_setValue = (name, value) => { if (name === key) throw new Error('fixture storage denied'); return write(name, value); };
const source = { kind: 'network', url: 'https://api.example.test/upm/collector-bank/users/_/answer', observedAt: '2026-10-30T00:00:00Z' };
window.__qaReceive([{ ...window.__qaCurrent, answerStatus: 'confirmed', answerTexts: ['正确'], correctOptionIds: ['1'],
answerEvidence: 'answer-response', source, answerSource: source }]);
}, RECORDS_KEY);
await waitFor(() => page.evaluate(() => !window.__qaCollector.getState().active), '题库持久化失败应立即停止后续练习');
assert.equal(await page.evaluate(() => window.__qaCollectorOptions.isCollecting()), false);
assert.equal(await page.locator('[data-action="start-collector"]').isEnabled(), false);
assert.match(await page.locator('#qb-status').innerText(), /保存失败/);
await page.reload();
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
assert.equal(await page.locator('#qb-panel').isVisible(), false);
assert.equal(await page.evaluate(() => window.__qaCollector.getState().active), false);
assert.equal(await page.evaluate(() => window.__qaControls.starts), 0, '刷新不会恢复练习补齐');
const ordinary = await context.newPage();
await ordinary.goto('https://ordinary.example.test/collector-fixture');
await ordinary.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await activate(ordinary, 'auto');
assert.equal(await ordinary.locator('[data-action="start-collector"]').isEnabled(), false, '普通网页不能启动判断题补齐');
await ordinary.locator('[data-action="start-collector"]').evaluate(button => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
assert.equal(await ordinary.evaluate(() => window.__qaControls.starts), 0, '事件边界也核验支持的网站');
assert.deepEqual(errors, []);
} finally {
await context.close();
await browser.close();
}
});
test('实际判断题补齐引擎:正常单选点击、标准反馈及可信入库后换题、停止不交卷', { timeout: 20000 }, async () => {
const core = require('../src/core.js');
const bank = { bankId: 'real-collector-bank', bankName: '本地判断题库2', taskId: 'real-collector-task', questionCount: 232, loading: false };
const source = { kind: 'network', url: 'https://api.example.test/ote/upm/start', observedAt: '2026-10-30T00:00:00Z', batchId: 'complete-fixture-list' };
const seed = Array.from({ length: 232 }, (_, index) => core.normalizeRecord({ bankId: bank.bankId, bankName: bank.bankName,
questionId: `real-collector-${index}`, questionIndex: index, contentOccurrence: 0, type: 'judge', stem: `本地正常判断练习题${index + 1}。`,
options: [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }],
answerStatus: index < 2 ? 'missing' : 'confirmed', correctOptionIds: index < 2 ? [] : ['0'], answerTexts: index < 2 ? [] : ['错误'],
...(index < 2 ? {} : { answerEvidence: 'submitted-list' }), source }));
const adapter = `const QBAdapter={create(options){window.__smokeReceive=options.onQuestions;return {start(){},stop(){},setCopyEnabled(){},
getCurrentQuestion(){const record=window.__smokeSeed[window.__smokePosition];return QBCore.normalizeRecord({...record,answerStatus:'missing',correctOptionIds:[],answerTexts:[],answerEvidence:undefined})},
getCurrentBank(){return ${JSON.stringify(bank)}}}}};`;
const exposeCollector = `const originalCollectorCreate=QBCollector.create;QBCollector.create=options=>{const api=originalCollectorCreate(options);window.__smokeCollector=api;window.__smokeRecords=options.getRecords;return api};`;
const appSource = `(function(){${fs.readFileSync(path.join(ROOT, 'src/core.js'), 'utf8')}\n${adapter}\n${fs.readFileSync(path.join(ROOT, 'src/autofill.js'), 'utf8')}\n${fs.readFileSync(path.join(ROOT, 'src/collector.js'), 'utf8')}\n${exposeCollector}\n${fs.readFileSync(path.join(ROOT, 'src/app.js'), 'utf8')}\n})();`;
const markup = '<!doctype html><html><head><meta charset="utf-8"><style>body{font:16px sans-serif;margin:20px}.yxtulcdsdk-ques-radio{display:block;padding:12px}button{padding:8px;margin:6px}</style></head><body>' +
'<div class="task-content-active">本地判断题库2 练习 | 必修</div><div class="yxtulcdsdk-answer-card__top">进度 230/232</div>' +
'<div class="yxtulcdsdk-pc-marking-main"><div class="standard-size-14"><span>判断题</span></div><div class="yxtulcdsdk-form" practice></div>' +
'<div id="smoke-feedback"></div><div class="yxtulcdsdk-examing__btns"><button type="button" id="smoke-next">下一题</button>' +
'<button type="button" id="smoke-end">结束</button><button type="button" id="smoke-submit">交卷</button></div></div>' +
'<ul class="yxtulcdsdk-pc-card-list"><li>1</li><li>2</li></ul><button type="button" id="smoke-show-standard">显示网站标准反馈</button>' +
'<button type="button" id="smoke-save-receipt">正常作答回执入库</button></body></html>';
const { chromium } = loadPlaywright(), browser = await chromium.launch(browserOptions());
const context = await browser.newContext();
const errors = [];
try {
await context.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: markup }));
await context.addInitScript({ content: `(${installGMShim.toString()})();window.__smokeSeed=${JSON.stringify(seed)};window.__smokePosition=0;
if(localStorage.getItem(${JSON.stringify(RECORDS_KEY)})===null)GM_setValue(${JSON.stringify(RECORDS_KEY)},window.__smokeSeed);
${appSource}` });
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
await page.goto('https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=real-collector-task');
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
await page.evaluate(() => {
window.__smokeActions = { radio: [], next: [], end: 0, submit: 0 };
const render = () => {
const record = window.__smokeSeed[window.__smokePosition];
const form = document.querySelector('.yxtulcdsdk-form');
form.innerHTML = `<div class="yxtulcdsdk-form__sub">${window.__smokePosition + 1}.</div><div class="yxtulcdsdk-form__content"><p>${record.stem}</p>` +
record.options.map(option => `<label class="yxtulcdsdk-ques-radio"><input type="radio" name="fixture-answer" value="${option.id}"><span class="yxtf-radio__label">${option.text}</span></label>`).join('') + '</div>';
document.querySelector('#smoke-feedback').replaceChildren();
};
document.querySelector('.yxtulcdsdk-form').addEventListener('change', event => {
if (event.target.matches('input[type="radio"]')) window.__smokeActions.radio.push({ index: window.__smokePosition, value: event.target.value });
});
document.querySelector('#smoke-next').addEventListener('click', () => {
window.__smokeActions.next.push(window.__smokePosition); window.__smokePosition++; render();
});
document.querySelector('#smoke-end').addEventListener('click', () => { window.__smokeActions.end++; });
document.querySelector('#smoke-submit').addEventListener('click', () => { window.__smokeActions.submit++; });
document.querySelector('#smoke-show-standard').addEventListener('click', () => {
const record = window.__smokeSeed[window.__smokePosition];
document.querySelector('#smoke-feedback').innerHTML = `<div class="yxtulcdsdk-examing__result-line" data-question-id="${record.questionId}"><span class="yxtulcdsdk-examing__result-line-left">正确答案</span><span class="yxtulcdsdk-examing__result-line-right">错误</span></div>`;
});
document.querySelector('#smoke-save-receipt').addEventListener('click', () => {
const record = window.__smokeSeed[window.__smokePosition];
const source = { kind: 'network', url: 'https://api.example.test/ote/upm/real-collector-bank/users/_/answer', observedAt: '2026-10-30T00:00:01Z' };
window.__smokeReceive([{ ...record, answerStatus: 'confirmed', correctOptionIds: ['0'], answerTexts: ['错误'],
answerEvidence: 'answer-response', source, answerSource: source }]);
});
render();
});
assert.equal(await page.evaluate(() => window.__smokeCollector.getState().active), false);
await activate(page, 'auto');
await page.locator('[data-action="start-collector"]').click();
await waitFor(() => page.evaluate(() => window.__smokeActions.radio.length === 1), '真实引擎应通过label点击一次正常radio');
assert.equal(await page.locator('.yxtulcdsdk-form input[value="1"]').isChecked(), true);
await page.waitForTimeout(700);
assert.deepEqual(await page.evaluate(() => window.__smokeActions), { radio: [{ index: 0, value: '1' }], next: [], end: 0, submit: 0 });
await page.locator('#smoke-show-standard').click();
await page.waitForTimeout(700);
assert.equal(await page.evaluate(() => window.__smokeActions.next.length), 0, '只有网站DOM反馈、未可信入库不能换题');
assert.equal(await page.evaluate(() => window.__smokeActions.radio.length), 1, '等待期间不会重复选择');
assert.equal(await page.evaluate(() => window.__smokeRecords().find(record => record.questionIndex === 0).answerStatus), 'missing');
// Stop through the actual panel button in the second-question transition,
// before the next 300ms tick, so the cancellation boundary is deterministic.
await page.evaluate(() => document.querySelector('#smoke-next').addEventListener('click', () => {
document.querySelector('#huayi-qb-root').shadowRoot.querySelector('[data-action="stop-collector"]').click();
}, { once: true }));
await page.locator('#smoke-save-receipt').click();
await waitFor(() => page.evaluate(() => window.__smokePosition === 1 && !window.__smokeCollector.getState().active), '一致的标准回执入库后才可换到第2题,并可立即停止');
const stored = await page.evaluate(() => window.__smokeRecords().find(record => record.questionIndex === 0));
assert.equal(stored.answerStatus, 'confirmed');
assert.equal(stored.answerEvidence, 'answer-response');
assert.deepEqual(stored.answerTexts, ['错误'], '保存独立网站标准,不能保存引擎刚选择的正确选项');
assert.equal(await page.evaluate(() => window.__smokeRecords().find(record => record.questionIndex === 1).answerStatus), 'missing');
await page.waitForTimeout(1100);
assert.deepEqual(await page.evaluate(() => window.__smokeActions), { radio: [{ index: 0, value: '1' }], next: [0], end: 0, submit: 0 });
assert.equal(await page.locator('.yxtulcdsdk-form__sub').innerText(), '2.');
assert.equal(await page.locator('.yxtulcdsdk-form input:checked').count(), 0);
assert.match(await page.locator('#qb-collector-status').innerText(), /手动停止/);
assert.equal((await storageRecords(page)).length, 232);
assert.deepEqual(errors, []);
} finally {
await context.close();
await browser.close();
}
});
test('判断题答案证据:旧默认值隔离、反馈恢复、备份及搜索候选区分', { timeout: 60000 }, async () => {
const core = require('../src/core.js');
const source = { kind: 'network', url: 'https://api-phx-ali.yunxuetang.cn/ote/upm/start', observedAt: '2026-10-29T00:00:00Z', batchId: 'old-judge-batch' };
+12 -2
View File
@@ -1,6 +1,6 @@
# 华谊集团化工总控工(高级)合并题库
五个题库于2026-10-29从Chrome正常练习页面导出,2026-10-30更正判断题答案来源并补入可信已提交列表后,保留 **1884个完整题目位置**,其中 **1261题答案已确认、623题待补、0题冲突**。配套助手版本为 **1.2.6**。
五个题库于2026-10-29从Chrome正常练习页面导出,2026-10-30更正判断题答案来源并补入可信已提交列表后,保留 **1884个完整题目位置**,其中 **1261题答案已确认、623题待补、0题冲突**。配套助手版本为 **1.2.7**。本次新增补齐功能,尚不代表623道待补题已取得标准答案;当前发布文件的统计保持不变。
**此前“1884题标准答案全部已确认”的结论错误,现明确更正。** `/upm/start` 和 `/upm/wrong/start` 的未作答判断题也可能包含默认 `judgeAnswer: 0`;没有提交或标准反馈证据的0和1都不能作为标准答案。题干、选项及位置完整,不代表答案完整。
@@ -19,10 +19,20 @@
用户更新1.2.6后重新正常加载练习,判断题库1取得67题、判断题库2取得42题的可信标准答案,共109题,来源均为非空已提交记录对应的 `submitted-list`。第67题的可信网络答案与此前人工核验一致;未作答第68题保持待补。判断题库2的42题来自用户此前累计的已作答记录,本次核验 **0次新提交,连续自动选择保持关闭**。补入后当前总计1261题已确认、623题待补,不按旧0、1或常识补造剩余答案。
仅由无证据列表占位值及继承这些值的DOM观察造成的旧冲突已清除。后续取得可信证据时可以恢复答案:`submitted-list` 表示已提交列表,`answer-response` 表示绑定到同题的真实作答回执,`bound-dom` 表示绑定当前题的标准反馈,`explicit-standard` 表示明确标准答案源。两个可信标准答案不同仍保留冲突,未获可信反馈的待补题不会自动选择。
仅由无证据列表占位值及继承这些值的DOM观察造成的旧冲突已清除。后续取得可信证据时可以恢复答案:`submitted-list` 表示已提交列表,`answer-response` 表示绑定到同题的真实作答回执,`bound-dom` 表示绑定当前题的标准反馈,`explicit-standard` 表示明确标准答案源。两个可信标准答案不同仍保留冲突。普通自动选择只操作已确认答案,未获可信反馈的待补题会跳过;手动补齐模式的作答行为另见下文。
逐项更正见 [`答案来源更正_20261030.json`](答案来源更正_20261030.json)。核验前原值保留在本地 `.cache/` 和 Git 提交 `4bf2de1` 中。助手升级后还会在独立存储项中备份用户本地旧判断题原值,可通过“设置”或油猴菜单的“导出判断题核验前备份”取回;备份失败时不覆盖原库。
## 用1.2.7补齐剩余判断题
“自动答题”页签新增“补齐当前判断题库”和“停止补齐”。功能默认关闭,必须手动启动,**会进行真实练习作答并改变累计答题与对错记录**。只支持本站500题或232题的正常判断练习;普通网页、正式考试、错题重做均不支持。启动前需保持采集开启,等待当前题库与完整题目身份加载。
补齐从第1题核验,跳过已有可信标准的题目。待补题正常点击“正确”选项一次,只用于触发网站标准反馈;点击的选项不能作为标准答案保存。只有网站当前题的标准反馈与同题可信入库记录一致后才点击“下一题”,标准可能为“正确”或“错误”。身份、证据、答案不符或反馈超时都会停止,不能按0、1或练习进度猜填。
随时可点“停止补齐”;暂停采集、切库、离页、导入或存储异常时会停止。隐藏及收起面板后仍继续,需恢复面板才能点击停止;刷新后默认关闭。补齐与原连续自动选择互斥:启动其中一种会停止另一种,“答当前题”也会先停止补齐。
普通自动选择模式只按已确认答案选择选项,不点击下一题;手动补齐模式会在核验成功后换题,但不会结束、交卷或点击末题提交。只有实际取得可信答案、完整题目位置全部通过核验后,才可更新本目录的已确认数量。当前仍为1261已确认、623待补、0冲突。
## 文件
- `华谊集团化工总控工(高级)-合并题库.csv`:UTF-8 BOM,可用Excel查看或导入题库助手,待补题答案列为空。