feat: add opt-in answer selection and global hidden panel
This commit is contained in:
@@ -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 或运行服务器。
|
安装文件:[`dist/huayi-question-bank.user.js`](dist/huayi-question-bank.user.js)。这是构建后的完整脚本,无须安装 Node.js 或运行服务器。
|
||||||
|
|
||||||
@@ -8,12 +8,25 @@
|
|||||||
|
|
||||||
1. 在浏览器中安装并启用 Tampermonkey(油猴)。
|
1. 在浏览器中安装并启用 Tampermonkey(油猴)。
|
||||||
2. 打开油猴管理面板,选择“新建脚本”,将安装文件的全部内容粘贴进去并保存。
|
2. 打开油猴管理面板,选择“新建脚本”,将安装文件的全部内容粘贴进去并保存。
|
||||||
3. 打开或刷新 `https://www.shhuayi-elearning.cn/` 下的练习页面。安装后应重新进入练习,以便观察首次加载的题目响应。
|
3. 打开或刷新普通网页,按 **Shift + Alt + Q** 显示题库助手,再按一次隐藏。每次加载页面都默认完全隐藏,无悬浮按钮;旧版保存的打开状态也会重置。
|
||||||
4. 右下角出现“题库助手”。默认开始采集正常加载的练习题;已加载的批量题目会一起记录。若答案需要作答后才返回,则在正常练习后补齐。
|
4. 在华谊练习页面,正常加载的题目和标准答案仍会自动保存;安装后应重新进入练习,以便观察首次题目响应。普通网页只使用本地题库检索,不观察其网络请求或自动采集表单。
|
||||||
5. 在“文字搜索”中输入或粘贴至少两个字符,按回车或点“搜索”。也可以在页面划选题干后松开鼠标自动查询。
|
5. 在“文字搜索”中输入或粘贴至少两个字符,按回车或点“搜索”。也可以在页面划选题干后松开鼠标自动查询。
|
||||||
6. 在“题库 / 导出”中检查实际题库名、范围内题数及文件名预览,再导出 CSV 或备份 JSON。默认只导出当前题库,全部题库需要手动选择。
|
6. 在“题库 / 导出”中检查实际题库名、范围内题数及文件名预览,再导出 CSV 或备份 JSON。已识别的练习页默认当前题库;普通网页默认全部已存题库。
|
||||||
|
|
||||||
浏览器需允许油猴在该网站运行用户脚本。若没有出现面板,先确认扩展已启用、该站点有运行权限,再刷新页面。
|
浏览器需允许油猴在相应网站运行用户脚本。更新到全网页版本后,浏览器可能需要重新授予网站访问权限。若快捷键没有显示面板,先确认扩展已启用且该站点有运行权限。浏览器内置页面、扩展商店及受限制页面不能运行普通用户脚本;本地文件取决于浏览器的文件访问设置,本版匹配普通 HTTP / HTTPS 页面。
|
||||||
|
|
||||||
|
## 自动答题
|
||||||
|
|
||||||
|
打开“自动答题”页签,先导入包含标准答案的题库,然后选择:
|
||||||
|
|
||||||
|
- **答当前题**:从本地已确认记录中精确匹配当前题干、题型和完整选项,自动选择正确选项。
|
||||||
|
- **连续自动选择**:仅在当前页面启用;题目变化后继续核对并选择。关闭后立即停止,刷新或打开其他网页后默认关闭。
|
||||||
|
|
||||||
|
单选按答案内容选择当前选项;多选同步完整正确集合,取消已经选中的错误项;判断题按“正确 / 错误”对应。候选答案冲突、题干或选项不完整、重复文字无法唯一对应、选项不可操作时跳过,显示原因。
|
||||||
|
|
||||||
|
选择完成后由你检查并提交或进入下一题。脚本不会点击提交、交卷、下一题按钮。华谊练习中的选项点击可能触发网站自身的作答响应,具体行为由练习页面决定。
|
||||||
|
|
||||||
|
自动选择支持华谊题目 DOM,以及标准 `fieldset + legend + label + radio/checkbox` 和明确题目容器结构。全网页显示不代表能自动识别任意网站的自定义答题控件;无法识别时可继续使用文字搜索。题库仍保存在同一个油猴脚本存储中,可在不同网站访问。
|
||||||
|
|
||||||
## 本次方案:按题库分别统计、搜索和导出
|
## 本次方案:按题库分别统计、搜索和导出
|
||||||
|
|
||||||
@@ -28,7 +41,7 @@
|
|||||||
## 文字搜索
|
## 文字搜索
|
||||||
|
|
||||||
- 输入或粘贴完整题干、题干片段、选项内容或标准答案关键词,支持按钮、回车和输入后自动查询,也可点“清空”。
|
- 输入或粘贴完整题干、题干片段、选项内容或标准答案关键词,支持按钮、回车和输入后自动查询,也可点“清空”。
|
||||||
- “搜索题库”可选当前库、任意已保存库或全部库;默认当前库。搜索范围与导出范围独立,跨库查答案不会让导出自动变成全部。
|
- “搜索题库”可选当前库、任意已保存库或全部库;已识别练习页默认当前库,普通网页默认全部库。搜索范围与导出范围独立,跨库查答案不会让导出自动变成全部。
|
||||||
- “搜索内容”可选全部文字、仅题干、仅选项、仅标准答案;多个关键词可用空格分隔,按同时包含处理。
|
- “搜索内容”可选全部文字、仅题干、仅选项、仅标准答案;多个关键词可用空格分隔,按同时包含处理。
|
||||||
- 结果标注命中题干、选项或答案,显示来源题库。只把已确认答案用于答案字段搜索,冲突和待补记录仍可通过题干找到。
|
- 结果标注命中题干、选项或答案,显示来源题库。只把已确认答案用于答案字段搜索,冲突和待补记录仍可通过题干找到。
|
||||||
- 每次最多展示 50 条结果,可细化关键词继续查找。
|
- 每次最多展示 50 条结果,可细化关键词继续查找。
|
||||||
@@ -46,11 +59,12 @@
|
|||||||
| 操作 | 效果 | 恢复方式 |
|
| 操作 | 效果 | 恢复方式 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 右上角“收起” | 只显示面板标题栏 | 点击“展开” |
|
| 右上角“收起” | 只显示面板标题栏 | 点击“展开” |
|
||||||
| 右上角“隐藏” | 面板隐藏,默认保留圆形“题库”按钮 | 点击悬浮按钮 |
|
| 打开或刷新网页 | 默认完全隐藏,无悬浮按钮 | **Shift + Alt + Q** |
|
||||||
| 设置中取消“隐藏时保留悬浮按钮”,再隐藏 | 面板和悬浮按钮均隐藏 | **Alt + Shift + Q** 或油猴菜单 |
|
| 右上角“隐藏” | 面板完全隐藏 | **Shift + Alt + Q** |
|
||||||
| 油猴菜单“完全隐藏题库助手” | 直接完全隐藏 | **Alt + Shift + Q** 或菜单“显示题库助手” |
|
| **Shift + Alt + Q** | 在显示与隐藏之间切换 | 再按一次 |
|
||||||
|
| 油猴菜单“显示题库助手” | 手动显示面板 | 右上角隐藏或快捷键 |
|
||||||
|
|
||||||
显示状态、悬浮按钮偏好和复制开关会保留,刷新后继续生效。隐藏时划选题干仍会更新查询,但不会重新弹出面板。隐藏与暂停采集是独立操作;需要暂停时,使用“题库 / 导出”中的“暂停采集”。
|
复制开关等设置会保留;面板在每次加载后均重置为隐藏,连续自动选择也会关闭。隐藏时划选题干仍会更新查询,但不会重新弹出面板。隐藏、采集和连续自动选择是独立操作;隐藏面板不会停止你在当前页已开启的连续选择,停止时请先关闭相应开关。
|
||||||
|
|
||||||
## 正确答案如何防止错位
|
## 正确答案如何防止错位
|
||||||
|
|
||||||
@@ -81,7 +95,7 @@ JSON 备份包含 `schemaVersion: 1` 和 `records` 数组,保留完整选项
|
|||||||
|
|
||||||
## 采集范围与当前验证边界
|
## 采集范围与当前验证边界
|
||||||
|
|
||||||
脚本在当前账号正常练习时,观察已收到的题目 / 答案响应,并补充当前页面 DOM。不主动提交答案。接口没有提供标准答案时,保留待补状态;仅安装脚本不会凭空补出答案。
|
脚本在当前账号正常练习时,观察已收到的题目 / 答案响应,并补充当前页面 DOM。自动选择只操作对应选项,网站可能按自身规则保存作答。接口没有提供标准答案时,保留待补状态;仅安装脚本不会凭空补出答案。
|
||||||
|
|
||||||
目前适配了保存页面中的真实题目结构,以及业务脚本中可确认的练习接口:`/upm/start`、`/upm/wrong/start`、`/pra/{id}/questions` 和对应单题作答结果。接口适配限制在本站及学习平台域名。脚本不发送题库到外部服务器,来源 URL 不保存查询参数。
|
目前适配了保存页面中的真实题目结构,以及业务脚本中可确认的练习接口:`/upm/start`、`/upm/wrong/start`、`/pra/{id}/questions` 和对应单题作答结果。接口适配限制在本站及学习平台域名。脚本不发送题库到外部服务器,来源 URL 不保存查询参数。
|
||||||
|
|
||||||
@@ -95,6 +109,7 @@ JSON 备份包含 `schemaVersion: 1` 和 `records` 数组,保留完整选项
|
|||||||
|
|
||||||
- `src/core.js`:答案规范化、去重与冲突、检索、字母映射、CSV。
|
- `src/core.js`:答案规范化、去重与冲突、检索、字母映射、CSV。
|
||||||
- `src/adapter.js`:本站响应观察、题目 DOM 读取、复制恢复。
|
- `src/adapter.js`:本站响应观察、题目 DOM 读取、复制恢复。
|
||||||
|
- `src/autofill.js`:精确答案匹配、标准选项操作与连续选择。
|
||||||
- `src/app.js`:面板、隐藏恢复、存储、导入导出。
|
- `src/app.js`:面板、隐藏恢复、存储、导入导出。
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ const root = __dirname;
|
|||||||
const header = `// ==UserScript==
|
const header = `// ==UserScript==
|
||||||
// @name 华谊题库助手 · CSV导出与划词检索
|
// @name 华谊题库助手 · CSV导出与划词检索
|
||||||
// @namespace local.huayi.question-bank
|
// @namespace local.huayi.question-bank
|
||||||
// @version 1.1.0
|
// @version 1.2.0
|
||||||
// @description 按题库采集与同名CSV导出,支持多题库批量导入、文字搜索、划词查答案、选择复制与隐藏面板。
|
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
|
||||||
// @match https://www.shhuayi-elearning.cn/*
|
// @match *://*/*
|
||||||
// @match https://shhuayi-elearning.cn/*
|
|
||||||
// @run-at document-start
|
// @run-at document-start
|
||||||
// @noframes
|
// @noframes
|
||||||
// @grant unsafeWindow
|
// @grant unsafeWindow
|
||||||
@@ -17,7 +16,7 @@ const header = `// ==UserScript==
|
|||||||
// @grant GM_addValueChangeListener
|
// @grant GM_addValueChangeListener
|
||||||
// ==/UserScript==
|
// ==/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');
|
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 });
|
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
|
||||||
const target = path.join(root, 'dist/huayi-question-bank.user.js');
|
const target = path.join(root, 'dist/huayi-question-bank.user.js');
|
||||||
|
|||||||
Vendored
+354
-29
@@ -1,10 +1,9 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name 华谊题库助手 · CSV导出与划词检索
|
// @name 华谊题库助手 · CSV导出与划词检索
|
||||||
// @namespace local.huayi.question-bank
|
// @namespace local.huayi.question-bank
|
||||||
// @version 1.1.0
|
// @version 1.2.0
|
||||||
// @description 按题库采集与同名CSV导出,支持多题库批量导入、文字搜索、划词查答案、选择复制与隐藏面板。
|
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
|
||||||
// @match https://www.shhuayi-elearning.cn/*
|
// @match *://*/*
|
||||||
// @match https://shhuayi-elearning.cn/*
|
|
||||||
// @run-at document-start
|
// @run-at document-start
|
||||||
// @noframes
|
// @noframes
|
||||||
// @grant unsafeWindow
|
// @grant unsafeWindow
|
||||||
@@ -566,7 +565,7 @@ const QBAdapter = (() => {
|
|||||||
} catch (_) { return ''; }
|
} 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 active = false;
|
||||||
let copyEnabled = false;
|
let copyEnabled = false;
|
||||||
let observer = null;
|
let observer = null;
|
||||||
@@ -593,6 +592,14 @@ const QBAdapter = (() => {
|
|||||||
const status = message => { try { onStatus(message); } catch (_) { /* UI callbacks must not affect the page. */ } };
|
const status = message => { try { onStatus(message); } catch (_) { /* UI callbacks must not affect the page. */ } };
|
||||||
const locationURL = () => String(win.location && win.location.href || '');
|
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() {
|
function formSnapshot() {
|
||||||
const element = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].find(visible);
|
const element = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].find(visible);
|
||||||
return element ? { element, text: flat(element.textContent) } : null;
|
return element ? { element, text: flat(element.textContent) } : null;
|
||||||
@@ -628,6 +635,18 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncBank() {
|
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 state = bankDOM();
|
||||||
const identity = JSON.stringify([state.taskId, flat(state.bankName)]);
|
const identity = JSON.stringify([state.taskId, flat(state.bankName)]);
|
||||||
const oldIdentity = currentBank && JSON.stringify([currentBank.taskId, flat(currentBank.bankName)]);
|
const oldIdentity = currentBank && JSON.stringify([currentBank.taskId, flat(currentBank.bankName)]);
|
||||||
@@ -663,7 +682,7 @@ const QBAdapter = (() => {
|
|||||||
function getCurrentBank() { return syncBank(); }
|
function getCurrentBank() { return syncBank(); }
|
||||||
|
|
||||||
function ensureBankListeners() {
|
function ensureBankListeners() {
|
||||||
if (bankListeners) return;
|
if (bankListeners || !isSupportedPage()) return;
|
||||||
bankListeners = true;
|
bankListeners = true;
|
||||||
win.addEventListener?.('hashchange', syncBank);
|
win.addEventListener?.('hashchange', syncBank);
|
||||||
win.addEventListener?.('popstate', syncBank);
|
win.addEventListener?.('popstate', syncBank);
|
||||||
@@ -713,6 +732,7 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentQuestion() {
|
function getCurrentQuestion() {
|
||||||
|
if (!isSupportedPage()) return null;
|
||||||
const bank = syncBank();
|
const bank = syncBank();
|
||||||
if (bankDOM().mismatch) return null;
|
if (bankDOM().mismatch) return null;
|
||||||
const forms = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].filter(visible);
|
const forms = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].filter(visible);
|
||||||
@@ -774,6 +794,10 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function captureCurrent() {
|
function captureCurrent() {
|
||||||
|
if (!isSupportedPage()) {
|
||||||
|
status('当前为通用网页,可使用本地题库搜索;本站采集未开启。');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const current = getCurrentQuestion();
|
const current = getCurrentQuestion();
|
||||||
if (current) emit([current]);
|
if (current) emit([current]);
|
||||||
else status('当前页面未发现可采集的练习题,请进入题目页面。');
|
else status('当前页面未发现可采集的练习题,请进入题目页面。');
|
||||||
@@ -796,6 +820,7 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function installNetwork() {
|
function installNetwork() {
|
||||||
|
if (!observeNetwork || !isSupportedPage()) return;
|
||||||
const generation = ++networkGeneration;
|
const generation = ++networkGeneration;
|
||||||
if (typeof win.fetch === 'function') {
|
if (typeof win.fetch === 'function') {
|
||||||
const originalFetch = win.fetch;
|
const originalFetch = win.fetch;
|
||||||
@@ -882,7 +907,7 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureCopyStyle() {
|
function ensureCopyStyle() {
|
||||||
if (!copyEnabled || copyStyle?.isConnected) return;
|
if (!copyEnabled || copyStyle?.isConnected || !isSupportedPage()) return;
|
||||||
const parent = doc.head || doc.documentElement;
|
const parent = doc.head || doc.documentElement;
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
copyStyle = doc.createElement('style');
|
copyStyle = doc.createElement('style');
|
||||||
@@ -892,7 +917,7 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureObserver() {
|
function ensureObserver() {
|
||||||
if (observer || !win.MutationObserver) return;
|
if (observer || !win.MutationObserver || !isSupportedPage()) return;
|
||||||
observer = new win.MutationObserver(mutations => {
|
observer = new win.MutationObserver(mutations => {
|
||||||
const relevant = mutations.some(m => {
|
const relevant = mutations.some(m => {
|
||||||
const node = m.target.nodeType === 1 ? m.target : m.target.parentElement;
|
const node = m.target.nodeType === 1 ? m.target : m.target.parentElement;
|
||||||
@@ -914,10 +939,12 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setCopyEnabled(value) {
|
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']) {
|
for (const type of ['copy', 'cut', 'contextmenu', 'selectstart']) {
|
||||||
win.removeEventListener(type, copyEvent, true);
|
win.removeEventListener?.(type, copyEvent, true);
|
||||||
if (copyEnabled) win.addEventListener(type, copyEvent, true);
|
if (copyEnabled) win.addEventListener?.(type, copyEvent, true);
|
||||||
}
|
}
|
||||||
if (copyEnabled) { ensureCopyStyle(); ensureObserver(); }
|
if (copyEnabled) { ensureCopyStyle(); ensureObserver(); }
|
||||||
else if (copyStyle) { copyStyle.remove(); copyStyle = null; }
|
else if (copyStyle) { copyStyle.remove(); copyStyle = null; }
|
||||||
@@ -929,6 +956,11 @@ const QBAdapter = (() => {
|
|||||||
function start() {
|
function start() {
|
||||||
if (active) return;
|
if (active) return;
|
||||||
active = true;
|
active = true;
|
||||||
|
if (!isSupportedPage()) {
|
||||||
|
syncBank();
|
||||||
|
status('当前为通用网页,可使用本地题库搜索;本站采集未开启。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
ensureBankListeners();
|
ensureBankListeners();
|
||||||
ensureObserver();
|
ensureObserver();
|
||||||
installNetwork();
|
installNetwork();
|
||||||
@@ -943,7 +975,7 @@ const QBAdapter = (() => {
|
|||||||
status('采集已暂停,已保存题库和选择复制功能仍可使用。');
|
status('采集已暂停,已保存题库和选择复制功能仍可使用。');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled };
|
return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled, isSupportedPage };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { create, extractQuestions };
|
return { create, extractQuestions };
|
||||||
@@ -951,12 +983,260 @@ const QBAdapter = (() => {
|
|||||||
if (typeof module !== 'undefined' && module.exports) module.exports = 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 ----
|
// ---- src/app.js ----
|
||||||
// UI and persistence stay inside the userscript sandbox. Page data is rendered as text.
|
// UI and persistence stay inside the userscript sandbox. Page data is rendered as text.
|
||||||
(() => {
|
(() => {
|
||||||
const CONFIG_KEY = 'huayi-qb.config.v1';
|
const CONFIG_KEY = 'huayi-qb.config.v1';
|
||||||
const RECORDS_KEY = 'huayi-qb.records.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 pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
|
||||||
const storage = {
|
const storage = {
|
||||||
get(key, fallback) {
|
get(key, fallback) {
|
||||||
@@ -973,9 +1253,13 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
try { rawConfig = storage.get(CONFIG_KEY, {}); } catch (_) { rawConfig = {}; }
|
try { rawConfig = storage.get(CONFIG_KEY, {}); } catch (_) { rawConfig = {}; }
|
||||||
const config = { ...DEFAULTS, ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}) };
|
const config = { ...DEFAULTS, ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}) };
|
||||||
if (!['open', 'collapsed', 'hidden'].includes(config.mode)) config.mode = 'open';
|
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]; });
|
['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 records = [];
|
||||||
|
let recordsDirty = false;
|
||||||
let loadError = '';
|
let loadError = '';
|
||||||
try {
|
try {
|
||||||
const saved = storage.get(RECORDS_KEY, []);
|
const saved = storage.get(RECORDS_KEY, []);
|
||||||
@@ -983,15 +1267,37 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
records = QBCore.upsert([], saved);
|
records = QBCore.upsert([], saved);
|
||||||
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
|
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
|
||||||
let root, shadow, panel, launcher, resultBox, statusBox;
|
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 activeBank = null, importing = false;
|
||||||
let status = loadError || '准备就绪,可输入文字搜索,也可选中题干检索。';
|
let status = loadError || '准备就绪,可输入文字搜索,也可选中题干检索。';
|
||||||
let storageError = false;
|
let storageError = false;
|
||||||
let mounted = 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) {
|
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 previous = activeBank;
|
||||||
const sameName = previous && bankNameKey(previous.bankName) === bankNameKey(bank.bankName);
|
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));
|
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() {
|
function persistRecords() {
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
if (loadError) return;
|
if (loadError || !recordsDirty) return;
|
||||||
try {
|
try {
|
||||||
// Merge other tabs before writing; GM change events also refresh an open panel.
|
// Merge other tabs before writing; GM change events also refresh an open panel.
|
||||||
const stored = storage.get(RECORDS_KEY, []);
|
const stored = storage.get(RECORDS_KEY, []);
|
||||||
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖');
|
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖');
|
||||||
records = QBCore.upsert(stored, records);
|
records = QBCore.upsert(stored, records);
|
||||||
storage.set(RECORDS_KEY, records);
|
storage.set(RECORDS_KEY, records);
|
||||||
|
recordsDirty = false;
|
||||||
storageError = false;
|
storageError = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
storageError = true;
|
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;
|
return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record;
|
||||||
});
|
});
|
||||||
records = QBCore.upsert(records, incoming);
|
records = QBCore.upsert(records, incoming);
|
||||||
|
recordsDirty = true;
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
saveTimer = setTimeout(persistRecords, 250);
|
saveTimer = setTimeout(persistRecords, 250);
|
||||||
if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); }
|
if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); }
|
||||||
@@ -1069,7 +1377,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.hidden = config.mode === 'hidden';
|
panel.hidden = config.mode === 'hidden';
|
||||||
panel.classList.toggle('collapsed', config.mode === 'collapsed');
|
panel.classList.toggle('collapsed', config.mode === 'collapsed');
|
||||||
launcher.hidden = config.mode !== 'hidden' || !config.keepLauncher;
|
launcher.hidden = true;
|
||||||
const collapse = shadow.querySelector('[data-action="collapse"]');
|
const collapse = shadow.querySelector('[data-action="collapse"]');
|
||||||
collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起';
|
collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起';
|
||||||
collapse.setAttribute('aria-expanded', String(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) {
|
function scopeName(selectedScope = scope) {
|
||||||
if (selectedScope === '') return '全部题库';
|
if (selectedScope === '') return '全部题库';
|
||||||
if (selectedScope === '__current__') return currentBank()?.bankName || '未识别当前题库';
|
if (selectedScope === '__current__') return currentBank()?.bankName || '当前网页未识别题库';
|
||||||
const anchor = records.find(record => bankKey(record) === selectedScope);
|
const anchor = records.find(record => bankKey(record) === selectedScope);
|
||||||
return anchor?.bankName || '未命名题库';
|
return anchor?.bankName || '未命名题库';
|
||||||
}
|
}
|
||||||
@@ -1186,14 +1494,23 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
if (!shadow) return;
|
if (!shadow) return;
|
||||||
const bank = currentBank();
|
const bank = currentBank();
|
||||||
const current = currentQuestion();
|
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').textContent = name;
|
||||||
shadow.getElementById('qb-context-name').title = name;
|
shadow.getElementById('qb-context-name').title = name;
|
||||||
const pool = scopedRecords('__current__');
|
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-title').textContent = bank?.loading ? '题库切换中,等待当前题目加载。' : current?.stem || '打开练习题后,这里显示当前题目。';
|
||||||
shadow.getElementById('qb-current-bank').textContent = name;
|
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() {
|
function renderResults() {
|
||||||
if (!resultBox) return;
|
if (!resultBox) return;
|
||||||
resultBox.replaceChildren();
|
resultBox.replaceChildren();
|
||||||
@@ -1312,6 +1629,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
const before = records.length;
|
const before = records.length;
|
||||||
const merged = QBCore.upsert(records, incoming);
|
const merged = QBCore.upsert(records, incoming);
|
||||||
records = merged;
|
records = merged;
|
||||||
|
recordsDirty = true;
|
||||||
persistRecords();
|
persistRecords();
|
||||||
refreshScope(); renderStats(); renderCurrent(); renderResults();
|
refreshScope(); renderStats(); renderCurrent(); renderResults();
|
||||||
const bankCount = new Set(incoming.map(bankKey)).size;
|
const bankCount = new Set(incoming.map(bankKey)).size;
|
||||||
@@ -1333,6 +1651,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
|
|
||||||
function mount() {
|
function mount() {
|
||||||
if (mounted || document.getElementById('huayi-qb-root')) return;
|
if (mounted || document.getElementById('huayi-qb-root')) return;
|
||||||
|
activateKnownPage();
|
||||||
root = node('div'); root.id = 'huayi-qb-root';
|
root = node('div'); root.id = 'huayi-qb-root';
|
||||||
shadow = root.attachShadow({ mode: 'open' });
|
shadow = root.attachShadow({ mode: 'open' });
|
||||||
const style = node('style'); style.textContent = css;
|
const style = node('style'); style.textContent = css;
|
||||||
@@ -1343,15 +1662,19 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
<header class="header"><div><div class="brand">题库助手</div><div class="subtitle">HUAYI · 本地练习资料</div></div><div class="tools"><button type="button" data-action="collapse" title="收起或展开内容" aria-expanded="true">收起</button><button type="button" data-action="hide" title="隐藏面板,Alt + Shift + Q 恢复">隐藏</button></div></header>
|
<header class="header"><div><div class="brand">题库助手</div><div class="subtitle">HUAYI · 本地练习资料</div></div><div class="tools"><button type="button" data-action="collapse" title="收起或展开内容" aria-expanded="true">收起</button><button type="button" data-action="hide" title="隐藏面板,Alt + Shift + Q 恢复">隐藏</button></div></header>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="context"><span class="context-label">当前题库</span><strong id="qb-context-name">正在识别题库…</strong><div class="context-count" id="qb-context-count"></div></div>
|
<div class="context"><span class="context-label">当前题库</span><strong id="qb-context-name">正在识别题库…</strong><div class="context-count" id="qb-context-count"></div></div>
|
||||||
<nav class="tabs" role="tablist" aria-label="助手功能"><button type="button" data-tab="search" role="tab" aria-controls="qb-view-search">文字搜索</button><button type="button" data-tab="library" role="tab" aria-controls="qb-view-library">题库 / 导出</button><button type="button" data-tab="settings" role="tab" aria-controls="qb-view-settings">设置</button></nav>
|
<nav class="tabs" role="tablist" aria-label="助手功能"><button type="button" data-tab="search" role="tab" aria-controls="qb-view-search">文字搜索</button><button type="button" data-tab="library" role="tab" aria-controls="qb-view-library">题库 / 导出</button><button type="button" data-tab="auto" role="tab" aria-controls="qb-view-auto">自动答题</button><button type="button" data-tab="settings" role="tab" aria-controls="qb-view-settings">设置</button></nav>
|
||||||
<div class="view" id="qb-view-search" data-view="search" role="tabpanel"><div class="search-label"><label for="qb-search">输入或粘贴文字查答案</label><button type="button" class="text-button" data-action="clear-search">清空</button></div><div class="search-wrap"><input id="qb-search" type="search" maxlength="4000" placeholder="题干、选项或答案关键词…" aria-label="文字搜索"><button type="button" class="btn primary" data-action="search">搜索</button></div><div class="search-filters"><label>搜索题库<select id="qb-search-scope" aria-label="搜索题库"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><label>搜索内容<select id="qb-search-field" aria-label="搜索内容"><option value="all">全部文字</option><option value="stem">仅题干</option><option value="options">仅选项</option><option value="answer">仅标准答案</option></select></label></div><p class="hint" id="qb-query-state"></p><div id="qb-results" aria-live="polite"></div></div>
|
<div class="view" id="qb-view-search" data-view="search" role="tabpanel"><div class="search-label"><label for="qb-search">输入或粘贴文字查答案</label><button type="button" class="text-button" data-action="clear-search">清空</button></div><div class="search-wrap"><input id="qb-search" type="search" maxlength="4000" placeholder="题干、选项或答案关键词…" aria-label="文字搜索"><button type="button" class="btn primary" data-action="search">搜索</button></div><div class="search-filters"><label>搜索题库<select id="qb-search-scope" aria-label="搜索题库"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><label>搜索内容<select id="qb-search-field" aria-label="搜索内容"><option value="all">全部文字</option><option value="stem">仅题干</option><option value="options">仅选项</option><option value="answer">仅标准答案</option></select></label></div><p class="hint" id="qb-query-state"></p><div id="qb-results" aria-live="polite"></div></div>
|
||||||
<div class="view" id="qb-view-library" data-view="library" role="tabpanel" hidden>
|
<div class="view" id="qb-view-library" data-view="library" role="tabpanel" hidden>
|
||||||
<div class="section"><h3>统计与导出范围</h3><label class="scope">选择题库<select id="qb-scope" aria-label="题库范围"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><p class="export-name" id="qb-export-name"></p><div class="stats"><div class="stat"><strong id="qb-stat-total">0</strong><span>范围内题数</span></div><div class="stat"><strong id="qb-stat-confirmed">0</strong><span>答案确认</span></div><div class="stat"><strong id="qb-stat-missing">0</strong><span>待补答案</span></div><div class="stat"><strong id="qb-stat-conflicts">0</strong><span>答案冲突</span></div></div><div class="row"><button type="button" class="btn primary" data-action="export-csv">导出 CSV</button><button type="button" class="btn" data-action="export-json">备份 JSON</button></div><p class="filename" id="qb-export-preview"></p></div>
|
<div class="section"><h3>统计与导出范围</h3><label class="scope">选择题库<select id="qb-scope" aria-label="题库范围"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><p class="export-name" id="qb-export-name"></p><div class="stats"><div class="stat"><strong id="qb-stat-total">0</strong><span>范围内题数</span></div><div class="stat"><strong id="qb-stat-confirmed">0</strong><span>答案确认</span></div><div class="stat"><strong id="qb-stat-missing">0</strong><span>待补答案</span></div><div class="stat"><strong id="qb-stat-conflicts">0</strong><span>答案冲突</span></div></div><div class="row"><button type="button" class="btn primary" data-action="export-csv">导出 CSV</button><button type="button" class="btn" data-action="export-json">备份 JSON</button></div><p class="filename" id="qb-export-preview"></p></div>
|
||||||
<div class="section"><div class="row spread"><h3>当前练习</h3><span class="state" id="qb-capture-state">采集中</span></div><p id="qb-current-bank"></p><div id="qb-current-title"></div><div class="row"><button type="button" class="btn small primary" data-action="capture-current">采集当前题</button><button type="button" class="btn small" data-action="toggle-capture">暂停采集</button></div><p>记录正常加载的练习题与标准答案。答案尚未返回的题目会标为待补,可在后续练习中继续补齐。</p></div>
|
<div class="section"><div class="row spread"><h3>当前练习</h3><span class="state" id="qb-capture-state">采集中</span></div><p id="qb-current-bank"></p><div id="qb-current-title"></div><div class="row"><button type="button" class="btn small primary" data-action="capture-current">采集当前题</button><button type="button" class="btn small" data-action="toggle-capture">暂停采集</button></div><p>记录正常加载的练习题与标准答案。答案尚未返回的题目会标为待补,可在后续练习中继续补齐。</p></div>
|
||||||
<div class="section"><div class="row spread"><h3>已保存题库</h3><button type="button" class="btn small" data-action="import">导入多个题库</button></div><input id="qb-import-file" type="file" accept=".csv,.json,text/csv,application/json" multiple hidden><p>可一次选择多个 CSV / JSON,分别保留题库名称。无名称的文件使用文件名作为题库名。</p><p id="qb-library-summary"></p><div class="bank-list" id="qb-bank-list"></div></div>
|
<div class="section"><div class="row spread"><h3>已保存题库</h3><button type="button" class="btn small" data-action="import">导入多个题库</button></div><input id="qb-import-file" type="file" accept=".csv,.json,text/csv,application/json" multiple hidden><p>可一次选择多个 CSV / JSON,分别保留题库名称。无名称的文件使用文件名作为题库名。</p><p id="qb-library-summary"></p><div class="bank-list" id="qb-bank-list"></div></div>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
<div class="view" id="qb-view-settings" data-view="settings" role="tabpanel" hidden>
|
<div class="view" id="qb-view-settings" data-view="settings" role="tabpanel" hidden>
|
||||||
<div class="section"><h3>面板显示</h3><p>右上角可收起或隐藏,刷新后保持原状态。</p><label class="check"><input id="qb-keep-launcher" type="checkbox"><span>隐藏时保留悬浮按钮<small>取消后可完全隐藏,用快捷键或油猴菜单恢复。</small></span></label><p>显示 / 隐藏快捷键:<kbd class="kbd">Alt + Shift + Q</kbd></p><p>面板隐藏时,划选文字仍可更新检索内容,面板保持隐藏。</p><button type="button" class="btn small" data-action="hide">立即隐藏</button></div>
|
<div class="section"><h3>面板显示</h3><p>每次打开或刷新网页都默认完全隐藏,不显示悬浮按钮。</p><p>显示 / 隐藏快捷键:<kbd class="kbd">Shift + Alt + Q</kbd></p><p>面板隐藏时,划选文字仍可更新检索内容,面板保持隐藏。</p><button type="button" class="btn small" data-action="hide">立即隐藏</button></div>
|
||||||
<div class="section"><h3>选择与复制</h3><label class="check"><input id="qb-copy-enabled" type="checkbox"><span>恢复题目区域的选择复制<small>换题后自动适配,关闭后恢复原有处理。</small></span></label></div>
|
<div class="section"><h3>选择与复制</h3><label class="check"><input id="qb-copy-enabled" type="checkbox"><span>恢复题目区域的选择复制<small>换题后自动适配,关闭后恢复原有处理。</small></span></label></div>
|
||||||
<div class="section"><h3>答案规则</h3><p>按标准答案内容保存,再对应当前页面的选项位置。没有标准答案就显示待补;不同答案会保留冲突。所有资料保存在当前浏览器,建议定期导出备份。</p></div>
|
<div class="section"><h3>答案规则</h3><p>按标准答案内容保存,再对应当前页面的选项位置。没有标准答案就显示待补;不同答案会保留冲突。所有资料保存在当前浏览器,建议定期导出备份。</p></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1363,7 +1686,6 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
panel = shadow.getElementById('qb-panel'); launcher = shadow.getElementById('qb-launcher');
|
panel = shadow.getElementById('qb-panel'); launcher = shadow.getElementById('qb-launcher');
|
||||||
resultBox = shadow.getElementById('qb-results'); statusBox = shadow.getElementById('qb-status');
|
resultBox = shadow.getElementById('qb-results'); statusBox = shadow.getElementById('qb-status');
|
||||||
mounted = true;
|
mounted = true;
|
||||||
shadow.getElementById('qb-keep-launcher').checked = Boolean(config.keepLauncher);
|
|
||||||
shadow.getElementById('qb-copy-enabled').checked = Boolean(config.copyEnabled);
|
shadow.getElementById('qb-copy-enabled').checked = Boolean(config.copyEnabled);
|
||||||
shadow.addEventListener('click', event => {
|
shadow.addEventListener('click', event => {
|
||||||
const button = event.target.closest('button');
|
const button = event.target.closest('button');
|
||||||
@@ -1376,6 +1698,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
case 'collapse': setMode(config.mode === 'collapsed' ? 'open' : 'collapsed'); break;
|
case 'collapse': setMode(config.mode === 'collapsed' ? 'open' : 'collapsed'); break;
|
||||||
case 'search': runSearch(shadow.getElementById('qb-search').value); break;
|
case 'search': runSearch(shadow.getElementById('qb-search').value); break;
|
||||||
case 'clear-search': clearTimeout(searchTimer); runSearch(''); shadow.getElementById('qb-search').focus(); 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 'capture-current': {
|
case 'capture-current': {
|
||||||
const captured = adapter.captureCurrent();
|
const captured = adapter.captureCurrent();
|
||||||
renderCurrent();
|
renderCurrent();
|
||||||
@@ -1405,7 +1728,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
shadow.getElementById('qb-scope').addEventListener('change', event => { scope = event.target.value; renderStats(); });
|
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-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-search-field').addEventListener('change', event => { searchField = event.target.value; renderResults(); });
|
||||||
shadow.getElementById('qb-keep-launcher').addEventListener('change', event => { config.keepLauncher = event.target.checked; persistConfig(); renderMode(); });
|
shadow.getElementById('qb-auto-enabled').addEventListener('change', event => { autoAnswer.setEnabled(event.target.checked); renderAuto(); });
|
||||||
shadow.getElementById('qb-copy-enabled').addEventListener('change', event => { config.copyEnabled = event.target.checked; adapter.setCopyEnabled(config.copyEnabled); persistConfig(); });
|
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 => {
|
shadow.getElementById('qb-import-file').addEventListener('change', async event => {
|
||||||
const input = event.currentTarget;
|
const input = event.currentTarget;
|
||||||
@@ -1418,7 +1741,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
catch (error) { tell(`导入失败,题库未替换:${error.message}`, true); }
|
catch (error) { tell(`导入失败,题库未替换:${error.message}`, true); }
|
||||||
finally { input.value = ''; importing = false; button.disabled = false; }
|
finally { input.value = ''; importing = false; button.disabled = false; }
|
||||||
});
|
});
|
||||||
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderStatus(); selectTab(config.tab); renderMode();
|
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode();
|
||||||
setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000);
|
setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1440,11 +1763,13 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
|
|||||||
event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden');
|
event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden');
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
window.addEventListener('pagehide', persistRecords);
|
window.addEventListener('pagehide', () => { autoAnswer.setEnabled(false); persistRecords(); });
|
||||||
|
window.addEventListener('pageshow', event => {
|
||||||
|
if (event.persisted) { autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); }
|
||||||
|
});
|
||||||
if (typeof GM_registerMenuCommand === 'function') {
|
if (typeof GM_registerMenuCommand === 'function') {
|
||||||
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
|
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
|
||||||
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
|
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
|
||||||
GM_registerMenuCommand('完全隐藏题库助手(快捷键或菜单恢复)', () => { config.keepLauncher = false; if (shadow) shadow.getElementById('qb-keep-launcher').checked = false; setMode('hidden'); });
|
|
||||||
}
|
}
|
||||||
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
|
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
|
||||||
if (!remote || !Array.isArray(value)) return;
|
if (!remote || !Array.isArray(value)) return;
|
||||||
|
|||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "huayi-question-bank",
|
"name": "huayi-question-bank",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "华谊练习题库本地采集、CSV 导出与划词检索油猴脚本",
|
"description": "华谊练习题库本地采集、CSV 导出与划词检索油猴脚本",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.cjs",
|
"build": "node build.cjs",
|
||||||
"test": "node --test tests/core.test.cjs tests/adapter.test.cjs",
|
"test": "node --test tests/core.test.cjs tests/adapter.test.cjs tests/autofill.test.cjs",
|
||||||
"test:browser": "node --test tests/integration.test.cjs",
|
"test:browser": "node --test tests/integration.test.cjs",
|
||||||
"check": "node build.cjs && node --check dist/huayi-question-bank.user.js"
|
"check": "node build.cjs && node --check dist/huayi-question-bank.user.js"
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-8
@@ -144,7 +144,7 @@ const QBAdapter = (() => {
|
|||||||
} catch (_) { return ''; }
|
} 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 active = false;
|
||||||
let copyEnabled = false;
|
let copyEnabled = false;
|
||||||
let observer = null;
|
let observer = null;
|
||||||
@@ -171,6 +171,14 @@ const QBAdapter = (() => {
|
|||||||
const status = message => { try { onStatus(message); } catch (_) { /* UI callbacks must not affect the page. */ } };
|
const status = message => { try { onStatus(message); } catch (_) { /* UI callbacks must not affect the page. */ } };
|
||||||
const locationURL = () => String(win.location && win.location.href || '');
|
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() {
|
function formSnapshot() {
|
||||||
const element = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].find(visible);
|
const element = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].find(visible);
|
||||||
return element ? { element, text: flat(element.textContent) } : null;
|
return element ? { element, text: flat(element.textContent) } : null;
|
||||||
@@ -206,6 +214,18 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncBank() {
|
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 state = bankDOM();
|
||||||
const identity = JSON.stringify([state.taskId, flat(state.bankName)]);
|
const identity = JSON.stringify([state.taskId, flat(state.bankName)]);
|
||||||
const oldIdentity = currentBank && JSON.stringify([currentBank.taskId, flat(currentBank.bankName)]);
|
const oldIdentity = currentBank && JSON.stringify([currentBank.taskId, flat(currentBank.bankName)]);
|
||||||
@@ -241,7 +261,7 @@ const QBAdapter = (() => {
|
|||||||
function getCurrentBank() { return syncBank(); }
|
function getCurrentBank() { return syncBank(); }
|
||||||
|
|
||||||
function ensureBankListeners() {
|
function ensureBankListeners() {
|
||||||
if (bankListeners) return;
|
if (bankListeners || !isSupportedPage()) return;
|
||||||
bankListeners = true;
|
bankListeners = true;
|
||||||
win.addEventListener?.('hashchange', syncBank);
|
win.addEventListener?.('hashchange', syncBank);
|
||||||
win.addEventListener?.('popstate', syncBank);
|
win.addEventListener?.('popstate', syncBank);
|
||||||
@@ -291,6 +311,7 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentQuestion() {
|
function getCurrentQuestion() {
|
||||||
|
if (!isSupportedPage()) return null;
|
||||||
const bank = syncBank();
|
const bank = syncBank();
|
||||||
if (bankDOM().mismatch) return null;
|
if (bankDOM().mismatch) return null;
|
||||||
const forms = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].filter(visible);
|
const forms = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].filter(visible);
|
||||||
@@ -352,6 +373,10 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function captureCurrent() {
|
function captureCurrent() {
|
||||||
|
if (!isSupportedPage()) {
|
||||||
|
status('当前为通用网页,可使用本地题库搜索;本站采集未开启。');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const current = getCurrentQuestion();
|
const current = getCurrentQuestion();
|
||||||
if (current) emit([current]);
|
if (current) emit([current]);
|
||||||
else status('当前页面未发现可采集的练习题,请进入题目页面。');
|
else status('当前页面未发现可采集的练习题,请进入题目页面。');
|
||||||
@@ -374,6 +399,7 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function installNetwork() {
|
function installNetwork() {
|
||||||
|
if (!observeNetwork || !isSupportedPage()) return;
|
||||||
const generation = ++networkGeneration;
|
const generation = ++networkGeneration;
|
||||||
if (typeof win.fetch === 'function') {
|
if (typeof win.fetch === 'function') {
|
||||||
const originalFetch = win.fetch;
|
const originalFetch = win.fetch;
|
||||||
@@ -460,7 +486,7 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureCopyStyle() {
|
function ensureCopyStyle() {
|
||||||
if (!copyEnabled || copyStyle?.isConnected) return;
|
if (!copyEnabled || copyStyle?.isConnected || !isSupportedPage()) return;
|
||||||
const parent = doc.head || doc.documentElement;
|
const parent = doc.head || doc.documentElement;
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
copyStyle = doc.createElement('style');
|
copyStyle = doc.createElement('style');
|
||||||
@@ -470,7 +496,7 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureObserver() {
|
function ensureObserver() {
|
||||||
if (observer || !win.MutationObserver) return;
|
if (observer || !win.MutationObserver || !isSupportedPage()) return;
|
||||||
observer = new win.MutationObserver(mutations => {
|
observer = new win.MutationObserver(mutations => {
|
||||||
const relevant = mutations.some(m => {
|
const relevant = mutations.some(m => {
|
||||||
const node = m.target.nodeType === 1 ? m.target : m.target.parentElement;
|
const node = m.target.nodeType === 1 ? m.target : m.target.parentElement;
|
||||||
@@ -492,10 +518,12 @@ const QBAdapter = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setCopyEnabled(value) {
|
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']) {
|
for (const type of ['copy', 'cut', 'contextmenu', 'selectstart']) {
|
||||||
win.removeEventListener(type, copyEvent, true);
|
win.removeEventListener?.(type, copyEvent, true);
|
||||||
if (copyEnabled) win.addEventListener(type, copyEvent, true);
|
if (copyEnabled) win.addEventListener?.(type, copyEvent, true);
|
||||||
}
|
}
|
||||||
if (copyEnabled) { ensureCopyStyle(); ensureObserver(); }
|
if (copyEnabled) { ensureCopyStyle(); ensureObserver(); }
|
||||||
else if (copyStyle) { copyStyle.remove(); copyStyle = null; }
|
else if (copyStyle) { copyStyle.remove(); copyStyle = null; }
|
||||||
@@ -507,6 +535,11 @@ const QBAdapter = (() => {
|
|||||||
function start() {
|
function start() {
|
||||||
if (active) return;
|
if (active) return;
|
||||||
active = true;
|
active = true;
|
||||||
|
if (!isSupportedPage()) {
|
||||||
|
syncBank();
|
||||||
|
status('当前为通用网页,可使用本地题库搜索;本站采集未开启。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
ensureBankListeners();
|
ensureBankListeners();
|
||||||
ensureObserver();
|
ensureObserver();
|
||||||
installNetwork();
|
installNetwork();
|
||||||
@@ -521,7 +554,7 @@ const QBAdapter = (() => {
|
|||||||
status('采集已暂停,已保存题库和选择复制功能仍可使用。');
|
status('采集已暂停,已保存题库和选择复制功能仍可使用。');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled };
|
return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled, isSupportedPage };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { create, extractQuestions };
|
return { create, extractQuestions };
|
||||||
|
|||||||
+62
-17
@@ -2,7 +2,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const CONFIG_KEY = 'huayi-qb.config.v1';
|
const CONFIG_KEY = 'huayi-qb.config.v1';
|
||||||
const RECORDS_KEY = 'huayi-qb.records.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 pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
|
||||||
const storage = {
|
const storage = {
|
||||||
get(key, fallback) {
|
get(key, fallback) {
|
||||||
@@ -19,9 +19,13 @@
|
|||||||
try { rawConfig = storage.get(CONFIG_KEY, {}); } catch (_) { rawConfig = {}; }
|
try { rawConfig = storage.get(CONFIG_KEY, {}); } catch (_) { rawConfig = {}; }
|
||||||
const config = { ...DEFAULTS, ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}) };
|
const config = { ...DEFAULTS, ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}) };
|
||||||
if (!['open', 'collapsed', 'hidden'].includes(config.mode)) config.mode = 'open';
|
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]; });
|
['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 records = [];
|
||||||
|
let recordsDirty = false;
|
||||||
let loadError = '';
|
let loadError = '';
|
||||||
try {
|
try {
|
||||||
const saved = storage.get(RECORDS_KEY, []);
|
const saved = storage.get(RECORDS_KEY, []);
|
||||||
@@ -29,15 +33,37 @@
|
|||||||
records = QBCore.upsert([], saved);
|
records = QBCore.upsert([], saved);
|
||||||
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
|
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
|
||||||
let root, shadow, panel, launcher, resultBox, statusBox;
|
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 activeBank = null, importing = false;
|
||||||
let status = loadError || '准备就绪,可输入文字搜索,也可选中题干检索。';
|
let status = loadError || '准备就绪,可输入文字搜索,也可选中题干检索。';
|
||||||
let storageError = false;
|
let storageError = false;
|
||||||
let mounted = 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) {
|
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 previous = activeBank;
|
||||||
const sameName = previous && bankNameKey(previous.bankName) === bankNameKey(bank.bankName);
|
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));
|
const identityChanged = previous && (!sameName || (previous.taskId && bank.taskId && previous.taskId !== bank.taskId) || (previous.bankId && bank.bankId && previous.bankId !== bank.bankId));
|
||||||
@@ -57,13 +83,14 @@
|
|||||||
}
|
}
|
||||||
function persistRecords() {
|
function persistRecords() {
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
if (loadError) return;
|
if (loadError || !recordsDirty) return;
|
||||||
try {
|
try {
|
||||||
// Merge other tabs before writing; GM change events also refresh an open panel.
|
// Merge other tabs before writing; GM change events also refresh an open panel.
|
||||||
const stored = storage.get(RECORDS_KEY, []);
|
const stored = storage.get(RECORDS_KEY, []);
|
||||||
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖');
|
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖');
|
||||||
records = QBCore.upsert(stored, records);
|
records = QBCore.upsert(stored, records);
|
||||||
storage.set(RECORDS_KEY, records);
|
storage.set(RECORDS_KEY, records);
|
||||||
|
recordsDirty = false;
|
||||||
storageError = false;
|
storageError = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
storageError = true;
|
storageError = true;
|
||||||
@@ -86,6 +113,7 @@
|
|||||||
return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record;
|
return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record;
|
||||||
});
|
});
|
||||||
records = QBCore.upsert(records, incoming);
|
records = QBCore.upsert(records, incoming);
|
||||||
|
recordsDirty = true;
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
saveTimer = setTimeout(persistRecords, 250);
|
saveTimer = setTimeout(persistRecords, 250);
|
||||||
if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); }
|
if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); }
|
||||||
@@ -115,7 +143,7 @@
|
|||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.hidden = config.mode === 'hidden';
|
panel.hidden = config.mode === 'hidden';
|
||||||
panel.classList.toggle('collapsed', config.mode === 'collapsed');
|
panel.classList.toggle('collapsed', config.mode === 'collapsed');
|
||||||
launcher.hidden = config.mode !== 'hidden' || !config.keepLauncher;
|
launcher.hidden = true;
|
||||||
const collapse = shadow.querySelector('[data-action="collapse"]');
|
const collapse = shadow.querySelector('[data-action="collapse"]');
|
||||||
collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起';
|
collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起';
|
||||||
collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed'));
|
collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed'));
|
||||||
@@ -177,7 +205,7 @@
|
|||||||
}
|
}
|
||||||
function scopeName(selectedScope = scope) {
|
function scopeName(selectedScope = scope) {
|
||||||
if (selectedScope === '') return '全部题库';
|
if (selectedScope === '') return '全部题库';
|
||||||
if (selectedScope === '__current__') return currentBank()?.bankName || '未识别当前题库';
|
if (selectedScope === '__current__') return currentBank()?.bankName || '当前网页未识别题库';
|
||||||
const anchor = records.find(record => bankKey(record) === selectedScope);
|
const anchor = records.find(record => bankKey(record) === selectedScope);
|
||||||
return anchor?.bankName || '未命名题库';
|
return anchor?.bankName || '未命名题库';
|
||||||
}
|
}
|
||||||
@@ -232,14 +260,23 @@
|
|||||||
if (!shadow) return;
|
if (!shadow) return;
|
||||||
const bank = currentBank();
|
const bank = currentBank();
|
||||||
const current = currentQuestion();
|
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').textContent = name;
|
||||||
shadow.getElementById('qb-context-name').title = name;
|
shadow.getElementById('qb-context-name').title = name;
|
||||||
const pool = scopedRecords('__current__');
|
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-title').textContent = bank?.loading ? '题库切换中,等待当前题目加载。' : current?.stem || '打开练习题后,这里显示当前题目。';
|
||||||
shadow.getElementById('qb-current-bank').textContent = name;
|
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() {
|
function renderResults() {
|
||||||
if (!resultBox) return;
|
if (!resultBox) return;
|
||||||
resultBox.replaceChildren();
|
resultBox.replaceChildren();
|
||||||
@@ -358,6 +395,7 @@
|
|||||||
const before = records.length;
|
const before = records.length;
|
||||||
const merged = QBCore.upsert(records, incoming);
|
const merged = QBCore.upsert(records, incoming);
|
||||||
records = merged;
|
records = merged;
|
||||||
|
recordsDirty = true;
|
||||||
persistRecords();
|
persistRecords();
|
||||||
refreshScope(); renderStats(); renderCurrent(); renderResults();
|
refreshScope(); renderStats(); renderCurrent(); renderResults();
|
||||||
const bankCount = new Set(incoming.map(bankKey)).size;
|
const bankCount = new Set(incoming.map(bankKey)).size;
|
||||||
@@ -379,6 +417,7 @@
|
|||||||
|
|
||||||
function mount() {
|
function mount() {
|
||||||
if (mounted || document.getElementById('huayi-qb-root')) return;
|
if (mounted || document.getElementById('huayi-qb-root')) return;
|
||||||
|
activateKnownPage();
|
||||||
root = node('div'); root.id = 'huayi-qb-root';
|
root = node('div'); root.id = 'huayi-qb-root';
|
||||||
shadow = root.attachShadow({ mode: 'open' });
|
shadow = root.attachShadow({ mode: 'open' });
|
||||||
const style = node('style'); style.textContent = css;
|
const style = node('style'); style.textContent = css;
|
||||||
@@ -389,15 +428,19 @@
|
|||||||
<header class="header"><div><div class="brand">题库助手</div><div class="subtitle">HUAYI · 本地练习资料</div></div><div class="tools"><button type="button" data-action="collapse" title="收起或展开内容" aria-expanded="true">收起</button><button type="button" data-action="hide" title="隐藏面板,Alt + Shift + Q 恢复">隐藏</button></div></header>
|
<header class="header"><div><div class="brand">题库助手</div><div class="subtitle">HUAYI · 本地练习资料</div></div><div class="tools"><button type="button" data-action="collapse" title="收起或展开内容" aria-expanded="true">收起</button><button type="button" data-action="hide" title="隐藏面板,Alt + Shift + Q 恢复">隐藏</button></div></header>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="context"><span class="context-label">当前题库</span><strong id="qb-context-name">正在识别题库…</strong><div class="context-count" id="qb-context-count"></div></div>
|
<div class="context"><span class="context-label">当前题库</span><strong id="qb-context-name">正在识别题库…</strong><div class="context-count" id="qb-context-count"></div></div>
|
||||||
<nav class="tabs" role="tablist" aria-label="助手功能"><button type="button" data-tab="search" role="tab" aria-controls="qb-view-search">文字搜索</button><button type="button" data-tab="library" role="tab" aria-controls="qb-view-library">题库 / 导出</button><button type="button" data-tab="settings" role="tab" aria-controls="qb-view-settings">设置</button></nav>
|
<nav class="tabs" role="tablist" aria-label="助手功能"><button type="button" data-tab="search" role="tab" aria-controls="qb-view-search">文字搜索</button><button type="button" data-tab="library" role="tab" aria-controls="qb-view-library">题库 / 导出</button><button type="button" data-tab="auto" role="tab" aria-controls="qb-view-auto">自动答题</button><button type="button" data-tab="settings" role="tab" aria-controls="qb-view-settings">设置</button></nav>
|
||||||
<div class="view" id="qb-view-search" data-view="search" role="tabpanel"><div class="search-label"><label for="qb-search">输入或粘贴文字查答案</label><button type="button" class="text-button" data-action="clear-search">清空</button></div><div class="search-wrap"><input id="qb-search" type="search" maxlength="4000" placeholder="题干、选项或答案关键词…" aria-label="文字搜索"><button type="button" class="btn primary" data-action="search">搜索</button></div><div class="search-filters"><label>搜索题库<select id="qb-search-scope" aria-label="搜索题库"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><label>搜索内容<select id="qb-search-field" aria-label="搜索内容"><option value="all">全部文字</option><option value="stem">仅题干</option><option value="options">仅选项</option><option value="answer">仅标准答案</option></select></label></div><p class="hint" id="qb-query-state"></p><div id="qb-results" aria-live="polite"></div></div>
|
<div class="view" id="qb-view-search" data-view="search" role="tabpanel"><div class="search-label"><label for="qb-search">输入或粘贴文字查答案</label><button type="button" class="text-button" data-action="clear-search">清空</button></div><div class="search-wrap"><input id="qb-search" type="search" maxlength="4000" placeholder="题干、选项或答案关键词…" aria-label="文字搜索"><button type="button" class="btn primary" data-action="search">搜索</button></div><div class="search-filters"><label>搜索题库<select id="qb-search-scope" aria-label="搜索题库"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><label>搜索内容<select id="qb-search-field" aria-label="搜索内容"><option value="all">全部文字</option><option value="stem">仅题干</option><option value="options">仅选项</option><option value="answer">仅标准答案</option></select></label></div><p class="hint" id="qb-query-state"></p><div id="qb-results" aria-live="polite"></div></div>
|
||||||
<div class="view" id="qb-view-library" data-view="library" role="tabpanel" hidden>
|
<div class="view" id="qb-view-library" data-view="library" role="tabpanel" hidden>
|
||||||
<div class="section"><h3>统计与导出范围</h3><label class="scope">选择题库<select id="qb-scope" aria-label="题库范围"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><p class="export-name" id="qb-export-name"></p><div class="stats"><div class="stat"><strong id="qb-stat-total">0</strong><span>范围内题数</span></div><div class="stat"><strong id="qb-stat-confirmed">0</strong><span>答案确认</span></div><div class="stat"><strong id="qb-stat-missing">0</strong><span>待补答案</span></div><div class="stat"><strong id="qb-stat-conflicts">0</strong><span>答案冲突</span></div></div><div class="row"><button type="button" class="btn primary" data-action="export-csv">导出 CSV</button><button type="button" class="btn" data-action="export-json">备份 JSON</button></div><p class="filename" id="qb-export-preview"></p></div>
|
<div class="section"><h3>统计与导出范围</h3><label class="scope">选择题库<select id="qb-scope" aria-label="题库范围"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><p class="export-name" id="qb-export-name"></p><div class="stats"><div class="stat"><strong id="qb-stat-total">0</strong><span>范围内题数</span></div><div class="stat"><strong id="qb-stat-confirmed">0</strong><span>答案确认</span></div><div class="stat"><strong id="qb-stat-missing">0</strong><span>待补答案</span></div><div class="stat"><strong id="qb-stat-conflicts">0</strong><span>答案冲突</span></div></div><div class="row"><button type="button" class="btn primary" data-action="export-csv">导出 CSV</button><button type="button" class="btn" data-action="export-json">备份 JSON</button></div><p class="filename" id="qb-export-preview"></p></div>
|
||||||
<div class="section"><div class="row spread"><h3>当前练习</h3><span class="state" id="qb-capture-state">采集中</span></div><p id="qb-current-bank"></p><div id="qb-current-title"></div><div class="row"><button type="button" class="btn small primary" data-action="capture-current">采集当前题</button><button type="button" class="btn small" data-action="toggle-capture">暂停采集</button></div><p>记录正常加载的练习题与标准答案。答案尚未返回的题目会标为待补,可在后续练习中继续补齐。</p></div>
|
<div class="section"><div class="row spread"><h3>当前练习</h3><span class="state" id="qb-capture-state">采集中</span></div><p id="qb-current-bank"></p><div id="qb-current-title"></div><div class="row"><button type="button" class="btn small primary" data-action="capture-current">采集当前题</button><button type="button" class="btn small" data-action="toggle-capture">暂停采集</button></div><p>记录正常加载的练习题与标准答案。答案尚未返回的题目会标为待补,可在后续练习中继续补齐。</p></div>
|
||||||
<div class="section"><div class="row spread"><h3>已保存题库</h3><button type="button" class="btn small" data-action="import">导入多个题库</button></div><input id="qb-import-file" type="file" accept=".csv,.json,text/csv,application/json" multiple hidden><p>可一次选择多个 CSV / JSON,分别保留题库名称。无名称的文件使用文件名作为题库名。</p><p id="qb-library-summary"></p><div class="bank-list" id="qb-bank-list"></div></div>
|
<div class="section"><div class="row spread"><h3>已保存题库</h3><button type="button" class="btn small" data-action="import">导入多个题库</button></div><input id="qb-import-file" type="file" accept=".csv,.json,text/csv,application/json" multiple hidden><p>可一次选择多个 CSV / JSON,分别保留题库名称。无名称的文件使用文件名作为题库名。</p><p id="qb-library-summary"></p><div class="bank-list" id="qb-bank-list"></div></div>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
<div class="view" id="qb-view-settings" data-view="settings" role="tabpanel" hidden>
|
<div class="view" id="qb-view-settings" data-view="settings" role="tabpanel" hidden>
|
||||||
<div class="section"><h3>面板显示</h3><p>右上角可收起或隐藏,刷新后保持原状态。</p><label class="check"><input id="qb-keep-launcher" type="checkbox"><span>隐藏时保留悬浮按钮<small>取消后可完全隐藏,用快捷键或油猴菜单恢复。</small></span></label><p>显示 / 隐藏快捷键:<kbd class="kbd">Alt + Shift + Q</kbd></p><p>面板隐藏时,划选文字仍可更新检索内容,面板保持隐藏。</p><button type="button" class="btn small" data-action="hide">立即隐藏</button></div>
|
<div class="section"><h3>面板显示</h3><p>每次打开或刷新网页都默认完全隐藏,不显示悬浮按钮。</p><p>显示 / 隐藏快捷键:<kbd class="kbd">Shift + Alt + Q</kbd></p><p>面板隐藏时,划选文字仍可更新检索内容,面板保持隐藏。</p><button type="button" class="btn small" data-action="hide">立即隐藏</button></div>
|
||||||
<div class="section"><h3>选择与复制</h3><label class="check"><input id="qb-copy-enabled" type="checkbox"><span>恢复题目区域的选择复制<small>换题后自动适配,关闭后恢复原有处理。</small></span></label></div>
|
<div class="section"><h3>选择与复制</h3><label class="check"><input id="qb-copy-enabled" type="checkbox"><span>恢复题目区域的选择复制<small>换题后自动适配,关闭后恢复原有处理。</small></span></label></div>
|
||||||
<div class="section"><h3>答案规则</h3><p>按标准答案内容保存,再对应当前页面的选项位置。没有标准答案就显示待补;不同答案会保留冲突。所有资料保存在当前浏览器,建议定期导出备份。</p></div>
|
<div class="section"><h3>答案规则</h3><p>按标准答案内容保存,再对应当前页面的选项位置。没有标准答案就显示待补;不同答案会保留冲突。所有资料保存在当前浏览器,建议定期导出备份。</p></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,7 +452,6 @@
|
|||||||
panel = shadow.getElementById('qb-panel'); launcher = shadow.getElementById('qb-launcher');
|
panel = shadow.getElementById('qb-panel'); launcher = shadow.getElementById('qb-launcher');
|
||||||
resultBox = shadow.getElementById('qb-results'); statusBox = shadow.getElementById('qb-status');
|
resultBox = shadow.getElementById('qb-results'); statusBox = shadow.getElementById('qb-status');
|
||||||
mounted = true;
|
mounted = true;
|
||||||
shadow.getElementById('qb-keep-launcher').checked = Boolean(config.keepLauncher);
|
|
||||||
shadow.getElementById('qb-copy-enabled').checked = Boolean(config.copyEnabled);
|
shadow.getElementById('qb-copy-enabled').checked = Boolean(config.copyEnabled);
|
||||||
shadow.addEventListener('click', event => {
|
shadow.addEventListener('click', event => {
|
||||||
const button = event.target.closest('button');
|
const button = event.target.closest('button');
|
||||||
@@ -422,6 +464,7 @@
|
|||||||
case 'collapse': setMode(config.mode === 'collapsed' ? 'open' : 'collapsed'); break;
|
case 'collapse': setMode(config.mode === 'collapsed' ? 'open' : 'collapsed'); break;
|
||||||
case 'search': runSearch(shadow.getElementById('qb-search').value); break;
|
case 'search': runSearch(shadow.getElementById('qb-search').value); break;
|
||||||
case 'clear-search': clearTimeout(searchTimer); runSearch(''); shadow.getElementById('qb-search').focus(); 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 'capture-current': {
|
case 'capture-current': {
|
||||||
const captured = adapter.captureCurrent();
|
const captured = adapter.captureCurrent();
|
||||||
renderCurrent();
|
renderCurrent();
|
||||||
@@ -451,7 +494,7 @@
|
|||||||
shadow.getElementById('qb-scope').addEventListener('change', event => { scope = event.target.value; renderStats(); });
|
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-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-search-field').addEventListener('change', event => { searchField = event.target.value; renderResults(); });
|
||||||
shadow.getElementById('qb-keep-launcher').addEventListener('change', event => { config.keepLauncher = event.target.checked; persistConfig(); renderMode(); });
|
shadow.getElementById('qb-auto-enabled').addEventListener('change', event => { autoAnswer.setEnabled(event.target.checked); renderAuto(); });
|
||||||
shadow.getElementById('qb-copy-enabled').addEventListener('change', event => { config.copyEnabled = event.target.checked; adapter.setCopyEnabled(config.copyEnabled); persistConfig(); });
|
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 => {
|
shadow.getElementById('qb-import-file').addEventListener('change', async event => {
|
||||||
const input = event.currentTarget;
|
const input = event.currentTarget;
|
||||||
@@ -464,7 +507,7 @@
|
|||||||
catch (error) { tell(`导入失败,题库未替换:${error.message}`, true); }
|
catch (error) { tell(`导入失败,题库未替换:${error.message}`, true); }
|
||||||
finally { input.value = ''; importing = false; button.disabled = false; }
|
finally { input.value = ''; importing = false; button.disabled = false; }
|
||||||
});
|
});
|
||||||
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderStatus(); selectTab(config.tab); renderMode();
|
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode();
|
||||||
setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000);
|
setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,11 +529,13 @@
|
|||||||
event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden');
|
event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden');
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
window.addEventListener('pagehide', persistRecords);
|
window.addEventListener('pagehide', () => { autoAnswer.setEnabled(false); persistRecords(); });
|
||||||
|
window.addEventListener('pageshow', event => {
|
||||||
|
if (event.persisted) { autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); }
|
||||||
|
});
|
||||||
if (typeof GM_registerMenuCommand === 'function') {
|
if (typeof GM_registerMenuCommand === 'function') {
|
||||||
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
|
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
|
||||||
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
|
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
|
||||||
GM_registerMenuCommand('完全隐藏题库助手(快捷键或菜单恢复)', () => { config.keepLauncher = false; if (shadow) shadow.getElementById('qb-keep-launcher').checked = false; setMode('hidden'); });
|
|
||||||
}
|
}
|
||||||
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
|
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
|
||||||
if (!remote || !Array.isArray(value)) return;
|
if (!remote || !Array.isArray(value)) return;
|
||||||
|
|||||||
+245
@@ -0,0 +1,245 @@
|
|||||||
|
/* 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;
|
||||||
@@ -318,3 +318,93 @@ test('initial HTML parsing and atomic full SPA render do not block a new form',
|
|||||||
assert.equal(question.stem, '同步渲染的新题目?');
|
assert.equal(question.stem, '同步渲染的新题目?');
|
||||||
page.adapter.stop();
|
page.adapter.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ordinary pages do not observe requests, capture questions or intercept copy events', async () => {
|
||||||
|
const page = fakePage(async () => new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }));
|
||||||
|
page.win.location.href = 'https://ordinary.example/account';
|
||||||
|
page.doc.title = '普通网页标题不应成为题库';
|
||||||
|
const originalFetch = page.win.fetch;
|
||||||
|
const originalOpen = page.win.XMLHttpRequest.prototype.open;
|
||||||
|
const originalSend = page.win.XMLHttpRequest.prototype.send;
|
||||||
|
const listeners = [];
|
||||||
|
const observers = [];
|
||||||
|
page.win.addEventListener = type => listeners.push(type);
|
||||||
|
page.win.removeEventListener = () => {};
|
||||||
|
page.win.MutationObserver = class {
|
||||||
|
constructor(callback) { observers.push(callback); }
|
||||||
|
observe() {}
|
||||||
|
};
|
||||||
|
page.doc.createElement = () => assert.fail('普通网页不能注入复制恢复样式');
|
||||||
|
const changes = [];
|
||||||
|
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false,
|
||||||
|
onQuestions: values => page.received.push(...values), onBankChange: bank => changes.push(bank) });
|
||||||
|
adapter.start();
|
||||||
|
adapter.setCopyEnabled(true);
|
||||||
|
assert.equal(adapter.isSupportedPage(), false);
|
||||||
|
assert.equal(adapter.getCurrentQuestion(), null);
|
||||||
|
assert.equal(adapter.captureCurrent(), null);
|
||||||
|
assert.deepEqual(adapter.getCurrentBank(), { bankId: '', bankName: '', taskId: '', loading: false });
|
||||||
|
await page.win.fetch('/upm/start?praId=not-a-bank');
|
||||||
|
const xhr = new page.win.XMLHttpRequest();
|
||||||
|
xhr.open('GET', '/upm/start?praId=not-a-bank'); xhr.send(); xhr.respond({ listQues: [single()] });
|
||||||
|
await flush();
|
||||||
|
adapter.stop();
|
||||||
|
assert.equal(page.win.fetch, originalFetch);
|
||||||
|
assert.equal(page.win.XMLHttpRequest.prototype.open, originalOpen);
|
||||||
|
assert.equal(page.win.XMLHttpRequest.prototype.send, originalSend);
|
||||||
|
assert.deepEqual(page.received, [], '普通页响应即使碰巧形似练习数据也不能写入题库');
|
||||||
|
assert.deepEqual(listeners, [], '普通页无需安装copy、selection或导航监听');
|
||||||
|
assert.deepEqual(observers, [], '普通页不需要观察整个DOM');
|
||||||
|
assert.ok(changes.every(bank => bank.bankName === '' && bank.bankId === ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('network observation can be disabled independently on a supported exercise page', async () => {
|
||||||
|
const page = bankPage();
|
||||||
|
const originalFetch = page.win.fetch;
|
||||||
|
const originalOpen = page.win.XMLHttpRequest.prototype.open;
|
||||||
|
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false,
|
||||||
|
onQuestions: values => page.received.push(...values) });
|
||||||
|
adapter.start();
|
||||||
|
assert.equal(adapter.isSupportedPage(), true);
|
||||||
|
assert.equal(page.received.length, 1, '已知站点的页面采集不依赖网络钩子');
|
||||||
|
assert.equal(page.received[0].stem, '升温时应选择?');
|
||||||
|
assert.equal(page.received[0].bankName, '判断题库1');
|
||||||
|
assert.equal(page.received[0].answerStatus, 'missing');
|
||||||
|
assert.equal(page.win.fetch, originalFetch);
|
||||||
|
assert.equal(page.win.XMLHttpRequest.prototype.open, originalOpen);
|
||||||
|
adapter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a known root appearing later is detectable without automatically enabling network observation', () => {
|
||||||
|
const page = fakePage();
|
||||||
|
page.win.location.href = 'https://ordinary.example/demo';
|
||||||
|
page.doc.title = '显式题目结构演示';
|
||||||
|
let exerciseRoot = null;
|
||||||
|
page.doc.querySelector = selector => selector === ROOT_SELECTOR_FOR_TEST ? exerciseRoot : null;
|
||||||
|
const originalFetch = page.win.fetch;
|
||||||
|
const originalOpen = page.win.XMLHttpRequest.prototype.open;
|
||||||
|
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false,
|
||||||
|
onQuestions: values => page.received.push(...values) });
|
||||||
|
adapter.start();
|
||||||
|
assert.equal(adapter.isSupportedPage(), false);
|
||||||
|
assert.equal(adapter.getCurrentBank().bankName, '');
|
||||||
|
exerciseRoot = {};
|
||||||
|
assert.equal(adapter.isSupportedPage(), true);
|
||||||
|
assert.equal(adapter.getCurrentBank().bankName, '显式题目结构演示');
|
||||||
|
assert.equal(page.win.fetch, originalFetch);
|
||||||
|
assert.equal(page.win.XMLHttpRequest.prototype.open, originalOpen);
|
||||||
|
assert.deepEqual(page.received, []);
|
||||||
|
adapter.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lookalike hostname is not treated as the supported learning platform', () => {
|
||||||
|
const page = fakePage();
|
||||||
|
page.win.location.href = 'https://www.shhuayi-elearning.cn.ordinary.example/';
|
||||||
|
page.doc.title = '页面标题不是题库';
|
||||||
|
const adapter = create({ window: page.win, document: page.doc, observeNetwork: false });
|
||||||
|
adapter.start();
|
||||||
|
assert.equal(adapter.isSupportedPage(), false);
|
||||||
|
assert.equal(adapter.getCurrentBank().bankName, '');
|
||||||
|
assert.equal(adapter.getCurrentQuestion(), null);
|
||||||
|
adapter.stop();
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
'use strict';
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const core = require('../src/core.js');
|
||||||
|
const { planAnswer, create } = require('../src/autofill.js');
|
||||||
|
|
||||||
|
function single(extra = {}) {
|
||||||
|
return { bankId: 'bank-a', bankName: '题库一', questionId: 'q1', type: 'single', stem: '以下哪项操作可以提高温度?',
|
||||||
|
options: [{ id: 'a', text: '降低温度', label: 'A' }, { id: 'b', text: '提高温度', label: 'B' }, { id: 'c', text: '保持不变', label: 'C' }],
|
||||||
|
correctOptionIds: ['b'], answerTexts: ['提高温度'], answerStatus: 'confirmed', ...extra };
|
||||||
|
}
|
||||||
|
function current(extra = {}) {
|
||||||
|
return single({ questionId: '', options: [{ id: 'c', text: '保持不变', label: 'A' }, { id: 'a', text: '降低温度', label: 'B' }, { id: 'b', text: '提高温度', label: 'C' }], answerTexts: [], correctOptionIds: [], answerStatus: 'missing', ...extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('single answer plan maps content to the current shuffled choice', () => {
|
||||||
|
const plan = planAnswer([single()], current(), core);
|
||||||
|
assert.equal(plan.status, 'ready'); assert.deepEqual(plan.labels, ['C']); assert.deepEqual(plan.answerTexts, ['提高温度']);
|
||||||
|
});
|
||||||
|
test('multiple answer plan preserves the complete shuffled correct set', () => {
|
||||||
|
const record = single({ type: 'multiple', answerTexts: ['降低温度', '提高温度'], correctOptionIds: ['a', 'b'] });
|
||||||
|
const plan = planAnswer([record], current({ type: 'multiple' }), core);
|
||||||
|
assert.equal(plan.status, 'ready'); assert.deepEqual(new Set(plan.labels), new Set(['B', 'C']));
|
||||||
|
});
|
||||||
|
test('judgement uses explicit correct and incorrect content, including zero ID', () => {
|
||||||
|
const record = { bankId: 'j', type: 'judge', stem: '这项说法正确吗?', options: [{ id: '1', text: '正确' }, { id: '0', text: '错误' }], correctOptionIds: ['0'], answerStatus: 'confirmed' };
|
||||||
|
const question = { ...record, options: [{ id: '0', text: '错误', label: '错误' }, { id: '1', text: '正确', label: '正确' }] };
|
||||||
|
assert.deepEqual(planAnswer([record], question, core).answerTexts, ['错误']);
|
||||||
|
assert.deepEqual(planAnswer([record], question, core).labels, ['错误']);
|
||||||
|
});
|
||||||
|
test('missing and fuzzy-only matches never cause an answer plan', () => {
|
||||||
|
assert.equal(planAnswer([single({ answerStatus: 'missing' })], current(), core).status, 'missing');
|
||||||
|
assert.equal(planAnswer([single()], current({ stem: '以下哪些操作可以提高温度?' }), core).status, 'missing');
|
||||||
|
});
|
||||||
|
test('any conflict or different confirmed answers makes the choice ambiguous', () => {
|
||||||
|
assert.equal(planAnswer([single(), single({ answerStatus: 'conflict' })], current(), core).status, 'ambiguous');
|
||||||
|
assert.equal(planAnswer([single(), single({ questionId: 'q2', correctOptionIds: ['a'], answerTexts: ['降低温度'] })], current(), core).status, 'ambiguous');
|
||||||
|
});
|
||||||
|
test('duplicate records with the same confirmed answer remain safe', () => {
|
||||||
|
assert.equal(planAnswer([single(), single({ questionId: 'q2' })], current(), core).status, 'ready');
|
||||||
|
});
|
||||||
|
test('a known bank ID rejects answers from other banks', () => {
|
||||||
|
assert.equal(planAnswer([single({ bankId: 'bank-b' })], current(), core).status, 'missing');
|
||||||
|
assert.equal(planAnswer([single(), single({ bankId: 'bank-b', correctOptionIds: ['a'], answerTexts: ['降低温度'] })], current(), core).status, 'ready');
|
||||||
|
});
|
||||||
|
test('without bank identity, contradictory cross-bank matches are ambiguous', () => {
|
||||||
|
const plan = planAnswer([single(), single({ bankId: 'bank-b', bankName: '题库二', correctOptionIds: ['a'], answerTexts: ['降低温度'] })], current({ bankId: '', bankName: '' }), core);
|
||||||
|
assert.equal(plan.status, 'ambiguous');
|
||||||
|
});
|
||||||
|
test('different complete option sets, duplicate option text and wrong option IDs are refused', () => {
|
||||||
|
assert.equal(planAnswer([single()], current({ options: [{ id: 'a', text: '降低温度' }, { id: 'b', text: '提高温度' }] }), core).status, 'missing');
|
||||||
|
assert.equal(planAnswer([single()], current({ options: [{ text: '提高温度' }, { text: '提高温度' }] }), core).status, 'ambiguous');
|
||||||
|
assert.equal(planAnswer([single()], current({ options: [{ id: 'b', text: '保持不变', label: 'A' }, { id: 'a', text: '降低温度', label: 'B' }, { id: 'c', text: '提高温度', label: 'C' }] }), core).status, 'ambiguous');
|
||||||
|
});
|
||||||
|
test('normalization preserves negatives, signs, units and superscripts', () => {
|
||||||
|
const record = single({ stem: '压力不大于10 MPa,且流量为2 m³/h。' });
|
||||||
|
assert.equal(planAnswer([record], current({ stem: '压力大于10 MPa,且流量为2 m³/h。' }), core).status, 'missing');
|
||||||
|
assert.equal(planAnswer([record], current({ stem: '压力不大于10 MPa,且流量为2 m3/h。' }), core).status, 'missing');
|
||||||
|
assert.equal(planAnswer([record], current({ stem: '压力不大于10 MPa,且流量为2 m³/h。' }), core).status, 'ready');
|
||||||
|
});
|
||||||
|
test('unsupported, incomplete and malformed questions are skipped', () => {
|
||||||
|
assert.equal(planAnswer([], null, core).status, 'unsupported');
|
||||||
|
assert.equal(planAnswer([], { stem: '简答题', type: 'unknown' }, core).status, 'unsupported');
|
||||||
|
assert.equal(planAnswer([{}, null], current(), core).status, 'missing');
|
||||||
|
});
|
||||||
|
test('continuous selection starts disabled and can be destroyed without scanning or persistence', () => {
|
||||||
|
const calls = [];
|
||||||
|
const win = { clearTimeout() {}, clearInterval() {}, setTimeout() { calls.push('timeout'); }, setInterval() { calls.push('interval'); } };
|
||||||
|
const engine = create({ window: win, document: {}, core });
|
||||||
|
assert.equal(engine.isEnabled(), false);
|
||||||
|
assert.equal(engine.getState().answeredCount, 0);
|
||||||
|
engine.destroy();
|
||||||
|
engine.setEnabled(true);
|
||||||
|
assert.equal(engine.isEnabled(), false); assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkboxPage({ disabled = false, ariaDisabled = false, duringClick } = {}) {
|
||||||
|
const clicks = [];
|
||||||
|
const baseNode = () => ({ nodeType: 1, isConnected: true, parentElement: null, hidden: false, getAttribute: () => null, closest: () => null });
|
||||||
|
const container = { ...baseNode(), matches: () => false, contains: () => true };
|
||||||
|
const title = { ...baseNode(), textContent: '选择全部正确的巡检操作。', querySelector: () => null };
|
||||||
|
const inputs = ['检查温度', '检查压力', '跳过巡检'].map((text, index) => {
|
||||||
|
const label = { ...baseNode(), textContent: text, querySelector: () => null,
|
||||||
|
cloneNode: () => ({ textContent: text, querySelectorAll: () => [] }) };
|
||||||
|
const input = { ...baseNode(), type: 'checkbox', value: String(index), name: 'checks', id: `check-${index}`, checked: false, disabled: false, labels: [label],
|
||||||
|
matches: selector => selector === ':disabled' && disabled,
|
||||||
|
closest: selector => selector === '[aria-disabled="true"]' ? (ariaDisabled ? container : null) : selector === 'label' ? label : selector.startsWith('fieldset') ? container : null,
|
||||||
|
click() { clicks.push(index); input.checked = !input.checked; duringClick?.(index); }
|
||||||
|
};
|
||||||
|
return input;
|
||||||
|
});
|
||||||
|
container.querySelectorAll = selector => selector.startsWith('input') ? inputs : [title];
|
||||||
|
const doc = { querySelectorAll: selector => selector.startsWith('.yxtulcdsdk') ? [] : [container] };
|
||||||
|
const win = { getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), clearTimeout() {}, clearInterval() {}, setTimeout() {}, setInterval() {} };
|
||||||
|
const record = { type: 'multiple', stem: title.textContent, options: inputs.map((input, index) => ({ id: input.value, text: ['检查温度', '检查压力', '跳过巡检'][index] })), answerTexts: ['检查温度', '检查压力'], correctOptionIds: ['0', '1'], answerStatus: 'confirmed' };
|
||||||
|
return { win, doc, record, inputs, clicks, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('disabled fieldsets and aria-disabled ancestors are never clicked', async () => {
|
||||||
|
for (const config of [{ disabled: true }, { ariaDisabled: true }]) {
|
||||||
|
const page = checkboxPage(config);
|
||||||
|
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
|
||||||
|
const result = await engine.answerCurrent();
|
||||||
|
assert.equal(result.status, 'skipped'); assert.deepEqual(page.clicks, []);
|
||||||
|
engine.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('setEnabled(false) cancels an in-progress one-shot before a second checkbox click', async () => {
|
||||||
|
let engine;
|
||||||
|
const page = checkboxPage({ duringClick: index => { if (index === 0) engine.setEnabled(false); } });
|
||||||
|
engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
|
||||||
|
const result = await engine.answerCurrent();
|
||||||
|
assert.equal(result.status, 'cancelled'); assert.deepEqual(page.clicks, [0]);
|
||||||
|
assert.equal(page.inputs[1].checked, false); assert.equal(engine.getState().busy, false);
|
||||||
|
engine.destroy();
|
||||||
|
});
|
||||||
|
test('a question changing during the first click stops the rest of a multiple selection', async () => {
|
||||||
|
let page;
|
||||||
|
page = checkboxPage({ duringClick: index => { if (index === 0) page.title.textContent = '已经切换到另一题。'; } });
|
||||||
|
const engine = create({ window: page.win, document: page.doc, core, getRecords: () => [page.record] });
|
||||||
|
const result = await engine.answerCurrent();
|
||||||
|
assert.equal(result.status, 'skipped'); assert.deepEqual(page.clicks, [0]);
|
||||||
|
assert.equal(page.inputs[1].checked, false);
|
||||||
|
engine.destroy();
|
||||||
|
});
|
||||||
+222
-17
@@ -12,7 +12,7 @@ const http = require('node:http');
|
|||||||
const ROOT = path.resolve(__dirname, '..');
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
const RECORDS_KEY = 'huayi-qb.records.v1';
|
const RECORDS_KEY = 'huayi-qb.records.v1';
|
||||||
const CONFIG_KEY = 'huayi-qb.config.v1';
|
const CONFIG_KEY = 'huayi-qb.config.v1';
|
||||||
const QA_DIR = path.join(os.tmpdir(), 'huayi-qb-qa-v110');
|
const QA_DIR = path.join(os.tmpdir(), 'huayi-qb-qa-v120');
|
||||||
const BUNDLED = path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules');
|
const BUNDLED = path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules');
|
||||||
|
|
||||||
function loadPlaywright() {
|
function loadPlaywright() {
|
||||||
@@ -125,6 +125,9 @@ function installGMShim() {
|
|||||||
window.GM_registerMenuCommand = (label, callback) => { window.__qbMenus.push({ label, callback }); return window.__qbMenus.length; };
|
window.GM_registerMenuCommand = (label, callback) => { window.__qbMenus.push({ label, callback }); return window.__qbMenus.length; };
|
||||||
window.GM_addValueChangeListener = () => 1;
|
window.GM_addValueChangeListener = () => 1;
|
||||||
window.GM_removeValueChangeListener = () => {};
|
window.GM_removeValueChangeListener = () => {};
|
||||||
|
window.__qbOriginalFetch = window.fetch;
|
||||||
|
window.__qbOriginalXHROpen = XMLHttpRequest.prototype.open;
|
||||||
|
window.__qbOriginalXHRSend = XMLHttpRequest.prototype.send;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitFor(predicate, message, timeout = 5000) {
|
async function waitFor(predicate, message, timeout = 5000) {
|
||||||
@@ -142,6 +145,7 @@ async function storageRecords(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function activate(page, tab) {
|
async function activate(page, tab) {
|
||||||
|
if (!await page.locator('#qb-panel').isVisible()) await page.keyboard.press('Alt+Shift+Q');
|
||||||
const exact = page.locator(`#huayi-qb-root [data-tab="${tab}"]`);
|
const exact = page.locator(`#huayi-qb-root [data-tab="${tab}"]`);
|
||||||
if (await exact.count()) { await exact.click(); return; }
|
if (await exact.count()) { await exact.click(); return; }
|
||||||
const names = { search: /检索|查询/, library: /题库|导出/, settings: /设置/, collect: /采集/ };
|
const names = { search: /检索|查询/, library: /题库|导出/, settings: /设置/, collect: /采集/ };
|
||||||
@@ -152,6 +156,7 @@ async function activate(page, tab) {
|
|||||||
async function importFile(page, name, text) {
|
async function importFile(page, name, text) {
|
||||||
await activate(page, 'library');
|
await activate(page, 'library');
|
||||||
await page.locator('#qb-import-file').setInputFiles({ name, mimeType: name.endsWith('.json') ? 'application/json' : 'text/csv', buffer: Buffer.from(text) });
|
await page.locator('#qb-import-file').setInputFiles({ name, mimeType: name.endsWith('.json') ? 'application/json' : 'text/csv', buffer: Buffer.from(text) });
|
||||||
|
await waitFor(async () => await page.locator('#qb-import-file').evaluate(input => input.files.length === 0), '等待异步文件解析和界面刷新完成');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectQuestion(page) {
|
async function selectQuestion(page) {
|
||||||
@@ -176,6 +181,57 @@ async function setSingleQuestion(page) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ordinaryFixture() {
|
||||||
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>普通网页自动答题验收</title>
|
||||||
|
<style>body{margin:30px;background:#f1f5f8;color:#253b50;font:16px/1.7 system-ui,sans-serif}main{max-width:700px;background:white;border-radius:12px;padding:25px}fieldset{border:1px solid #ccd9e4;border-radius:10px;padding:18px}legend{font-weight:600}label{display:block;padding:10px;border-bottom:1px solid #edf0f3}button{margin:12px 8px 0 0;padding:8px 14px}@media(max-width:500px){body{margin:12px}main{padding:14px}}</style>
|
||||||
|
</head><body><main><h1>普通网页练习</h1><p>使用本地题库,仅选择已确认的选项。</p><form id="ordinary-form"><div id="ordinary-question"></div><button type="submit" id="ordinary-submit">提交答案</button><button type="button" id="ordinary-next">下一题</button></form><button type="button" id="ordinary-unrelated">普通功能按钮</button></main></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_FIXTURES = {
|
||||||
|
single: { type: 'single', stem: '通用单选:哪项操作有助于提高温度?', options: [['hold', '保持不变'], ['cool', '降低温度'], ['heat', '提高温度']], answer: ['提高温度'] },
|
||||||
|
judge: { type: 'judge', stem: '通用判断:停车检修时可以跳过确认步骤。', options: [['1', '正确'], ['0', '错误']], answer: ['错误'] },
|
||||||
|
multiple: { type: 'multiple', stem: '通用多选:巡检时需要检查哪些参数?', options: [['skip', '跳过检查'], ['pressure', '压力'], ['temperature', '温度']], answer: ['压力', '温度'] },
|
||||||
|
continuous: { type: 'single', stem: '连续模式:换题后应选择哪个已确认的项目?', options: [['hold', '保持状态'], ['check', '完成检查']], answer: ['完成检查'] },
|
||||||
|
stopped: { type: 'single', stem: '关闭模式:这一题应由用户自行选择。', options: [['stop-a', '选项甲'], ['stop-b', '选项乙']], answer: ['选项乙'] },
|
||||||
|
ambiguous: { type: 'single', stem: '歧义题:同题干两个来源给出不同答案。', options: [['a', '做法甲'], ['b', '做法乙']], answer: ['做法甲'] },
|
||||||
|
missing: { type: 'single', stem: '尚无标准答案的通用题目。', options: [['a', '选项一'], ['b', '选项二']], answer: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
function autoRecords() {
|
||||||
|
const records = Object.entries(AUTO_FIXTURES).filter(([key]) => key !== 'missing').map(([key, item]) => ({
|
||||||
|
bankId: 'generic-seed-bank', bankName: '通用已确认题库', questionId: `generic-${key}`, type: item.type, stem: item.stem,
|
||||||
|
options: item.options.map(([id, text], index) => ({ id, text, label: item.type === 'judge' ? text : String.fromCharCode(65 + index) })),
|
||||||
|
answerTexts: item.answer, correctOptionIds: item.options.filter(([, text]) => item.answer.includes(text)).map(([id]) => id), answerStatus: 'confirmed'
|
||||||
|
}));
|
||||||
|
const ambiguous = records.find(record => record.questionId === 'generic-ambiguous');
|
||||||
|
records.push({ ...ambiguous, bankId: 'other-generic-bank', bankName: '另一来源题库', questionId: 'generic-ambiguous-other', answerTexts: ['做法乙'], correctOptionIds: ['b'] });
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installOrdinaryQuestion(page, key, wrongChecked = false) {
|
||||||
|
await page.evaluate(({ question, wrongChecked }) => {
|
||||||
|
const root = document.querySelector('#ordinary-question');
|
||||||
|
root.replaceChildren();
|
||||||
|
const fieldset = document.createElement('fieldset');
|
||||||
|
fieldset.id = 'generic-question';
|
||||||
|
const legend = document.createElement('legend');
|
||||||
|
legend.textContent = question.stem;
|
||||||
|
fieldset.append(legend);
|
||||||
|
question.options.forEach(([id, text], index) => {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = question.type === 'multiple' ? 'checkbox' : 'radio';
|
||||||
|
input.name = 'ordinary-answer';
|
||||||
|
input.value = id;
|
||||||
|
input.id = `ordinary-${id}`;
|
||||||
|
if (wrongChecked && index === 0) input.checked = true;
|
||||||
|
label.append(input, document.createTextNode(` ${text}`));
|
||||||
|
fieldset.append(label);
|
||||||
|
});
|
||||||
|
root.append(fieldset);
|
||||||
|
}, { question: AUTO_FIXTURES[key], wrongChecked });
|
||||||
|
}
|
||||||
|
|
||||||
test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢复', { timeout: 150000 }, async t => {
|
test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢复', { timeout: 150000 }, async t => {
|
||||||
const { chromium } = loadPlaywright();
|
const { chromium } = loadPlaywright();
|
||||||
const builtPath = path.join(ROOT, 'dist/huayi-question-bank.user.js');
|
const builtPath = path.join(ROOT, 'dist/huayi-question-bank.user.js');
|
||||||
@@ -231,6 +287,10 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`${origin}/o2o/#/playinfo?taskId=qa-task-initial`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${origin}/o2o/#/playinfo?taskId=qa-task-initial`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
|
||||||
|
assert.equal(await page.locator('#qb-panel').isVisible(), false, '首次加载默认完全隐藏');
|
||||||
|
assert.equal(await page.locator('#qb-launcher').isVisible(), false, '默认不保留悬浮按钮');
|
||||||
|
await page.keyboard.press('Alt+Shift+Q');
|
||||||
await page.locator('#qb-panel').waitFor({ state: 'visible' });
|
await page.locator('#qb-panel').waitFor({ state: 'visible' });
|
||||||
await t.test('页面非空、脱敏DOM夹具采集缺答案题目并恢复文字选择', async () => {
|
await t.test('页面非空、脱敏DOM夹具采集缺答案题目并恢复文字选择', async () => {
|
||||||
assert.match(await page.title(), /判断题库1/);
|
assert.match(await page.title(), /判断题库1/);
|
||||||
@@ -281,7 +341,7 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
|
|||||||
await activate(page, 'search');
|
await activate(page, 'search');
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('折叠后可展开;隐藏与刷新后恢复入口保留', async () => {
|
await t.test('折叠后可展开;隐藏无悬浮入口且刷新后仍完全隐藏', async () => {
|
||||||
const fullHeight = (await page.locator('#qb-panel').boundingBox()).height;
|
const fullHeight = (await page.locator('#qb-panel').boundingBox()).height;
|
||||||
await page.locator('[data-action="collapse"]').click();
|
await page.locator('[data-action="collapse"]').click();
|
||||||
const collapsedHeight = (await page.locator('#qb-panel').boundingBox()).height;
|
const collapsedHeight = (await page.locator('#qb-panel').boundingBox()).height;
|
||||||
@@ -289,41 +349,34 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
|
|||||||
await page.locator('[data-action="collapse"]').click();
|
await page.locator('[data-action="collapse"]').click();
|
||||||
await page.locator('.header [data-action="hide"]').click();
|
await page.locator('.header [data-action="hide"]').click();
|
||||||
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
||||||
assert.equal(await page.locator('#qb-launcher').isVisible(), true);
|
assert.equal(await page.locator('#qb-launcher').isVisible(), false);
|
||||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
await page.locator('#qb-launcher').waitFor({ state: 'visible' });
|
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
|
||||||
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
||||||
await selectQuestion(page);
|
await selectQuestion(page);
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
assert.equal(await page.locator('#qb-panel').isVisible(), false, '隐藏后划词不能擅自弹出面板');
|
assert.equal(await page.locator('#qb-panel').isVisible(), false, '隐藏后划词不能擅自弹出面板');
|
||||||
await page.screenshot({ path: path.join(QA_DIR, 'hidden-launcher.png') });
|
await page.screenshot({ path: path.join(QA_DIR, 'hidden-default.png') });
|
||||||
await page.locator('[data-action="restore"]').click();
|
await page.keyboard.press('Alt+Shift+Q');
|
||||||
assert.equal(await page.locator('#qb-panel').isVisible(), true);
|
assert.equal(await page.locator('#qb-panel').isVisible(), true);
|
||||||
assert.match(await page.locator('#qb-search').inputValue(), /干燥过程传质推动力/, '恢复面板后应保留隐藏时划词检索的内容');
|
assert.match(await page.locator('#qb-search').inputValue(), /干燥过程传质推动力/, '恢复面板后应保留隐藏时划词检索的内容');
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('完全隐藏后快捷键与油猴菜单均可唤出', async () => {
|
await t.test('快捷键切换显隐;即使旧配置为打开和悬浮按钮刷新也保持隐藏', async () => {
|
||||||
await activate(page, 'settings');
|
await activate(page, 'settings');
|
||||||
await page.locator('#qb-keep-launcher').uncheck();
|
assert.equal(await page.locator('#qb-keep-launcher').count(), 0, '默认隐藏不再提供悬浮按钮偏好');
|
||||||
await page.locator('.header [data-action="hide"]').click();
|
await page.locator('.header [data-action="hide"]').click();
|
||||||
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
||||||
assert.equal(await page.locator('#qb-launcher').isVisible(), false);
|
assert.equal(await page.locator('#qb-launcher').isVisible(), false);
|
||||||
|
await page.evaluate(key => window.GM_setValue(key, { ...window.GM_getValue(key, {}), mode: 'open', keepLauncher: true }), CONFIG_KEY);
|
||||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForSelector('#huayi-qb-root', { state: 'attached' });
|
await page.waitForSelector('#huayi-qb-root', { state: 'attached' });
|
||||||
assert.equal(await page.locator('#qb-launcher').isVisible(), false);
|
assert.equal(await page.locator('#qb-launcher').isVisible(), false);
|
||||||
await page.keyboard.press('Alt+Shift+Q');
|
await page.keyboard.press('Alt+Shift+Q');
|
||||||
await page.locator('#qb-panel').waitFor({ state: 'visible' });
|
await page.locator('#qb-panel').waitFor({ state: 'visible' });
|
||||||
await page.locator('.header [data-action="hide"]').click();
|
await page.locator('.header [data-action="hide"]').click();
|
||||||
const menuFound = await page.evaluate(() => {
|
await page.keyboard.press('Alt+Shift+Q');
|
||||||
const menu = window.__qbMenus.find(item => /显示|唤出|恢复|打开/.test(item.label));
|
|
||||||
if (!menu) return false;
|
|
||||||
menu.callback();
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
assert.equal(menuFound, true, '应提供油猴菜单恢复入口');
|
|
||||||
assert.equal(await page.locator('#qb-panel').isVisible(), true);
|
assert.equal(await page.locator('#qb-panel').isVisible(), true);
|
||||||
await activate(page, 'settings');
|
|
||||||
await page.locator('#qb-keep-launcher').check();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await t.test('JSON导入与当前页面选项乱序:正确内容映射为C', async () => {
|
await t.test('JSON导入与当前页面选项乱序:正确内容映射为C', async () => {
|
||||||
@@ -470,6 +523,9 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
|
|||||||
bankPage.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
bankPage.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||||
try {
|
try {
|
||||||
await bankPage.goto(`${origin}/multi-bank#/playinfo?taskId=${BANK_FIXTURES[0].taskId}`, { waitUntil: 'domcontentloaded' });
|
await bankPage.goto(`${origin}/multi-bank#/playinfo?taskId=${BANK_FIXTURES[0].taskId}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await bankPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
|
||||||
|
assert.equal(await bankPage.locator('#qb-panel').isVisible(), false);
|
||||||
|
await bankPage.keyboard.press('Alt+Shift+Q');
|
||||||
await bankPage.locator('#qb-panel').waitFor({ state: 'visible' });
|
await bankPage.locator('#qb-panel').waitFor({ state: 'visible' });
|
||||||
await setupBankSwitches(bankPage);
|
await setupBankSwitches(bankPage);
|
||||||
await waitFor(async () => (await storageRecords(bankPage)).some(record => record.bankId === 'judge-1'), '初始判断题库应采集到稳定身份');
|
await waitFor(async () => (await storageRecords(bankPage)).some(record => record.bankId === 'judge-1'), '初始判断题库应采集到稳定身份');
|
||||||
@@ -499,6 +555,30 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
|
|||||||
await bankPage.screenshot({ path: path.join(QA_DIR, 'current-bank-export.png') });
|
await bankPage.screenshot({ path: path.join(QA_DIR, 'current-bank-export.png') });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await group.test('华谊题目DOM可自动选择多选和乱序单选,保持提交与下一题由用户操作', async () => {
|
||||||
|
await bankPage.evaluate(() => {
|
||||||
|
window.__qaSiteButtonClicks = 0;
|
||||||
|
document.querySelector('.fixture-content').addEventListener('click', event => {
|
||||||
|
if (event.target.closest('button')) window.__qaSiteButtonClicks++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await activate(bankPage, 'auto');
|
||||||
|
assert.equal(await bankPage.locator('#qb-auto-enabled').isChecked(), false);
|
||||||
|
await bankPage.locator('[data-action="auto-answer-current"]').click();
|
||||||
|
await waitFor(async () => (await bankPage.locator('.yxtulcdsdk-form input:checked').count()) === 2, '华谊多选应同时选择两个标准答案');
|
||||||
|
assert.equal(await bankPage.locator('.yxtulcdsdk-form input[value="check-t"]').isChecked(), true);
|
||||||
|
assert.equal(await bankPage.locator('.yxtulcdsdk-form input[value="check-p"]').isChecked(), true);
|
||||||
|
assert.equal(await bankPage.locator('.yxtulcdsdk-form input[value="skip"]').isChecked(), false);
|
||||||
|
await bankPage.locator('[data-qa-bank="single-1"]').click();
|
||||||
|
await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes('单选题库1'), '切到单选库等待题库身份更新');
|
||||||
|
await waitFor(async () => (await bankPage.locator('.yxtulcdsdk-form__content > [data-rich-text]').innerText()).includes('单选库一'), '等待新题目正文加载');
|
||||||
|
await bankPage.locator('[data-action="auto-answer-current"]').click();
|
||||||
|
await waitFor(async () => await bankPage.locator('.yxtulcdsdk-form input[value="yes"]').isChecked(), '华谊单选应按当前C位置选中标准内容');
|
||||||
|
assert.equal(await bankPage.locator('.yxtulcdsdk-form input:checked').count(), 1);
|
||||||
|
assert.equal(await bankPage.evaluate(() => window.__qaSiteButtonClicks), 0, '自动答题不能点击题目区的上一题、下一题或提交按钮');
|
||||||
|
await bankPage.screenshot({ path: path.join(QA_DIR, 'huayi-auto-answer.png') });
|
||||||
|
});
|
||||||
|
|
||||||
await group.test('文字搜索可手输题干、选项与标准答案,支持当前库和全部库', async () => {
|
await group.test('文字搜索可手输题干、选项与标准答案,支持当前库和全部库', async () => {
|
||||||
await bankPage.evaluate(() => { window.__qaLateResponse = fetch('/upm/start?praId=multiple&item=bank-multiple&delay=500').then(response => response.json()); });
|
await bankPage.evaluate(() => { window.__qaLateResponse = fetch('/upm/start?praId=multiple&item=bank-multiple&delay=500').then(response => response.json()); });
|
||||||
await bankPage.locator('[data-qa-bank="single-1"]').click();
|
await bankPage.locator('[data-qa-bank="single-1"]').click();
|
||||||
@@ -619,6 +699,131 @@ test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢
|
|||||||
} finally { await bankContext.close(); }
|
} finally { await bankContext.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await t.test('全局网页隐藏唤出与自动答题', async group => {
|
||||||
|
assert.ok(/\/\/ @match\s+\*:\/\/\*\/\*/.test(script) || (/\/\/ @match\s+http:\/\/\*\/\*/.test(script) && /\/\/ @match\s+https:\/\/\*\/\*/.test(script)), '安装元数据应覆盖普通HTTP/HTTPS网页');
|
||||||
|
const genericContext = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true });
|
||||||
|
const seed = JSON.stringify(autoRecords());
|
||||||
|
const initialization = `(${installGMShim.toString()})();\nif(!localStorage.getItem(${JSON.stringify(RECORDS_KEY)}))localStorage.setItem(${JSON.stringify(RECORDS_KEY)},${JSON.stringify(seed)});\nlocalStorage.setItem(${JSON.stringify(CONFIG_KEY)},JSON.stringify({mode:'open',keepLauncher:true,copyEnabled:true,collecting:true,tab:'search'}));\n${script}`;
|
||||||
|
await genericContext.addInitScript({ content: initialization });
|
||||||
|
await genericContext.route('**/*', route => {
|
||||||
|
if (route.request().url().endsWith('/favicon.ico')) return route.fulfill({ status: 204, body: '' });
|
||||||
|
if (new URL(route.request().url()).pathname === '/upm/start') return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(bankResponse(BANK_FIXTURES[0])) });
|
||||||
|
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: ordinaryFixture() });
|
||||||
|
});
|
||||||
|
const genericPage = await genericContext.newPage();
|
||||||
|
genericPage.on('pageerror', error => errors.push(error.message));
|
||||||
|
genericPage.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||||
|
try {
|
||||||
|
await genericPage.goto('http://ordinary.example.test/exercise', { waitUntil: 'domcontentloaded' });
|
||||||
|
await genericPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
|
||||||
|
await genericPage.evaluate(() => {
|
||||||
|
window.__qaSubmitCount = 0; window.__qaNextCount = 0; window.__qaUnrelatedCount = 0;
|
||||||
|
document.querySelector('#ordinary-form').addEventListener('submit', event => { event.preventDefault(); window.__qaSubmitCount++; });
|
||||||
|
document.querySelector('#ordinary-next').addEventListener('click', () => window.__qaNextCount++);
|
||||||
|
document.querySelector('#ordinary-unrelated').addEventListener('click', () => window.__qaUnrelatedCount++);
|
||||||
|
});
|
||||||
|
await installOrdinaryQuestion(genericPage, 'single');
|
||||||
|
|
||||||
|
await group.test('普通域名每次默认隐藏,快捷键显示/隐藏,不拦截网页网络和采集普通表单', async () => {
|
||||||
|
assert.equal(await genericPage.locator('#qb-panel').isVisible(), false);
|
||||||
|
assert.equal(await genericPage.locator('#qb-launcher').isVisible(), false);
|
||||||
|
assert.equal(await genericPage.evaluate(() => window.fetch === window.__qbOriginalFetch && XMLHttpRequest.prototype.open === window.__qbOriginalXHROpen && XMLHttpRequest.prototype.send === window.__qbOriginalXHRSend), true, '普通网页网络函数应保持原样');
|
||||||
|
await genericPage.evaluate(() => fetch('/upm/start?praId=judge-1').then(response => response.json()));
|
||||||
|
await genericPage.waitForTimeout(300);
|
||||||
|
assert.equal((await storageRecords(genericPage)).length, autoRecords().length, '普通页面不得把任意业务响应采集进题库');
|
||||||
|
await genericPage.keyboard.press('Alt+Shift+Q');
|
||||||
|
await genericPage.locator('#qb-panel').waitFor({ state: 'visible' });
|
||||||
|
assert.equal(await genericPage.locator('#qb-search-scope').inputValue(), '', '没有当前题库的普通页默认搜索全部库');
|
||||||
|
await genericPage.locator('#qb-search').fill('提高温度');
|
||||||
|
await genericPage.locator('#qb-search').press('Enter');
|
||||||
|
await waitFor(async () => (await genericPage.locator('#qb-results article').count()) === 1, '普通网页也可查已导入题库');
|
||||||
|
await genericPage.keyboard.press('Alt+Shift+Q');
|
||||||
|
assert.equal(await genericPage.locator('#qb-panel').isVisible(), false);
|
||||||
|
await genericPage.keyboard.press('Alt+Shift+Q');
|
||||||
|
assert.equal(await genericPage.locator('#qb-panel').isVisible(), true);
|
||||||
|
await activate(genericPage, 'auto');
|
||||||
|
assert.equal(await genericPage.locator('#qb-auto-enabled').isChecked(), false, '自动答题默认关闭');
|
||||||
|
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await group.test('单选乱序按内容选择当前答案且不提交或点击下一题', async () => {
|
||||||
|
await genericPage.locator('[data-action="auto-answer-current"]').click();
|
||||||
|
await waitFor(async () => await genericPage.locator('#ordinary-heat').isChecked(), '应选择当前顺序中的提高温度');
|
||||||
|
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 1);
|
||||||
|
assert.equal(await genericPage.evaluate(() => window.__qaSubmitCount + window.__qaNextCount), 0);
|
||||||
|
await genericPage.screenshot({ path: path.join(QA_DIR, 'generic-auto-single.png') });
|
||||||
|
});
|
||||||
|
|
||||||
|
await group.test('判断与多选完整集合正确,取消原来选错的多选项', async () => {
|
||||||
|
await installOrdinaryQuestion(genericPage, 'judge');
|
||||||
|
await genericPage.locator('[data-action="auto-answer-current"]').click();
|
||||||
|
await waitFor(async () => await genericPage.locator('#ordinary-0').isChecked(), '判断题应选择错误,不能将0当真');
|
||||||
|
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 1);
|
||||||
|
await installOrdinaryQuestion(genericPage, 'multiple', true);
|
||||||
|
assert.equal(await genericPage.locator('#ordinary-skip').isChecked(), true);
|
||||||
|
await genericPage.locator('[data-action="auto-answer-current"]').click();
|
||||||
|
await waitFor(async () => await genericPage.locator('#ordinary-pressure').isChecked() && await genericPage.locator('#ordinary-temperature').isChecked() && !await genericPage.locator('#ordinary-skip').isChecked(), '多选必须选择全部正确项并取消错误项');
|
||||||
|
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 2);
|
||||||
|
assert.equal(await genericPage.evaluate(() => window.__qaSubmitCount + window.__qaNextCount), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await group.test('缺答案与不同来源冲突题跳过,不误点普通功能按钮', async () => {
|
||||||
|
for (const key of ['missing', 'ambiguous']) {
|
||||||
|
await installOrdinaryQuestion(genericPage, key);
|
||||||
|
await genericPage.locator('[data-action="auto-answer-current"]').click();
|
||||||
|
await genericPage.waitForTimeout(200);
|
||||||
|
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0, `${key}题目不应猜测答案`);
|
||||||
|
assert.match(await genericPage.locator('#qb-auto-status').innerText(), /未|没有|跳过|冲突|歧义|不明确|无法|多个/);
|
||||||
|
}
|
||||||
|
assert.equal(await genericPage.evaluate(() => window.__qaUnrelatedCount), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await group.test('连续模式选择动态新题,关闭后立即停止,刷新后关闭并隐藏', async () => {
|
||||||
|
await installOrdinaryQuestion(genericPage, 'continuous');
|
||||||
|
await genericPage.locator('#qb-auto-enabled').check();
|
||||||
|
await waitFor(async () => await genericPage.locator('#ordinary-check').isChecked(), '连续模式开启后选择已确认答案');
|
||||||
|
await installOrdinaryQuestion(genericPage, 'single');
|
||||||
|
await waitFor(async () => await genericPage.locator('#ordinary-heat').isChecked(), '连续模式应处理动态换题');
|
||||||
|
await genericPage.locator('#qb-auto-enabled').uncheck();
|
||||||
|
await installOrdinaryQuestion(genericPage, 'stopped');
|
||||||
|
await genericPage.waitForTimeout(600);
|
||||||
|
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0, '关闭后不得继续操作新题');
|
||||||
|
await genericPage.locator('#qb-auto-enabled').check();
|
||||||
|
await waitFor(async () => await genericPage.locator('#ordinary-stop-b').isChecked(), '重新开启后可选择当前题');
|
||||||
|
await genericPage.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await genericPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
|
||||||
|
assert.equal(await genericPage.locator('#qb-panel').isVisible(), false, '刷新不应记住打开状态');
|
||||||
|
assert.equal(await genericPage.locator('#qb-launcher').isVisible(), false);
|
||||||
|
await genericPage.keyboard.press('Alt+Shift+Q');
|
||||||
|
await activate(genericPage, 'auto');
|
||||||
|
assert.equal(await genericPage.locator('#qb-auto-enabled').isChecked(), false, '连续模式不能跨页面自动开启');
|
||||||
|
await installOrdinaryQuestion(genericPage, 'single');
|
||||||
|
await genericPage.waitForTimeout(300);
|
||||||
|
assert.equal(await genericPage.locator('#generic-question input:checked').count(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await group.test('另一个HTTPS普通域名也默认隐藏,普通无题页面和窄屏不误操作', async () => {
|
||||||
|
await genericPage.goto('https://another.example.test/ordinary', { waitUntil: 'domcontentloaded' });
|
||||||
|
await genericPage.locator('#huayi-qb-root').waitFor({ state: 'attached' });
|
||||||
|
assert.equal(await genericPage.locator('#qb-panel').isVisible(), false);
|
||||||
|
await genericPage.keyboard.press('Alt+Shift+Q');
|
||||||
|
await activate(genericPage, 'auto');
|
||||||
|
await genericPage.evaluate(() => {
|
||||||
|
document.querySelector('#ordinary-form').replaceChildren();
|
||||||
|
const option = document.createElement('input'); option.type = 'checkbox'; option.id = 'ordinary-unrelated-checkbox';
|
||||||
|
document.querySelector('main').append(option);
|
||||||
|
});
|
||||||
|
await genericPage.locator('[data-action="auto-answer-current"]').click();
|
||||||
|
await genericPage.waitForTimeout(200);
|
||||||
|
assert.equal(await genericPage.locator('#ordinary-unrelated-checkbox').isChecked(), false);
|
||||||
|
await genericPage.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const rect = await genericPage.locator('#qb-panel').boundingBox();
|
||||||
|
assert.ok(rect.x >= 0 && rect.x + rect.width <= 391 && rect.y >= 0 && rect.y + rect.height <= 845);
|
||||||
|
await genericPage.screenshot({ path: path.join(QA_DIR, 'generic-auto-mobile.png') });
|
||||||
|
});
|
||||||
|
} finally { await genericContext.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
await t.test('控制台无脚本错误', async () => { assert.deepEqual(errors, []); });
|
await t.test('控制台无脚本错误', async () => { assert.deepEqual(errors, []); });
|
||||||
t.diagnostic(`Browser plugin not available; cached Chromium via Playwright. Local origin: ${origin}; viewports 1440x1000 and 390x844; evidence: ${QA_DIR}`);
|
t.diagnostic(`Browser plugin not available; cached Chromium via Playwright. Local origin: ${origin}; viewports 1440x1000 and 390x844; evidence: ${QA_DIR}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user