diff --git a/README.md b/README.md index 5409e40..a9133e3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.cjs b/build.cjs index a4e8f2a..85b9a13 100644 --- a/build.cjs +++ b/build.cjs @@ -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'); diff --git a/dist/huayi-question-bank.user.js b/dist/huayi-question-bank.user.js index ea49e25..8ca45e1 100644 --- a/dist/huayi-question-bank.user.js +++ b/dist/huayi-question-bank.user.js @@ -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;