普通网页练习
使用本地题库,仅选择已确认的选项。
From 83ca85cc17e706b62ab378835d1b80a5d81f1d6b Mon Sep 17 00:00:00 2001
From: suanyuit-cyber <346708943+suanyuit-cyber@users.noreply.github.com>
Date: Thu, 29 Oct 2026 18:29:58 +0800
Subject: [PATCH] feat: add opt-in answer selection and global hidden panel
---
README.md | 37 ++-
build.cjs | 9 +-
dist/huayi-question-bank.user.js | 383 ++++++++++++++++++++++++++++---
package.json | 4 +-
src/adapter.js | 49 +++-
src/app.js | 79 +++++--
src/autofill.js | 245 ++++++++++++++++++++
tests/adapter.test.cjs | 90 ++++++++
tests/autofill.test.cjs | 125 ++++++++++
tests/integration.test.cjs | 239 +++++++++++++++++--
10 files changed, 1171 insertions(+), 89 deletions(-)
create mode 100644 src/autofill.js
create mode 100644 tests/autofill.test.cjs
diff --git a/README.md b/README.md
index 1e76fb1..6b84388 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 华谊题库助手
-用于华谊学习平台练习页面的油猴脚本:按题库采集题目和平台提供的标准答案,恢复题目选择复制,按实际名称导出 CSV,一次导入多个题库,输入文字或划选题干查答案。当前版本 **1.1.0**。
+支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.0**。
安装文件:[`dist/huayi-question-bank.user.js`](dist/huayi-question-bank.user.js)。这是构建后的完整脚本,无须安装 Node.js 或运行服务器。
@@ -8,12 +8,25 @@
1. 在浏览器中安装并启用 Tampermonkey(油猴)。
2. 打开油猴管理面板,选择“新建脚本”,将安装文件的全部内容粘贴进去并保存。
-3. 打开或刷新 `https://www.shhuayi-elearning.cn/` 下的练习页面。安装后应重新进入练习,以便观察首次加载的题目响应。
-4. 右下角出现“题库助手”。默认开始采集正常加载的练习题;已加载的批量题目会一起记录。若答案需要作答后才返回,则在正常练习后补齐。
+3. 打开或刷新普通网页,按 **Shift + Alt + Q** 显示题库助手,再按一次隐藏。每次加载页面都默认完全隐藏,无悬浮按钮;旧版保存的打开状态也会重置。
+4. 在华谊练习页面,正常加载的题目和标准答案仍会自动保存;安装后应重新进入练习,以便观察首次题目响应。普通网页只使用本地题库检索,不观察其网络请求或自动采集表单。
5. 在“文字搜索”中输入或粘贴至少两个字符,按回车或点“搜索”。也可以在页面划选题干后松开鼠标自动查询。
-6. 在“题库 / 导出”中检查实际题库名、范围内题数及文件名预览,再导出 CSV 或备份 JSON。默认只导出当前题库,全部题库需要手动选择。
+6. 在“题库 / 导出”中检查实际题库名、范围内题数及文件名预览,再导出 CSV 或备份 JSON。已识别的练习页默认当前题库;普通网页默认全部已存题库。
-浏览器需允许油猴在该网站运行用户脚本。若没有出现面板,先确认扩展已启用、该站点有运行权限,再刷新页面。
+浏览器需允许油猴在相应网站运行用户脚本。更新到全网页版本后,浏览器可能需要重新授予网站访问权限。若快捷键没有显示面板,先确认扩展已启用且该站点有运行权限。浏览器内置页面、扩展商店及受限制页面不能运行普通用户脚本;本地文件取决于浏览器的文件访问设置,本版匹配普通 HTTP / HTTPS 页面。
+
+## 自动答题
+
+打开“自动答题”页签,先导入包含标准答案的题库,然后选择:
+
+- **答当前题**:从本地已确认记录中精确匹配当前题干、题型和完整选项,自动选择正确选项。
+- **连续自动选择**:仅在当前页面启用;题目变化后继续核对并选择。关闭后立即停止,刷新或打开其他网页后默认关闭。
+
+单选按答案内容选择当前选项;多选同步完整正确集合,取消已经选中的错误项;判断题按“正确 / 错误”对应。候选答案冲突、题干或选项不完整、重复文字无法唯一对应、选项不可操作时跳过,显示原因。
+
+选择完成后由你检查并提交或进入下一题。脚本不会点击提交、交卷、下一题按钮。华谊练习中的选项点击可能触发网站自身的作答响应,具体行为由练习页面决定。
+
+自动选择支持华谊题目 DOM,以及标准 `fieldset + legend + label + radio/checkbox` 和明确题目容器结构。全网页显示不代表能自动识别任意网站的自定义答题控件;无法识别时可继续使用文字搜索。题库仍保存在同一个油猴脚本存储中,可在不同网站访问。
## 本次方案:按题库分别统计、搜索和导出
@@ -28,7 +41,7 @@
## 文字搜索
- 输入或粘贴完整题干、题干片段、选项内容或标准答案关键词,支持按钮、回车和输入后自动查询,也可点“清空”。
-- “搜索题库”可选当前库、任意已保存库或全部库;默认当前库。搜索范围与导出范围独立,跨库查答案不会让导出自动变成全部。
+- “搜索题库”可选当前库、任意已保存库或全部库;已识别练习页默认当前库,普通网页默认全部库。搜索范围与导出范围独立,跨库查答案不会让导出自动变成全部。
- “搜索内容”可选全部文字、仅题干、仅选项、仅标准答案;多个关键词可用空格分隔,按同时包含处理。
- 结果标注命中题干、选项或答案,显示来源题库。只把已确认答案用于答案字段搜索,冲突和待补记录仍可通过题干找到。
- 每次最多展示 50 条结果,可细化关键词继续查找。
@@ -46,11 +59,12 @@
| 操作 | 效果 | 恢复方式 |
| --- | --- | --- |
| 右上角“收起” | 只显示面板标题栏 | 点击“展开” |
-| 右上角“隐藏” | 面板隐藏,默认保留圆形“题库”按钮 | 点击悬浮按钮 |
-| 设置中取消“隐藏时保留悬浮按钮”,再隐藏 | 面板和悬浮按钮均隐藏 | **Alt + Shift + Q** 或油猴菜单 |
-| 油猴菜单“完全隐藏题库助手” | 直接完全隐藏 | **Alt + Shift + Q** 或菜单“显示题库助手” |
+| 打开或刷新网页 | 默认完全隐藏,无悬浮按钮 | **Shift + Alt + Q** |
+| 右上角“隐藏” | 面板完全隐藏 | **Shift + Alt + Q** |
+| **Shift + Alt + Q** | 在显示与隐藏之间切换 | 再按一次 |
+| 油猴菜单“显示题库助手” | 手动显示面板 | 右上角隐藏或快捷键 |
-显示状态、悬浮按钮偏好和复制开关会保留,刷新后继续生效。隐藏时划选题干仍会更新查询,但不会重新弹出面板。隐藏与暂停采集是独立操作;需要暂停时,使用“题库 / 导出”中的“暂停采集”。
+复制开关等设置会保留;面板在每次加载后均重置为隐藏,连续自动选择也会关闭。隐藏时划选题干仍会更新查询,但不会重新弹出面板。隐藏、采集和连续自动选择是独立操作;隐藏面板不会停止你在当前页已开启的连续选择,停止时请先关闭相应开关。
## 正确答案如何防止错位
@@ -81,7 +95,7 @@ JSON 备份包含 `schemaVersion: 1` 和 `records` 数组,保留完整选项
## 采集范围与当前验证边界
-脚本在当前账号正常练习时,观察已收到的题目 / 答案响应,并补充当前页面 DOM。不主动提交答案。接口没有提供标准答案时,保留待补状态;仅安装脚本不会凭空补出答案。
+脚本在当前账号正常练习时,观察已收到的题目 / 答案响应,并补充当前页面 DOM。自动选择只操作对应选项,网站可能按自身规则保存作答。接口没有提供标准答案时,保留待补状态;仅安装脚本不会凭空补出答案。
目前适配了保存页面中的真实题目结构,以及业务脚本中可确认的练习接口:`/upm/start`、`/upm/wrong/start`、`/pra/{id}/questions` 和对应单题作答结果。接口适配限制在本站及学习平台域名。脚本不发送题库到外部服务器,来源 URL 不保存查询参数。
@@ -95,6 +109,7 @@ JSON 备份包含 `schemaVersion: 1` 和 `records` 数组,保留完整选项
- `src/core.js`:答案规范化、去重与冲突、检索、字母映射、CSV。
- `src/adapter.js`:本站响应观察、题目 DOM 读取、复制恢复。
+- `src/autofill.js`:精确答案匹配、标准选项操作与连续选择。
- `src/app.js`:面板、隐藏恢复、存储、导入导出。
```powershell
diff --git a/build.cjs b/build.cjs
index 7f631e8..7f48542 100644
--- a/build.cjs
+++ b/build.cjs
@@ -4,10 +4,9 @@ const root = __dirname;
const header = `// ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank
-// @version 1.1.0
-// @description 按题库采集与同名CSV导出,支持多题库批量导入、文字搜索、划词查答案、选择复制与隐藏面板。
-// @match https://www.shhuayi-elearning.cn/*
-// @match https://shhuayi-elearning.cn/*
+// @version 1.2.0
+// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
+// @match *://*/*
// @run-at document-start
// @noframes
// @grant unsafeWindow
@@ -17,7 +16,7 @@ const header = `// ==UserScript==
// @grant GM_addValueChangeListener
// ==/UserScript==
`;
-const sources = ['src/core.js', 'src/adapter.js', 'src/app.js'];
+const sources = ['src/core.js', 'src/adapter.js', 'src/autofill.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 b918f27..f6aff80 100644
--- a/dist/huayi-question-bank.user.js
+++ b/dist/huayi-question-bank.user.js
@@ -1,10 +1,9 @@
// ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank
-// @version 1.1.0
-// @description 按题库采集与同名CSV导出,支持多题库批量导入、文字搜索、划词查答案、选择复制与隐藏面板。
-// @match https://www.shhuayi-elearning.cn/*
-// @match https://shhuayi-elearning.cn/*
+// @version 1.2.0
+// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
+// @match *://*/*
// @run-at document-start
// @noframes
// @grant unsafeWindow
@@ -566,7 +565,7 @@ const QBAdapter = (() => {
} catch (_) { return ''; }
}
- function create({ window: win, document: doc, onQuestions = () => {}, onStatus = () => {}, onBankChange = () => {} }) {
+ function create({ window: win, document: doc, onQuestions = () => {}, onStatus = () => {}, onBankChange = () => {}, observeNetwork = true }) {
let active = false;
let copyEnabled = false;
let observer = null;
@@ -593,6 +592,14 @@ const QBAdapter = (() => {
const status = message => { try { onStatus(message); } catch (_) { /* UI callbacks must not affect the page. */ } };
const locationURL = () => String(win.location && win.location.href || '');
+ function isSupportedPage() {
+ try {
+ const host = new URL(locationURL()).hostname;
+ if (host === 'www.shhuayi-elearning.cn' || host === 'shhuayi-elearning.cn') return true;
+ } catch (_) { /* An incomplete location still allows an explicit exercise root. */ }
+ return Boolean(doc.querySelector(ROOT_SELECTOR));
+ }
+
function formSnapshot() {
const element = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].find(visible);
return element ? { element, text: flat(element.textContent) } : null;
@@ -628,6 +635,18 @@ const QBAdapter = (() => {
}
function syncBank() {
+ if (!isSupportedPage()) {
+ const empty = { bankId: '', bankName: '', taskId: '', loading: false };
+ const changed = !currentBank || JSON.stringify(currentBank) !== JSON.stringify(empty);
+ currentBank = empty;
+ pendingSelection = '';
+ blockedForm = null;
+ lastFormSnapshot = null;
+ if (changed) {
+ try { onBankChange({ ...empty }); } catch (_) { /* The host page remains independent. */ }
+ }
+ return { ...empty };
+ }
const state = bankDOM();
const identity = JSON.stringify([state.taskId, flat(state.bankName)]);
const oldIdentity = currentBank && JSON.stringify([currentBank.taskId, flat(currentBank.bankName)]);
@@ -663,7 +682,7 @@ const QBAdapter = (() => {
function getCurrentBank() { return syncBank(); }
function ensureBankListeners() {
- if (bankListeners) return;
+ if (bankListeners || !isSupportedPage()) return;
bankListeners = true;
win.addEventListener?.('hashchange', syncBank);
win.addEventListener?.('popstate', syncBank);
@@ -713,6 +732,7 @@ const QBAdapter = (() => {
}
function getCurrentQuestion() {
+ if (!isSupportedPage()) return null;
const bank = syncBank();
if (bankDOM().mismatch) return null;
const forms = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].filter(visible);
@@ -774,6 +794,10 @@ const QBAdapter = (() => {
}
function captureCurrent() {
+ if (!isSupportedPage()) {
+ status('当前为通用网页,可使用本地题库搜索;本站采集未开启。');
+ return null;
+ }
const current = getCurrentQuestion();
if (current) emit([current]);
else status('当前页面未发现可采集的练习题,请进入题目页面。');
@@ -796,6 +820,7 @@ const QBAdapter = (() => {
}
function installNetwork() {
+ if (!observeNetwork || !isSupportedPage()) return;
const generation = ++networkGeneration;
if (typeof win.fetch === 'function') {
const originalFetch = win.fetch;
@@ -882,7 +907,7 @@ const QBAdapter = (() => {
}
function ensureCopyStyle() {
- if (!copyEnabled || copyStyle?.isConnected) return;
+ if (!copyEnabled || copyStyle?.isConnected || !isSupportedPage()) return;
const parent = doc.head || doc.documentElement;
if (!parent) return;
copyStyle = doc.createElement('style');
@@ -892,7 +917,7 @@ const QBAdapter = (() => {
}
function ensureObserver() {
- if (observer || !win.MutationObserver) return;
+ if (observer || !win.MutationObserver || !isSupportedPage()) return;
observer = new win.MutationObserver(mutations => {
const relevant = mutations.some(m => {
const node = m.target.nodeType === 1 ? m.target : m.target.parentElement;
@@ -914,10 +939,12 @@ const QBAdapter = (() => {
}
function setCopyEnabled(value) {
- copyEnabled = Boolean(value);
+ const supported = isSupportedPage();
+ if (!supported && !copyEnabled && !copyStyle) return;
+ copyEnabled = Boolean(value) && supported;
for (const type of ['copy', 'cut', 'contextmenu', 'selectstart']) {
- win.removeEventListener(type, copyEvent, true);
- if (copyEnabled) win.addEventListener(type, copyEvent, true);
+ win.removeEventListener?.(type, copyEvent, true);
+ if (copyEnabled) win.addEventListener?.(type, copyEvent, true);
}
if (copyEnabled) { ensureCopyStyle(); ensureObserver(); }
else if (copyStyle) { copyStyle.remove(); copyStyle = null; }
@@ -929,6 +956,11 @@ const QBAdapter = (() => {
function start() {
if (active) return;
active = true;
+ if (!isSupportedPage()) {
+ syncBank();
+ status('当前为通用网页,可使用本地题库搜索;本站采集未开启。');
+ return;
+ }
ensureBankListeners();
ensureObserver();
installNetwork();
@@ -943,7 +975,7 @@ const QBAdapter = (() => {
status('采集已暂停,已保存题库和选择复制功能仍可使用。');
}
- return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled };
+ return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled, isSupportedPage };
}
return { create, extractQuestions };
@@ -951,12 +983,260 @@ const QBAdapter = (() => {
if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
+// ---- src/autofill.js ----
+/* Select confirmed local answers. This module never submits forms or changes pages. */
+const QBAuto = (() => {
+ 'use strict';
+ const tidy = value => String(value == null ? '' : value).replace(/\u00a0/g, ' ').trim();
+ const flat = value => tidy(value).replace(/\s+/gu, '');
+ const stemKey = value => flat(tidy(value).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, ''));
+ const judgeText = value => /^(正确|对|是|√|true)$/i.test(tidy(value)) ? '正确' : /^(错误|错|否|×|false)$/i.test(tidy(value)) ? '错误' : tidy(value);
+ const optionKey = (value, type) => flat(type === 'judge' ? judgeText(value) : value);
+ const sorted = values => [...values].sort();
+ const optionSet = question => JSON.stringify(sorted(question.options.map(option => optionKey(option.text, question.type))));
+ const localCore = supplied => supplied || (typeof QBCore !== 'undefined' ? QBCore : typeof require === 'function' ? require('./core.js') : null);
+
+ function planAnswer(records, current, suppliedCore) {
+ const result = (status, message, more = {}) => ({ status, message, labels: [], answerTexts: [], ...more });
+ const core = localCore(suppliedCore);
+ if (!core || !current || !Array.isArray(records)) return result('unsupported', '当前网页未识别到可自动选择的题目。');
+ let question;
+ try { question = core.normalizeRecord({ ...current, answerStatus: 'missing' }); }
+ catch (_) { return result('unsupported', '当前题目结构不完整,已跳过。'); }
+ if (!['single', 'multiple', 'judge'].includes(question.type) || question.options.length < 2 || question.options.length > 100) return result('unsupported', '当前题型暂不支持自动选择。');
+ const contents = question.options.map(option => optionKey(option.text, question.type));
+ if (new Set(contents).size !== contents.length || new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项文字或标记重复,无法唯一对应,已跳过。');
+ const relevant = [];
+ for (const input of records) {
+ let record;
+ try { record = core.normalizeRecord(input); } catch (_) { continue; }
+ if (record.type !== question.type || stemKey(record.stem) !== stemKey(question.stem) || optionSet(record) !== optionSet(question)) continue;
+ if (question.bankId && record.bankId && question.bankId !== record.bankId) continue;
+ if (!record.bankId && question.bankName && question.bankName !== '未命名题库' && record.bankName !== '未命名题库' && flat(record.bankName) !== flat(question.bankName)) continue;
+ relevant.push(record);
+ }
+ let candidates = relevant;
+ if (question.bankId) {
+ const exactBank = relevant.filter(record => record.bankId === question.bankId);
+ if (exactBank.length) candidates = relevant.filter(record => record.bankId === question.bankId || !record.bankId);
+ } else if (question.bankName && question.bankName !== '未命名题库') {
+ const sameName = relevant.filter(record => flat(record.bankName) === flat(question.bankName));
+ if (sameName.length) candidates = sameName;
+ }
+ if (candidates.some(record => record.answerStatus === 'conflict')) return result('ambiguous', '本地题库存在该题的冲突答案,已跳过。');
+ const confirmed = candidates.filter(record => record.answerStatus === 'confirmed');
+ if (!confirmed.length) return result('missing', '未找到题干、题型和全部选项一致的已确认答案。');
+ const signatures = new Set(confirmed.map(record => JSON.stringify(sorted(record.answerTexts.map(value => optionKey(value, record.type))))));
+ if (signatures.size !== 1) return result('ambiguous', '相同题目对应多个不同标准答案,已跳过。');
+ const mapped = confirmed.map(record => ({ record, mapping: core.mapAnswers(record, question) }));
+ if (mapped.some(item => item.mapping.status !== 'matched' || !item.mapping.labels.length)) return result('ambiguous', '标准答案无法安全对应当前选项,已跳过。');
+ const labelSets = new Set(mapped.map(item => JSON.stringify(sorted(item.mapping.labels))));
+ if (labelSets.size !== 1) return result('ambiguous', '标准答案对应的选项位置不一致,已跳过。');
+ return result('ready', '已找到唯一对应的标准答案。', { labels: [...mapped[0].mapping.labels], answerTexts: [...mapped[0].mapping.answerTexts], record: mapped[0].record });
+ }
+
+ function create({ window: win, document: doc, core: suppliedCore, getRecords = () => [], getQuestion = () => null, getBank = () => null, onStatus = () => {}, onChange = () => {} }) {
+ const core = localCore(suppliedCore);
+ const SITE_FORM = '.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form';
+ const GENERIC = 'fieldset, .question, .question-item, [data-question]';
+ let state = { enabled: false, busy: false, status: 'idle', message: '自动答题未开启。', answeredCount: 0, skippedCount: 0, lastStem: '' };
+ let observer = null, timer = null, interval = null, generation = 0, destroyed = false;
+ let attempts = new WeakMap();
+ const setTimer = (fn, delay) => win.setTimeout(fn, delay);
+ function emit(patch) {
+ const before = state;
+ state = { ...state, ...patch };
+ if (JSON.stringify(before) === JSON.stringify(state)) return;
+ try { onChange({ ...state }); } catch (_) { /* UI failures must not affect a page. */ }
+ if (before.message !== state.message) try { onStatus(state.message); } catch (_) { /* Ignore callback errors. */ }
+ }
+ function visible(element) {
+ if (!element || !element.isConnected || element.closest('#huayi-qb-root')) return false;
+ for (let at = element; at && at.nodeType === 1; at = at.parentElement) {
+ if (at.hidden || at.getAttribute('aria-hidden') === 'true') return false;
+ const style = win.getComputedStyle?.(at);
+ if (style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse') return false;
+ }
+ return true;
+ }
+ function labelText(input, container) {
+ let label = input.labels && [...input.labels].find(item => container.contains(item));
+ if (!label) label = input.closest('label');
+ if (label) {
+ const rich = label.querySelector('.yxtf-radio__label [data-rich-text], .yxtf-checkbox__label [data-rich-text]');
+ if (rich) return tidy(rich.textContent);
+ const clone = (label.querySelector('.yxtf-radio__label, .yxtf-checkbox__label') || label).cloneNode(true);
+ clone.querySelectorAll('input,button,script,style').forEach(node => node.remove());
+ return tidy(clone.textContent).replace(/^(?:[A-Z][.、.)::]|\[[A-Z]\])\s*/u, '');
+ }
+ const labelled = tidy(input.getAttribute('aria-labelledby')).split(/\s+/u).filter(Boolean).map(id => doc.getElementById(id)).filter(node => node && container.contains(node));
+ return tidy(labelled.length ? labelled.map(node => node.textContent).join(' ') : input.getAttribute('aria-label'));
+ }
+ function controls(container, site) {
+ const selector = site ? '.yxtulcdsdk-ques-radio input[type="radio"],.yxtulcdsdk-ques-checkbox input[type="checkbox"]' : 'input[type="radio"],input[type="checkbox"]';
+ const inputs = [...container.querySelectorAll(selector)].filter(input => site || input.closest(GENERIC) === container);
+ if (inputs.length < 2 || inputs.length > 100) return null;
+ const types = new Set(inputs.map(input => input.type));
+ if (types.size !== 1 || inputs.some(input => input.disabled || input.matches(':disabled') || input.closest('[aria-disabled="true"]'))) return null;
+ if (inputs.some(input => !visible(input) && !visible(input.closest('label')))) return null;
+ if (!site && inputs[0].type === 'radio' && new Set(inputs.map(input => input.name)).size > 1) return null;
+ const texts = inputs.map(input => labelText(input, container));
+ if (texts.some(value => !value)) return null;
+ return { inputs, texts, type: inputs[0].type === 'checkbox' ? 'multiple' : 'single' };
+ }
+ function readSite(container) {
+ if (!visible(container)) return null;
+ const current = getQuestion();
+ const bank = getBank();
+ if (!current || bank?.loading) return null;
+ const set = controls(container, true);
+ if (!set || set.inputs.length !== current.options?.length || set.texts.some((value, index) => optionKey(value, current.type) !== optionKey(current.options[index].text, current.type))) return null;
+ return { container, inputs: set.inputs, question: current, site: true };
+ }
+ function readGeneric(container) {
+ if (!visible(container)) return null;
+ const set = controls(container, false);
+ if (!set) return null;
+ const titles = [...container.querySelectorAll(':scope > legend, :scope > .question-title, :scope > [data-question-title], :scope > .question-stem, :scope > .stem, :scope > h2, :scope > h3, :scope > p')].filter(node => visible(node) && !node.querySelector('input'));
+ if (titles.length !== 1) return null;
+ const stem = tidy(titles[0].textContent);
+ if (!stem || stem.length > 100000) return null;
+ const judged = set.type === 'single' && set.texts.length === 2 && set.texts.map(judgeText).includes('正确') && set.texts.map(judgeText).includes('错误');
+ const type = judged ? 'judge' : set.type;
+ const question = {
+ bankId: tidy(container.getAttribute('data-bank-id')), bankName: tidy(container.getAttribute('data-bank-name')),
+ questionId: '', stem, type, options: set.inputs.map((input, index) => ({ id: input.value || input.id || '', text: judged ? judgeText(set.texts[index]) : set.texts[index], label: judged ? judgeText(set.texts[index]) : String.fromCharCode(65 + index) })),
+ answerStatus: 'missing', answerTexts: [], correctOptionIds: []
+ };
+ return { container, inputs: set.inputs, question, site: false };
+ }
+ function allCandidates() {
+ const siteForms = [...doc.querySelectorAll(SITE_FORM)].filter(visible);
+ if (siteForms.length) return siteForms.length === 1 ? [readSite(siteForms[0])].filter(Boolean) : [];
+ return [...doc.querySelectorAll(GENERIC)].map(readGeneric).filter(Boolean);
+ }
+ function fingerprint(candidate) {
+ const question = candidate.question;
+ return JSON.stringify([question.bankId || '', question.bankName || '', question.type, stemKey(question.stem), question.options.map(option => [option.label, optionKey(option.text, question.type)])]);
+ }
+ function stillSame(candidate, mark) {
+ if (!candidate.container.isConnected || candidate.inputs.some(input => !input.isConnected || input.disabled)) return false;
+ const current = candidate.site ? readSite(candidate.container) : readGeneric(candidate.container);
+ return current && fingerprint(current) === mark && current.inputs.length === candidate.inputs.length && current.inputs.every((input, index) => input === candidate.inputs[index]);
+ }
+ function attempted(candidate, plan) { return `${fingerprint(candidate)}\u001f${plan.status}\u001f${JSON.stringify(sorted(plan.answerTexts || []))}`; }
+ function desired(candidate, plan) { return candidate.question.options.map(option => plan.labels.includes(option.label)); }
+ function alreadyChosen(candidate, flags) { return candidate.inputs.every((input, index) => Boolean(input.checked) === flags[index]); }
+ function skipped(message, stem = '', count = true) {
+ emit({ busy: false, status: 'skipped', message, lastStem: stem, skippedCount: state.skippedCount + (count ? 1 : 0) });
+ return { status: 'skipped', message };
+ }
+ async function answerCurrent(options = {}) {
+ const continuous = options.continuous === true;
+ if (destroyed) return { status: 'cancelled', message: '自动答题已关闭。' };
+ if (state.busy) return { status: 'busy', message: '正在处理当前题目。' };
+ if (continuous && !state.enabled) return { status: 'cancelled', message: '连续自动选择已暂停。' };
+ const candidates = allCandidates();
+ if (!candidates.length) return continuous ? { status: 'idle', message: '等待可识别的选择题。' } : skipped('当前网页未识别到明确的题干和选项,已跳过。');
+ const prioritized = [...candidates].sort((a, b) => Number(Boolean(b.container.matches('.current,.is-active,[aria-current="true"]'))) - Number(Boolean(a.container.matches('.current,.is-active,[aria-current="true"]'))));
+ let candidate, plan, attempt;
+ for (const item of prioritized) {
+ const proposal = planAnswer(getRecords(), item.question, core);
+ const key = attempted(item, proposal);
+ if (continuous && attempts.get(item.container) === key) continue;
+ candidate = item; plan = proposal; attempt = key; break;
+ }
+ if (!candidate) return { status: 'idle', message: '当前识别的题目已处理。' };
+ const questionStem = candidate.question.stem;
+ attempts.set(candidate.container, attempt);
+ if (plan.status !== 'ready') return skipped(plan.message, questionStem);
+ const flags = desired(candidate, plan);
+ if (candidate.question.type !== 'multiple' && flags.filter(Boolean).length !== 1) return skipped('单选或判断题的目标选项不唯一,已跳过。', questionStem);
+ if (alreadyChosen(candidate, flags)) {
+ emit({ status: 'answered', message: '当前题已选择正确选项。', lastStem: questionStem });
+ return { status: 'answered', alreadySelected: true, labels: plan.labels, answerTexts: plan.answerTexts };
+ }
+ const mark = fingerprint(candidate);
+ const runGeneration = generation;
+ emit({ busy: true, status: 'matched', message: `正在选择:${plan.answerTexts.join(';')}`, lastStem: questionStem });
+ try {
+ const actions = candidate.question.type === 'multiple'
+ ? candidate.inputs.map((input, index) => ({ input, index })).filter(({ input, index }) => Boolean(input.checked) !== flags[index])
+ : candidate.inputs.map((input, index) => ({ input, index })).filter(({ index }) => flags[index]);
+ for (const { input } of actions) {
+ if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) {
+ emit({ busy: false, status: 'idle', message: '自动选择已暂停。' });
+ return { status: 'cancelled', message: '自动选择已暂停。' };
+ }
+ if (!stillSame(candidate, mark)) return skipped('点击前题目或选项已变化,已停止本次选择。', questionStem);
+ input.click();
+ await Promise.resolve();
+ if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) {
+ emit({ busy: false, status: 'idle', message: '自动选择已暂停。' });
+ return { status: 'cancelled', message: '自动选择已暂停。' };
+ }
+ if (!stillSame(candidate, mark)) return skipped('页面在选择过程中换题或替换控件,已停止后续点击。', questionStem);
+ }
+ if (!alreadyChosen(candidate, flags)) return skipped('网页未保留预期的选中状态,请人工核对。', questionStem);
+ emit({ busy: false, status: 'answered', message: `已选择正确选项:${plan.labels.join('、')}`, answeredCount: state.answeredCount + 1, lastStem: questionStem });
+ return { status: 'answered', labels: plan.labels, answerTexts: plan.answerTexts };
+ } catch (error) {
+ return skipped(`自动选择未完成:${error.message || '控件操作失败'}`, questionStem);
+ } finally { if (state.busy) emit({ busy: false }); }
+ }
+ function schedule() {
+ if (!state.enabled || destroyed) return;
+ win.clearTimeout(timer);
+ timer = setTimer(() => { timer = null; void answerCurrent({ continuous: true }); }, 160);
+ }
+ function stopObservers() {
+ win.clearTimeout(timer); win.clearInterval(interval); timer = null; interval = null;
+ if (observer) observer.disconnect(); observer = null;
+ }
+ function setEnabled(value) {
+ if (destroyed) return;
+ const enabled = Boolean(value);
+ if (state.enabled === enabled) {
+ // A page can be hidden while a one-shot selection is awaiting a UI
+ // microtask. Calling false must cancel that run even without continuous mode.
+ if (!enabled) {
+ generation++;
+ stopObservers();
+ if (state.busy) emit({ busy: false, status: 'idle', message: '自动选择已暂停。' });
+ }
+ return;
+ }
+ generation++;
+ stopObservers();
+ emit({ enabled, busy: false, status: 'idle', message: enabled ? '连续自动选择已开启,仅处理明确匹配的题目。' : '连续自动选择已暂停。' });
+ if (!enabled) return;
+ attempts = new WeakMap();
+ if (win.MutationObserver) {
+ observer = new win.MutationObserver(mutations => {
+ if (mutations.some(mutation => {
+ const element = mutation.target.nodeType === 1 ? mutation.target : mutation.target.parentElement;
+ return element && !element.closest('#huayi-qb-root');
+ })) schedule();
+ });
+ observer.observe(doc, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['class', 'hidden', 'disabled', 'aria-disabled'] });
+ }
+ interval = win.setInterval(schedule, 1500);
+ schedule();
+ }
+ function destroy() { setEnabled(false); stopObservers(); destroyed = true; generation++; }
+ return { answerCurrent, setEnabled, isEnabled: () => state.enabled, getState: () => ({ ...state }), destroy };
+ }
+ return { planAnswer, create };
+})();
+if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
+
+
// ---- src/app.js ----
// UI and persistence stay inside the userscript sandbox. Page data is rendered as text.
(() => {
const CONFIG_KEY = 'huayi-qb.config.v1';
const RECORDS_KEY = 'huayi-qb.records.v1';
- const DEFAULTS = { mode: 'open', keepLauncher: true, copyEnabled: true, collecting: true, tab: 'search' };
+ const DEFAULTS = { mode: 'hidden', keepLauncher: false, copyEnabled: true, collecting: true, tab: 'search' };
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const storage = {
get(key, fallback) {
@@ -973,9 +1253,13 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
try { rawConfig = storage.get(CONFIG_KEY, {}); } catch (_) { rawConfig = {}; }
const config = { ...DEFAULTS, ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}) };
if (!['open', 'collapsed', 'hidden'].includes(config.mode)) config.mode = 'open';
- if (!['search', 'library', 'settings'].includes(config.tab)) config.tab = 'search';
+ if (!['search', 'library', 'auto', 'settings'].includes(config.tab)) config.tab = 'search';
['keepLauncher', 'copyEnabled', 'collecting'].forEach(key => { if (typeof config[key] !== 'boolean') config[key] = DEFAULTS[key]; });
+ // Every new page starts completely hidden, including upgrades from older settings.
+ config.mode = 'hidden';
+ config.keepLauncher = false;
let records = [];
+ let recordsDirty = false;
let loadError = '';
try {
const saved = storage.get(RECORDS_KEY, []);
@@ -983,15 +1267,37 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
records = QBCore.upsert([], saved);
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
let root, shadow, panel, launcher, resultBox, statusBox;
- let saveTimer, searchTimer, query = '', scope = '__current__', searchScope = '__current__', searchField = 'all';
+ let networkEnabled = ['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(window.location.hostname);
+ let saveTimer, searchTimer, query = '', scope = networkEnabled ? '__current__' : '', searchScope = scope, searchField = 'all';
let activeBank = null, importing = false;
let status = loadError || '准备就绪,可输入文字搜索,也可选中题干检索。';
let storageError = false;
let mounted = false;
- const adapter = QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged });
+ 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(); } });
+
+ function activateKnownPage() {
+ if (!networkEnabled && document.querySelector('.yxtulcdsdk-pc-marking-main')) {
+ adapter.stop();
+ adapter.setCopyEnabled(false);
+ networkEnabled = true;
+ scope = '__current__'; searchScope = '__current__';
+ adapter = createAdapter();
+ if (config.collecting) adapter.start();
+ adapter.setCopyEnabled(Boolean(config.copyEnabled));
+ }
+ }
function bankChanged(bank) {
- if (!bank || (!bank.bankName && !bank.bankId)) return;
+ if (!bank || (!bank.bankName && !bank.bankId)) {
+ if (activeBank) {
+ activeBank = null; scope = ''; searchScope = '';
+ if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); }
+ }
+ return;
+ }
const previous = activeBank;
const sameName = previous && bankNameKey(previous.bankName) === bankNameKey(bank.bankName);
const identityChanged = previous && (!sameName || (previous.taskId && bank.taskId && previous.taskId !== bank.taskId) || (previous.bankId && bank.bankId && previous.bankId !== bank.bankId));
@@ -1011,13 +1317,14 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
}
function persistRecords() {
clearTimeout(saveTimer);
- if (loadError) return;
+ if (loadError || !recordsDirty) return;
try {
// Merge other tabs before writing; GM change events also refresh an open panel.
const stored = storage.get(RECORDS_KEY, []);
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖');
records = QBCore.upsert(stored, records);
storage.set(RECORDS_KEY, records);
+ recordsDirty = false;
storageError = false;
} catch (error) {
storageError = true;
@@ -1040,6 +1347,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record;
});
records = QBCore.upsert(records, incoming);
+ recordsDirty = true;
clearTimeout(saveTimer);
saveTimer = setTimeout(persistRecords, 250);
if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); }
@@ -1069,7 +1377,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
if (!panel) return;
panel.hidden = config.mode === 'hidden';
panel.classList.toggle('collapsed', config.mode === 'collapsed');
- launcher.hidden = config.mode !== 'hidden' || !config.keepLauncher;
+ launcher.hidden = true;
const collapse = shadow.querySelector('[data-action="collapse"]');
collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起';
collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed'));
@@ -1131,7 +1439,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
}
function scopeName(selectedScope = scope) {
if (selectedScope === '') return '全部题库';
- if (selectedScope === '__current__') return currentBank()?.bankName || '未识别当前题库';
+ if (selectedScope === '__current__') return currentBank()?.bankName || '当前网页未识别题库';
const anchor = records.find(record => bankKey(record) === selectedScope);
return anchor?.bankName || '未命名题库';
}
@@ -1186,14 +1494,23 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
if (!shadow) return;
const bank = currentBank();
const current = currentQuestion();
- const name = bank?.bankName || '尚未识别当前题库';
+ const known = Boolean(bank?.bankName || bank?.bankId);
+ const name = bank?.bankName || '通用网页 · 本地题库';
shadow.getElementById('qb-context-name').textContent = name;
shadow.getElementById('qb-context-name').title = name;
const pool = scopedRecords('__current__');
- shadow.getElementById('qb-context-count').textContent = `${bank?.loading ? '切换中 · ' : ''}本库已采集 ${pool.length} 题 · 已确认 ${pool.filter(record => record.answerStatus === 'confirmed').length} 题`;
+ shadow.getElementById('qb-context-count').textContent = known ? `${bank?.loading ? '切换中 · ' : ''}本库已采集 ${pool.length} 题 · 已确认 ${pool.filter(record => record.answerStatus === 'confirmed').length} 题` : `本地共 ${records.length} 题 · 可划词或输入文字搜索`;
shadow.getElementById('qb-current-title').textContent = bank?.loading ? '题库切换中,等待当前题目加载。' : current?.stem || '打开练习题后,这里显示当前题目。';
shadow.getElementById('qb-current-bank').textContent = name;
}
+ function renderAuto() {
+ if (!shadow) 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);
+ }
function renderResults() {
if (!resultBox) return;
resultBox.replaceChildren();
@@ -1312,6 +1629,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
const before = records.length;
const merged = QBCore.upsert(records, incoming);
records = merged;
+ recordsDirty = true;
persistRecords();
refreshScope(); renderStats(); renderCurrent(); renderResults();
const bankCount = new Set(incoming.map(bankKey)).size;
@@ -1333,6 +1651,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
function mount() {
if (mounted || document.getElementById('huayi-qb-root')) return;
+ activateKnownPage();
root = node('div'); root.id = 'huayi-qb-root';
shadow = root.attachShadow({ mode: 'open' });
const style = node('style'); style.textContent = css;
@@ -1343,15 +1662,19 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
记录正常加载的练习题与标准答案。答案尚未返回的题目会标为待补,可在后续练习中继续补齐。
可一次选择多个 CSV / JSON,分别保留题库名称。无名称的文件使用文件名作为题库名。
根据本地已确认答案核对题干、题型和完整选项,按当前顺序选择。没有答案、答案冲突或无法唯一匹配的题目会跳过。
选择后按页面流程检查、提交或换题;部分网站会自动保存作答。
支持华谊练习页面,以及使用标准单选框/复选框和明确题干容器的网页。网页未识别时可继续使用文字搜索。
右上角可收起或隐藏,刷新后保持原状态。
显示 / 隐藏快捷键:Alt + Shift + Q
面板隐藏时,划选文字仍可更新检索内容,面板保持隐藏。
每次打开或刷新网页都默认完全隐藏,不显示悬浮按钮。
显示 / 隐藏快捷键:Shift + Alt + Q
面板隐藏时,划选文字仍可更新检索内容,面板保持隐藏。
按标准答案内容保存,再对应当前页面的选项位置。没有标准答案就显示待补;不同答案会保留冲突。所有资料保存在当前浏览器,建议定期导出备份。
记录正常加载的练习题与标准答案。答案尚未返回的题目会标为待补,可在后续练习中继续补齐。
可一次选择多个 CSV / JSON,分别保留题库名称。无名称的文件使用文件名作为题库名。
根据本地已确认答案核对题干、题型和完整选项,按当前顺序选择。没有答案、答案冲突或无法唯一匹配的题目会跳过。
选择后按页面流程检查、提交或换题;部分网站会自动保存作答。
支持华谊练习页面,以及使用标准单选框/复选框和明确题干容器的网页。网页未识别时可继续使用文字搜索。
右上角可收起或隐藏,刷新后保持原状态。
显示 / 隐藏快捷键:Alt + Shift + Q
面板隐藏时,划选文字仍可更新检索内容,面板保持隐藏。
每次打开或刷新网页都默认完全隐藏,不显示悬浮按钮。
显示 / 隐藏快捷键:Shift + Alt + Q
面板隐藏时,划选文字仍可更新检索内容,面板保持隐藏。
按标准答案内容保存,再对应当前页面的选项位置。没有标准答案就显示待补;不同答案会保留冲突。所有资料保存在当前浏览器,建议定期导出备份。
使用本地题库,仅选择已确认的选项。