feat: add local question bank reset with stale-tab isolation

This commit is contained in:
suanyuit-cyber
2026-11-03 03:55:22 +08:00
parent 3df021e8d1
commit a7d2d30daa
8 changed files with 666 additions and 83 deletions
+15 -1
View File
@@ -1,6 +1,8 @@
# 华谊题库助手 # 华谊题库助手
支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.7**。 支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.8**。
1.2.8 新增“重置全部题库”,用于清除旧版或错误导入的数据。确认后清空本地全部题库、答案、冲突和四类历史修复备份,取消正在进行的导入及待保存任务,停止自动选择和判断题补齐,并暂停采集。新的题库存储与重置前隔离,旧标签页或旧版脚本再次写入的数据不会混回。升级本身保留已有资料,需要清空时再手动重置。
1.2.7 新增默认关闭的“补齐当前判断题库”。手动启动后会通过正常练习控件逐题作答,等待网站标准反馈与同题可信入库记录一致后再进入下一题;这会改变练习记录。只支持本站500题或232题的正常判断练习,不支持普通网页、正式考试或错题重做。该功能与普通自动选择互斥,详见下方使用说明。仓库答案在实际采集及逐条严格核验后更新,不能从脚本版本或练习进度推定完整。 1.2.7 新增默认关闭的“补齐当前判断题库”。手动启动后会通过正常练习控件逐题作答,等待网站标准反馈与同题可信入库记录一致后再进入下一题;这会改变练习记录。只支持本站500题或232题的正常判断练习,不支持普通网页、正式考试或错题重做。该功能与普通自动选择互斥,详见下方使用说明。仓库答案在实际采集及逐条严格核验后更新,不能从脚本版本或练习进度推定完整。
@@ -104,6 +106,18 @@
导入成功后可在“已保存题库”查看各库名称和题数,再在搜索 / 导出下拉框选择相应题库。导入不会改变页面当前正在练习的题库,也不会自动将导出范围切为全部。 导入成功后可在“已保存题库”查看各库名称和题数,再在搜索 / 导出下拉框选择相应题库。导入不会改变页面当前正在练习的题库,也不会自动将导出范围切为全部。
## 清除旧数据并重新导入
1. 将油猴中的原脚本完整更新为 [`dist/huayi-question-bank.user.js`](dist/huayi-question-bank.user.js)(1.2.8),保存并刷新网页。
2. 按 **Shift + Alt + Q** 显示面板。在“题库 / 导出”的“已保存题库”下方,或“设置”顶部,点击 **重置全部题库**;油猴菜单也有同名入口。
3. 确认提示会说明清除范围。如需保留旧资料,先取消并点击“备份 JSON”。确认重置后,本地题数归零,四类历史修复备份一起清除,正在读取的导入文件也不会在稍后写回。
4. 点击“导入多个题库”,选择仓库中已核验的 [`合并题库JSON`](题库/华谊集团化工总控工(高级)-合并题库.json) 或 [`合并CSV`](题库/华谊集团化工总控工(高级)-合并题库.csv),导入后应为 **1884题**。只需选择其中一种;也可导入所需的分库文件。
5. 重置后采集保持暂停,刷新后也不会自动恢复。需要重新采集时,再点击“继续采集”;连续自动选择和判断题补齐仍须手动开启。
重置作用于当前浏览器中同一油猴脚本保存的全部题库,不受搜索或导出范围限制。已打开的新版本标签页会同步清空内存,并关闭自动操作;漏收通知的页面在下次读取、保存或恢复前检查重置状态。旧版页面继续写入旧存储时,新版也不会再读取那一份数据。复制开关和面板偏好保留,网页账号的练习记录以及已下载的文件不受影响。
重置需要先验证空题库和重置标记写入成功,再清理原数据。准备或标记保存失败时保留原题库并显示错误;如果仅旧存储清理失败,会明确提示旧数据已隔离。原题库或修复备份损坏时,仍可使用重置功能重新初始化。
## 隐藏、收起与恢复 ## 隐藏、收起与恢复
| 操作 | 效果 | 恢复方式 | | 操作 | 效果 | 恢复方式 |
+3 -1
View File
@@ -4,7 +4,7 @@ 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.2.7 // @version 1.2.8
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。 // @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
// @match *://*/* // @match *://*/*
// @run-at document-start // @run-at document-start
@@ -12,8 +12,10 @@ const header = `// ==UserScript==
// @grant unsafeWindow // @grant unsafeWindow
// @grant GM_getValue // @grant GM_getValue
// @grant GM_setValue // @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand // @grant GM_registerMenuCommand
// @grant GM_addValueChangeListener // @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// ==/UserScript== // ==/UserScript==
`; `;
const sources = ['src/core.js', 'src/adapter.js', 'src/autofill.js', 'src/collector.js', 'src/app.js']; const sources = ['src/core.js', 'src/adapter.js', 'src/autofill.js', 'src/collector.js', 'src/app.js'];
+176 -15
View File
@@ -1,7 +1,7 @@
// ==UserScript== // ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索 // @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank // @namespace local.huayi.question-bank
// @version 1.2.7 // @version 1.2.8
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。 // @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
// @match *://*/* // @match *://*/*
// @run-at document-start // @run-at document-start
@@ -9,8 +9,10 @@
// @grant unsafeWindow // @grant unsafeWindow
// @grant GM_getValue // @grant GM_getValue
// @grant GM_setValue // @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand // @grant GM_registerMenuCommand
// @grant GM_addValueChangeListener // @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// ==/UserScript== // ==/UserScript==
(function () { (function () {
@@ -1777,7 +1779,15 @@ const QBAdapter = (() => {
status('采集已暂停,已保存题库和选择复制功能仍可使用。'); status('采集已暂停,已保存题库和选择复制功能仍可使用。');
} }
return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled, isSupportedPage }; function clearCache() {
stop();
known.clear(); knownContentIndex = null;
banksById.clear(); banksByTask.clear(); knownSnapshotOrder.clear();
currentBank = null; pendingSelection = ''; blockedForm = null; lastFormSnapshot = null;
lastDOMSignature = '';
}
return { start, stop, clearCache, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled, isSupportedPage };
} }
return { create, extractQuestions, richText }; return { create, extractQuestions, richText };
@@ -2371,9 +2381,11 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
const OPTION_REPAIR_BACKUP_KEY = 'huayi-qb.records.before-duplicate-option-repair.v1'; const OPTION_REPAIR_BACKUP_KEY = 'huayi-qb.records.before-duplicate-option-repair.v1';
const IMAGE_UPGRADE_BACKUP_KEY = 'huayi-qb.records.before-image-upgrade.v1'; const IMAGE_UPGRADE_BACKUP_KEY = 'huayi-qb.records.before-image-upgrade.v1';
const JUDGE_EVIDENCE_BACKUP_KEY = 'huayi-qb.records.before-judge-evidence-repair.v1'; const JUDGE_EVIDENCE_BACKUP_KEY = 'huayi-qb.records.before-judge-evidence-repair.v1';
const RESET_KEY = 'huayi-qb.library-reset.v1';
const LIBRARY_KEYS = [RECORDS_KEY, BACKUP_KEY, OPTION_REPAIR_BACKUP_KEY, IMAGE_UPGRADE_BACKUP_KEY, JUDGE_EVIDENCE_BACKUP_KEY];
const DEFAULTS = { mode: 'hidden', keepLauncher: false, 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 rawStorage = {
get(key, fallback) { get(key, fallback) {
if (typeof GM_getValue === 'function') return GM_getValue(key, fallback); if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
const raw = localStorage.getItem(key); const raw = localStorage.getItem(key);
@@ -2382,8 +2394,30 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
set(key, value) { set(key, value) {
if (typeof GM_setValue === 'function') return GM_setValue(key, value); if (typeof GM_setValue === 'function') return GM_setValue(key, value);
localStorage.setItem(key, JSON.stringify(value)); localStorage.setItem(key, JSON.stringify(value));
},
remove(key) {
if (typeof GM_deleteValue === 'function') return GM_deleteValue(key);
if (typeof GM_setValue === 'function') return GM_setValue(key, null);
localStorage.removeItem(key);
} }
}; };
function readGeneration() {
const marker = rawStorage.get(RESET_KEY, null);
if (marker === null) return '';
if (marker?.schemaVersion !== 1 || typeof marker.generation !== 'string' || !/^[a-zA-Z0-9-]{1,100}$/.test(marker.generation)) {
throw new Error('题库重置标记异常,请使用“重置全部题库”重新初始化');
}
return marker.generation;
}
const scopedKey = (key, generation) => generation ? `${key}.reset.${generation}` : key;
let libraryGeneration = '', generationError = '';
try { libraryGeneration = readGeneration(); } catch (error) { generationError = error.message; }
// After a reset, even a still-open pre-upgrade tab can only write to an
// obsolete namespace. Never migrate those legacy values into the new library.
const storage = {
get: (key, fallback) => rawStorage.get(scopedKey(key, libraryGeneration), fallback),
set: (key, value) => rawStorage.set(scopedKey(key, libraryGeneration), value)
};
let rawConfig; let rawConfig;
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 : {}) };
@@ -2393,9 +2427,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
// Every new page starts completely hidden, including upgrades from older settings. // Every new page starts completely hidden, including upgrades from older settings.
config.mode = 'hidden'; config.mode = 'hidden';
config.keepLauncher = false; config.keepLauncher = false;
if (generationError) config.collecting = false;
let records = []; let records = [];
let recordsDirty = false; let recordsDirty = false;
let loadError = ''; let loadError = generationError;
let cleanupNotice = ''; let cleanupNotice = '';
let storedRecordsText = ''; let storedRecordsText = '';
let saveIdle = null; let saveIdle = null;
@@ -2506,7 +2541,9 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
imageReplacementSignature(replacement, false) === item.replacementContentSignature)))); imageReplacementSignature(replacement, false) === item.replacementContentSignature))));
}); });
} }
function loadRecords() {
try { try {
if (generationError) throw new Error(generationError);
const saved = storage.get(RECORDS_KEY, []); const saved = storage.get(RECORDS_KEY, []);
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确'); if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
storedRecordsText = JSON.stringify(saved); storedRecordsText = JSON.stringify(saved);
@@ -2531,21 +2568,25 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
cleanupNotice += `${cleanupNotice ? ' ' : ''}已核验 ${judgeRepair.changed.length} 条判断题答案,缺少标准答案依据的记录已改为待补,原值已独立备份。`; cleanupNotice += `${cleanupNotice ? ' ' : ''}已核验 ${judgeRepair.changed.length} 条判断题答案,缺少标准答案依据的记录已改为待补,原值已独立备份。`;
} }
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; } } catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
}
loadRecords();
let root, shadow, panel, launcher, resultBox, statusBox; let root, shadow, panel, launcher, resultBox, statusBox;
let networkEnabled = ['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(window.location.hostname); 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 saveTimer, searchTimer, query = '', scope = networkEnabled ? '__current__' : '', searchScope = scope, searchField = 'all';
let activeBank = null, importing = false; let activeBank = null, importing = false, importAttempt = 0;
let status = loadError || cleanupNotice || '可输入文字或划选题干检索。'; let status = loadError || cleanupNotice || '可输入文字或划选题干检索。';
let storageError = false; let storageError = false;
let mounted = false; let mounted = false;
let recordsListener = null;
let collector = null, collectorInitError = ''; let collector = null, collectorInitError = '';
const createAdapter = () => QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged, observeNetwork: networkEnabled }); const createAdapter = () => QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged, observeNetwork: networkEnabled });
let adapter = createAdapter(); let adapter = createAdapter();
const autoAnswer = QBAuto.create({ window: pageWindow, document, core: QBCore, getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank, const getLiveRecords = () => { synchronizeReset(); return records; };
const autoAnswer = QBAuto.create({ window: pageWindow, document, core: QBCore, getRecords: getLiveRecords, getQuestion: currentQuestion, getBank: currentBank,
onStatus: message => { tell(message); }, onChange: () => { renderAuto(); } }); onStatus: message => { tell(message); }, onChange: () => { renderAuto(); } });
try { try {
if (typeof QBCollector !== 'undefined') collector = QBCollector.create({ window: pageWindow, document, core: QBCore, if (typeof QBCollector !== 'undefined') collector = QBCollector.create({ window: pageWindow, document, core: QBCore,
getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank, getRecords: getLiveRecords, getQuestion: currentQuestion, getBank: currentBank,
isCollecting: () => networkEnabled && config.collecting && !loadError && !storageError && !importing, isCollecting: () => networkEnabled && config.collecting && !loadError && !storageError && !importing,
onChange: () => { if (mounted && config.mode === 'open' && config.tab === 'auto') renderAuto(); } }); onChange: () => { if (mounted && config.mode === 'open' && config.tab === 'auto') renderAuto(); } });
} catch (error) { collectorInitError = `练习补齐模块加载失败:${error.message}`; } } catch (error) { collectorInitError = `练习补齐模块加载失败:${error.message}`; }
@@ -2614,14 +2655,96 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
const message = typeof value === 'string' ? value : value && value.message; const message = typeof value === 'string' ? value : value && value.message;
if (message && !storageError) { status = message; renderStatus(); } if (message && !storageError) { status = message; renderStatus(); }
} }
function cancelPendingSave() {
clearTimeout(saveTimer);
saveTimer = null;
if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; }
}
function adoptGeneration(generation) {
cancelPendingSave();
stopCollector('题库已重置,练习补齐已停止。');
autoAnswer.setEnabled(false);
config.collecting = false;
if (adapter.clearCache) adapter.clearCache();
else adapter.stop();
libraryGeneration = generation;
generationError = ''; loadError = ''; storageError = false; cleanupNotice = '';
records = []; recordsDirty = false; storedRecordsText = '';
importAttempt++; importing = false;
clearTimeout(searchTimer);
query = ''; scope = networkEnabled ? '__current__' : ''; searchScope = scope; searchField = 'all';
if (shadow) {
shadow.getElementById('qb-search').value = '';
shadow.getElementById('qb-search-field').value = 'all';
shadow.getElementById('qb-import-file').value = '';
shadow.querySelector('[data-action="import"]').disabled = false;
}
loadRecords();
watchRecords();
if (recordsDirty && !loadError) schedulePersist();
tell(loadError || '题库已重置并同步,采集与自动答题已关闭。请重新导入可信题库。', Boolean(loadError));
renderVisible(); renderAuto();
}
function synchronizeReset() {
try {
const generation = readGeneration();
if (generation === libraryGeneration && !generationError) return true;
adoptGeneration(generation);
} catch (error) {
generationError = error.message;
loadError = `题库重置状态读取失败:${error.message}`;
cancelPendingSave();
config.collecting = false;
stopCollector('题库重置状态异常,练习补齐已停止。');
autoAnswer.setEnabled(false); adapter.stop();
tell(loadError, true);
}
return false;
}
function resetLibrary() {
synchronizeReset();
if (!window.confirm(`确定重置全部本地题库吗?\n\n将清空全部 ${records.length} 道题、答案、冲突及历史修复备份,并暂停采集和自动答题。\n如需保留,请先取消并导出 JSON 备份。重置后可重新导入可信题库。`)) return;
const previousGeneration = libraryGeneration;
const generation = window.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const nextConfig = { ...config, collecting: false };
const staged = [scopedKey(RECORDS_KEY, generation), scopedKey(CONFIG_KEY, generation)];
try {
// Prepare and verify an empty library before publishing the reset marker.
// Failure here leaves the existing library and all its backups untouched.
rawStorage.set(staged[0], []);
rawStorage.set(staged[1], nextConfig);
if (JSON.stringify(rawStorage.get(staged[0], null)) !== '[]' ||
JSON.stringify(rawStorage.get(staged[1], null)) !== JSON.stringify(nextConfig)) throw new Error('空题库未能完整保存');
rawStorage.set(RESET_KEY, { schemaVersion: 1, generation, resetAt: new Date().toISOString() });
if (readGeneration() !== generation) throw new Error('重置状态未能保存或已被另一标签页更新');
} catch (error) {
// A storage provider may throw after writing; follow the published marker
// in that case, rather than letting this tab keep saving pre-reset data.
const unchanged = synchronizeReset();
if (unchanged) for (const key of staged) { try { rawStorage.remove(key); } catch (_) {} }
tell(`重置未完成:${error.message}。请检查油猴存储后重试。`, true);
return;
}
adoptGeneration(generation);
const obsolete = new Set([...LIBRARY_KEYS, CONFIG_KEY].flatMap(key => [key, scopedKey(key, previousGeneration)]));
let cleanupFailed = false;
for (const key of obsolete) {
try {
rawStorage.remove(key);
if (rawStorage.get(key, null) !== null) cleanupFailed = true;
} catch (_) { cleanupFailed = true; }
}
tell(cleanupFailed ? '题库已重置;部分旧存储清理失败,已隔离且不会再读取。请检查油猴存储。' :
'已清空全部题库及历史修复备份,采集与自动答题已关闭。请重新导入可信题库。', cleanupFailed);
}
function persistConfig() { function persistConfig() {
if (!synchronizeReset()) return;
try { storage.set(CONFIG_KEY, { ...config }); } try { storage.set(CONFIG_KEY, { ...config }); }
catch (error) { tell(`设置保存失败:${error.message}`, true); } catch (error) { tell(`设置保存失败:${error.message}`, true); }
} }
function persistRecords() { function persistRecords() {
clearTimeout(saveTimer); if (!synchronizeReset()) return;
saveTimer = null; cancelPendingSave();
if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; }
if (loadError || !recordsDirty) 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.
@@ -2676,6 +2799,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
JSON.stringify(record.options.map(option => flat(option.text)).sort()); JSON.stringify(record.options.map(option => flat(option.text)).sort());
} }
function receive(incoming, context = {}) { function receive(incoming, context = {}) {
if (!synchronizeReset()) return;
if (!Array.isArray(incoming) || !incoming.length || loadError) return; if (!Array.isArray(incoming) || !incoming.length || loadError) return;
try { try {
incoming = incoming.filter(record => !redundantDOMObservation(record)); incoming = incoming.filter(record => !redundantDOMObservation(record));
@@ -2971,6 +3095,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
}); });
} }
function runSearch(text) { function runSearch(text) {
if (!synchronizeReset()) return;
query = String(text || '').trim().slice(0, 4000); query = String(text || '').trim().slice(0, 4000);
if (shadow) shadow.getElementById('qb-search').value = query; if (shadow) shadow.getElementById('qb-search').value = query;
renderResults(); renderResults();
@@ -2995,6 +3120,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
return `${safeFilename(name)}_${count}题_${parts.year}${parts.month}${parts.day}.${format}`; return `${safeFilename(name)}_${count}题_${parts.year}${parts.month}${parts.day}.${format}`;
} }
function exportData(format) { function exportData(format) {
if (!synchronizeReset()) return;
const selected = scopedRecords(); const selected = scopedRecords();
if (!selected.length) return tell('所选范围还没有题目,请先采集或导入。'); if (!selected.length) return tell('所选范围还没有题目,请先采集或导入。');
const date = new Date(); const date = new Date();
@@ -3005,24 +3131,28 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
tell(`已导出「${bankName}」${selected.length} 道题,其中 ${selected.filter(record => record.answerStatus === 'confirmed').length} 道有已确认答案。`); tell(`已导出「${bankName}」${selected.length} 道题,其中 ${selected.filter(record => record.answerStatus === 'confirmed').length} 道有已确认答案。`);
} }
function exportCleanupBackup() { function exportCleanupBackup() {
if (!synchronizeReset()) return;
const backup = storage.get(BACKUP_KEY, null); const backup = storage.get(BACKUP_KEY, null);
if (!backup || !Array.isArray(backup.records)) return tell('没有需要恢复的整理前备份。'); if (!backup || !Array.isArray(backup.records)) return tell('没有需要恢复的整理前备份。');
download(JSON.stringify(backup, null, 2), exportFilename('题库整理前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8'); download(JSON.stringify(backup, null, 2), exportFilename('题库整理前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
tell(`已导出整理前的 ${backup.records.length} 条原始记录。`); tell(`已导出整理前的 ${backup.records.length} 条原始记录。`);
} }
function exportOptionRepairBackup() { function exportOptionRepairBackup() {
if (!synchronizeReset()) return;
const backup = storage.get(OPTION_REPAIR_BACKUP_KEY, null); const backup = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
if (!backup || !Array.isArray(backup.records)) return tell('没有答案误报修复前的备份。'); if (!backup || !Array.isArray(backup.records)) return tell('没有答案误报修复前的备份。');
download(JSON.stringify(backup, null, 2), exportFilename('答案误报修复前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8'); download(JSON.stringify(backup, null, 2), exportFilename('答案误报修复前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
tell(`已导出答案误报修复前的 ${backup.records.length} 条原始记录。`); tell(`已导出答案误报修复前的 ${backup.records.length} 条原始记录。`);
} }
function exportImageUpgradeBackup() { function exportImageUpgradeBackup() {
if (!synchronizeReset()) return;
const backup = imageUpgradeBackup(); const backup = imageUpgradeBackup();
if (!backup) return tell('没有内容补全前的备份。'); if (!backup) return tell('没有内容补全前的备份。');
download(JSON.stringify(backup, null, 2), exportFilename('内容补全前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8'); download(JSON.stringify(backup, null, 2), exportFilename('内容补全前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
tell(`已导出内容补全前的 ${backup.records.length} 条原始记录。`); tell(`已导出内容补全前的 ${backup.records.length} 条原始记录。`);
} }
function exportJudgeEvidenceBackup() { function exportJudgeEvidenceBackup() {
if (!synchronizeReset()) return;
const backup = storage.get(JUDGE_EVIDENCE_BACKUP_KEY, null); const backup = storage.get(JUDGE_EVIDENCE_BACKUP_KEY, null);
if (!backup || backup.schemaVersion !== 1 || !Array.isArray(backup.records)) return tell('没有判断题答案核验前的备份。'); if (!backup || backup.schemaVersion !== 1 || !Array.isArray(backup.records)) return tell('没有判断题答案核验前的备份。');
download(JSON.stringify(backup, null, 2), exportFilename('判断题答案核验前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8'); download(JSON.stringify(backup, null, 2), exportFilename('判断题答案核验前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
@@ -3050,6 +3180,8 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
} }
async function importFiles(files) { async function importFiles(files) {
if (!files.length) return; if (!files.length) return;
if (!synchronizeReset()) throw new Error('题库已重置,请重新选择导入文件');
const importGeneration = libraryGeneration;
if (loadError) throw new Error('现有本地题库异常,为保留原数据已停止写入。请先从油猴存储备份原数据'); if (loadError) throw new Error('现有本地题库异常,为保留原数据已停止写入。请先从油猴存储备份原数据');
if (files.reduce((sum, file) => sum + file.size, 0) > 50 * 1024 * 1024) throw new Error('本次文件合计超过 50 MB,请分批导入'); if (files.reduce((sum, file) => sum + file.size, 0) > 50 * 1024 * 1024) throw new Error('本次文件合计超过 50 MB,请分批导入');
const incoming = []; const incoming = [];
@@ -3057,6 +3189,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
for (const file of files) { for (const file of files) {
try { incoming.push(...await parseImportFile(file)); } try { incoming.push(...await parseImportFile(file)); }
catch (error) { throw new Error(`「${file.name}」:${error.message}`); } catch (error) { throw new Error(`「${file.name}」:${error.message}`); }
if (!synchronizeReset() || importGeneration !== libraryGeneration) throw new Error('题库已重置,本次导入已取消,请重新选择文件');
if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入'); if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入');
} }
const before = records.length; const before = records.length;
@@ -3105,7 +3238,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
<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" class="muted-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></div> <div class="section"><div class="row spread"><h3>当前练习</h3><span class="state" id="qb-capture-state">采集中</span></div><p id="qb-current-bank" class="muted-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></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 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 id="qb-library-summary"></p><div class="bank-list" id="qb-bank-list"></div><button type="button" class="btn small" data-action="reset-library">重置全部题库</button></div>
</div> </div>
<div class="view" id="qb-view-auto" data-view="auto" role="tabpanel" hidden> <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><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>
@@ -3113,6 +3246,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
<div class="section"><h3>适配范围</h3><p>支持华谊练习页面,以及使用标准单选框/复选框和明确题干容器的网页。网页未识别时可继续使用文字搜索。</p></div> <div class="section"><h3>适配范围</h3><p>支持华谊练习页面,以及使用标准单选框/复选框和明确题干容器的网页。网页未识别时可继续使用文字搜索。</p></div>
</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>清空全部题库、答案、冲突及历史修复备份,同时暂停采集并关闭自动答题。重置前如需保留,请先导出 JSON;重置后重新导入可信题库。</p><button type="button" class="btn small" data-action="reset-library">重置全部题库</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><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>
@@ -3164,6 +3298,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
case 'export-option-repair-backup': exportOptionRepairBackup(); break; case 'export-option-repair-backup': exportOptionRepairBackup(); break;
case 'export-image-upgrade-backup': exportImageUpgradeBackup(); break; case 'export-image-upgrade-backup': exportImageUpgradeBackup(); break;
case 'export-judge-evidence-backup': exportJudgeEvidenceBackup(); break; case 'export-judge-evidence-backup': exportJudgeEvidenceBackup(); break;
case 'reset-library': resetLibrary(); break;
case 'import': shadow.getElementById('qb-import-file').click(); break; case 'import': shadow.getElementById('qb-import-file').click(); break;
} }
} catch (error) { tell(`操作失败:${error.message}`, true); } } catch (error) { tell(`操作失败:${error.message}`, true); }
@@ -3188,13 +3323,14 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
const input = event.currentTarget; const input = event.currentTarget;
if (importing) return; if (importing) return;
const files = Array.from(input.files || []); const files = Array.from(input.files || []);
const attempt = ++importAttempt;
importing = true; importing = true;
stopCollector('开始导入题库,练习补齐已停止。'); stopCollector('开始导入题库,练习补齐已停止。');
const button = shadow.querySelector('[data-action="import"]'); const button = shadow.querySelector('[data-action="import"]');
button.disabled = true; button.disabled = true;
try { await importFiles(files); } try { await importFiles(files); }
catch (error) { tell(`导入失败,题库未替换:${error.message}`, true); } catch (error) { if (attempt === importAttempt) tell(`导入失败,题库未替换:${error.message}`, true); }
finally { input.value = ''; importing = false; button.disabled = false; } finally { if (attempt === importAttempt) { input.value = ''; importing = false; button.disabled = false; } }
}); });
if (cleanupNotice) status = cleanupNotice; if (cleanupNotice) status = cleanupNotice;
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode(); refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode();
@@ -3223,18 +3359,22 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
}, true); }, true);
window.addEventListener('pagehide', () => { stopCollector('页面已离开,练习补齐已停止。'); autoAnswer.setEnabled(false); persistRecords(); }); window.addEventListener('pagehide', () => { stopCollector('页面已离开,练习补齐已停止。'); autoAnswer.setEnabled(false); persistRecords(); });
window.addEventListener('pageshow', event => { window.addEventListener('pageshow', event => {
synchronizeReset();
if (event.persisted) { stopCollector('页面已恢复,练习补齐保持关闭。'); autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); } if (event.persisted) { stopCollector('页面已恢复,练习补齐保持关闭。'); autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); }
}); });
window.addEventListener('focus', synchronizeReset);
document.addEventListener('visibilitychange', () => { if (!document.hidden) synchronizeReset(); });
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('重置全部本地题库', resetLibrary);
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup); GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup); GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup);
GM_registerMenuCommand('导出内容补全前原始备份', exportImageUpgradeBackup); GM_registerMenuCommand('导出内容补全前原始备份', exportImageUpgradeBackup);
GM_registerMenuCommand('导出判断题答案核验前原始备份', exportJudgeEvidenceBackup); GM_registerMenuCommand('导出判断题答案核验前原始备份', exportJudgeEvidenceBackup);
} }
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => { function mergeRemoteRecords(value) {
if (!remote || !Array.isArray(value)) return; if (!Array.isArray(value)) return;
try { try {
const valueText = JSON.stringify(value); const valueText = JSON.stringify(value);
if (valueText === storedRecordsText) return; if (valueText === storedRecordsText) return;
@@ -3247,6 +3387,27 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto
renderVisible(); renderVisible();
} }
catch (error) { tell(`跨标签题库未合并:${error.message}。当前内存记录仍保留。`, true); } catch (error) { tell(`跨标签题库未合并:${error.message}。当前内存记录仍保留。`, true); }
}
function watchRecords() {
if (recordsListener !== null && typeof GM_removeValueChangeListener === 'function') GM_removeValueChangeListener(recordsListener);
if (typeof GM_addValueChangeListener !== 'function') return;
const generation = libraryGeneration;
recordsListener = GM_addValueChangeListener(scopedKey(RECORDS_KEY, generation), (_key, _old, value, remote) => {
if (remote && synchronizeReset() && generation === libraryGeneration) mergeRemoteRecords(value);
});
}
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RESET_KEY, (_key, _old, _value, remote) => {
if (remote) synchronizeReset();
});
watchRecords();
// The fallback uses origin-local storage; GM storage remains shared across
// supported sites. No polling is needed for either reset notification path.
window.addEventListener('storage', event => {
if (event.key === RESET_KEY) synchronizeReset();
else if (event.key === scopedKey(RECORDS_KEY, libraryGeneration) && synchronizeReset()) {
try { mergeRemoteRecords(event.newValue === null ? [] : JSON.parse(event.newValue)); }
catch (error) { tell(`跨标签题库读取失败:${error.message}`, true); }
}
}); });
if (config.collecting) adapter.start(); if (config.collecting) adapter.start();
adapter.setCopyEnabled(Boolean(config.copyEnabled)); adapter.setCopyEnabled(Boolean(config.copyEnabled));
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "huayi-question-bank", "name": "huayi-question-bank",
"version": "1.2.7", "version": "1.2.8",
"private": true, "private": true,
"description": "华谊练习题库本地采集、CSV 导出与划词检索油猴脚本", "description": "华谊练习题库本地采集、CSV 导出与划词检索油猴脚本",
"scripts": { "scripts": {
@@ -8,7 +8,7 @@
"merge:banks": "node scripts/merge-question-banks.cjs", "merge:banks": "node scripts/merge-question-banks.cjs",
"select:snapshots": "node scripts/select-bank-snapshots.cjs", "select:snapshots": "node scripts/select-bank-snapshots.cjs",
"test": "node --test tests/core.test.cjs tests/adapter.test.cjs tests/autofill.test.cjs tests/collector.test.cjs tests/select-bank-snapshots.test.cjs", "test": "node --test tests/core.test.cjs tests/adapter.test.cjs tests/autofill.test.cjs tests/collector.test.cjs tests/select-bank-snapshots.test.cjs",
"test:browser": "node --test tests/integration.test.cjs", "test:browser": "node --test tests/integration.test.cjs tests/reset-browser.test.cjs",
"test:performance": "node --test tests/performance-browser.test.cjs", "test:performance": "node --test tests/performance-browser.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"
} }
+9 -1
View File
@@ -817,7 +817,15 @@ const QBAdapter = (() => {
status('采集已暂停,已保存题库和选择复制功能仍可使用。'); status('采集已暂停,已保存题库和选择复制功能仍可使用。');
} }
return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled, isSupportedPage }; function clearCache() {
stop();
known.clear(); knownContentIndex = null;
banksById.clear(); banksByTask.clear(); knownSnapshotOrder.clear();
currentBank = null; pendingSelection = ''; blockedForm = null; lastFormSnapshot = null;
lastDOMSignature = '';
}
return { start, stop, clearCache, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled, isSupportedPage };
} }
return { create, extractQuestions, richText }; return { create, extractQuestions, richText };
+164 -13
View File
@@ -6,9 +6,11 @@
const OPTION_REPAIR_BACKUP_KEY = 'huayi-qb.records.before-duplicate-option-repair.v1'; const OPTION_REPAIR_BACKUP_KEY = 'huayi-qb.records.before-duplicate-option-repair.v1';
const IMAGE_UPGRADE_BACKUP_KEY = 'huayi-qb.records.before-image-upgrade.v1'; const IMAGE_UPGRADE_BACKUP_KEY = 'huayi-qb.records.before-image-upgrade.v1';
const JUDGE_EVIDENCE_BACKUP_KEY = 'huayi-qb.records.before-judge-evidence-repair.v1'; const JUDGE_EVIDENCE_BACKUP_KEY = 'huayi-qb.records.before-judge-evidence-repair.v1';
const RESET_KEY = 'huayi-qb.library-reset.v1';
const LIBRARY_KEYS = [RECORDS_KEY, BACKUP_KEY, OPTION_REPAIR_BACKUP_KEY, IMAGE_UPGRADE_BACKUP_KEY, JUDGE_EVIDENCE_BACKUP_KEY];
const DEFAULTS = { mode: 'hidden', keepLauncher: false, 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 rawStorage = {
get(key, fallback) { get(key, fallback) {
if (typeof GM_getValue === 'function') return GM_getValue(key, fallback); if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
const raw = localStorage.getItem(key); const raw = localStorage.getItem(key);
@@ -17,8 +19,30 @@
set(key, value) { set(key, value) {
if (typeof GM_setValue === 'function') return GM_setValue(key, value); if (typeof GM_setValue === 'function') return GM_setValue(key, value);
localStorage.setItem(key, JSON.stringify(value)); localStorage.setItem(key, JSON.stringify(value));
},
remove(key) {
if (typeof GM_deleteValue === 'function') return GM_deleteValue(key);
if (typeof GM_setValue === 'function') return GM_setValue(key, null);
localStorage.removeItem(key);
} }
}; };
function readGeneration() {
const marker = rawStorage.get(RESET_KEY, null);
if (marker === null) return '';
if (marker?.schemaVersion !== 1 || typeof marker.generation !== 'string' || !/^[a-zA-Z0-9-]{1,100}$/.test(marker.generation)) {
throw new Error('题库重置标记异常,请使用“重置全部题库”重新初始化');
}
return marker.generation;
}
const scopedKey = (key, generation) => generation ? `${key}.reset.${generation}` : key;
let libraryGeneration = '', generationError = '';
try { libraryGeneration = readGeneration(); } catch (error) { generationError = error.message; }
// After a reset, even a still-open pre-upgrade tab can only write to an
// obsolete namespace. Never migrate those legacy values into the new library.
const storage = {
get: (key, fallback) => rawStorage.get(scopedKey(key, libraryGeneration), fallback),
set: (key, value) => rawStorage.set(scopedKey(key, libraryGeneration), value)
};
let rawConfig; let rawConfig;
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 : {}) };
@@ -28,9 +52,10 @@
// Every new page starts completely hidden, including upgrades from older settings. // Every new page starts completely hidden, including upgrades from older settings.
config.mode = 'hidden'; config.mode = 'hidden';
config.keepLauncher = false; config.keepLauncher = false;
if (generationError) config.collecting = false;
let records = []; let records = [];
let recordsDirty = false; let recordsDirty = false;
let loadError = ''; let loadError = generationError;
let cleanupNotice = ''; let cleanupNotice = '';
let storedRecordsText = ''; let storedRecordsText = '';
let saveIdle = null; let saveIdle = null;
@@ -141,7 +166,9 @@
imageReplacementSignature(replacement, false) === item.replacementContentSignature)))); imageReplacementSignature(replacement, false) === item.replacementContentSignature))));
}); });
} }
function loadRecords() {
try { try {
if (generationError) throw new Error(generationError);
const saved = storage.get(RECORDS_KEY, []); const saved = storage.get(RECORDS_KEY, []);
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确'); if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
storedRecordsText = JSON.stringify(saved); storedRecordsText = JSON.stringify(saved);
@@ -166,21 +193,25 @@
cleanupNotice += `${cleanupNotice ? ' ' : ''}已核验 ${judgeRepair.changed.length} 条判断题答案,缺少标准答案依据的记录已改为待补,原值已独立备份。`; cleanupNotice += `${cleanupNotice ? ' ' : ''}已核验 ${judgeRepair.changed.length} 条判断题答案,缺少标准答案依据的记录已改为待补,原值已独立备份。`;
} }
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; } } catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
}
loadRecords();
let root, shadow, panel, launcher, resultBox, statusBox; let root, shadow, panel, launcher, resultBox, statusBox;
let networkEnabled = ['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(window.location.hostname); 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 saveTimer, searchTimer, query = '', scope = networkEnabled ? '__current__' : '', searchScope = scope, searchField = 'all';
let activeBank = null, importing = false; let activeBank = null, importing = false, importAttempt = 0;
let status = loadError || cleanupNotice || '可输入文字或划选题干检索。'; let status = loadError || cleanupNotice || '可输入文字或划选题干检索。';
let storageError = false; let storageError = false;
let mounted = false; let mounted = false;
let recordsListener = null;
let collector = null, collectorInitError = ''; let collector = null, collectorInitError = '';
const createAdapter = () => QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged, observeNetwork: networkEnabled }); const createAdapter = () => QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged, observeNetwork: networkEnabled });
let adapter = createAdapter(); let adapter = createAdapter();
const autoAnswer = QBAuto.create({ window: pageWindow, document, core: QBCore, getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank, const getLiveRecords = () => { synchronizeReset(); return records; };
const autoAnswer = QBAuto.create({ window: pageWindow, document, core: QBCore, getRecords: getLiveRecords, getQuestion: currentQuestion, getBank: currentBank,
onStatus: message => { tell(message); }, onChange: () => { renderAuto(); } }); onStatus: message => { tell(message); }, onChange: () => { renderAuto(); } });
try { try {
if (typeof QBCollector !== 'undefined') collector = QBCollector.create({ window: pageWindow, document, core: QBCore, if (typeof QBCollector !== 'undefined') collector = QBCollector.create({ window: pageWindow, document, core: QBCore,
getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank, getRecords: getLiveRecords, getQuestion: currentQuestion, getBank: currentBank,
isCollecting: () => networkEnabled && config.collecting && !loadError && !storageError && !importing, isCollecting: () => networkEnabled && config.collecting && !loadError && !storageError && !importing,
onChange: () => { if (mounted && config.mode === 'open' && config.tab === 'auto') renderAuto(); } }); onChange: () => { if (mounted && config.mode === 'open' && config.tab === 'auto') renderAuto(); } });
} catch (error) { collectorInitError = `练习补齐模块加载失败:${error.message}`; } } catch (error) { collectorInitError = `练习补齐模块加载失败:${error.message}`; }
@@ -249,14 +280,96 @@
const message = typeof value === 'string' ? value : value && value.message; const message = typeof value === 'string' ? value : value && value.message;
if (message && !storageError) { status = message; renderStatus(); } if (message && !storageError) { status = message; renderStatus(); }
} }
function cancelPendingSave() {
clearTimeout(saveTimer);
saveTimer = null;
if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; }
}
function adoptGeneration(generation) {
cancelPendingSave();
stopCollector('题库已重置,练习补齐已停止。');
autoAnswer.setEnabled(false);
config.collecting = false;
if (adapter.clearCache) adapter.clearCache();
else adapter.stop();
libraryGeneration = generation;
generationError = ''; loadError = ''; storageError = false; cleanupNotice = '';
records = []; recordsDirty = false; storedRecordsText = '';
importAttempt++; importing = false;
clearTimeout(searchTimer);
query = ''; scope = networkEnabled ? '__current__' : ''; searchScope = scope; searchField = 'all';
if (shadow) {
shadow.getElementById('qb-search').value = '';
shadow.getElementById('qb-search-field').value = 'all';
shadow.getElementById('qb-import-file').value = '';
shadow.querySelector('[data-action="import"]').disabled = false;
}
loadRecords();
watchRecords();
if (recordsDirty && !loadError) schedulePersist();
tell(loadError || '题库已重置并同步,采集与自动答题已关闭。请重新导入可信题库。', Boolean(loadError));
renderVisible(); renderAuto();
}
function synchronizeReset() {
try {
const generation = readGeneration();
if (generation === libraryGeneration && !generationError) return true;
adoptGeneration(generation);
} catch (error) {
generationError = error.message;
loadError = `题库重置状态读取失败:${error.message}`;
cancelPendingSave();
config.collecting = false;
stopCollector('题库重置状态异常,练习补齐已停止。');
autoAnswer.setEnabled(false); adapter.stop();
tell(loadError, true);
}
return false;
}
function resetLibrary() {
synchronizeReset();
if (!window.confirm(`确定重置全部本地题库吗?\n\n将清空全部 ${records.length} 道题、答案、冲突及历史修复备份,并暂停采集和自动答题。\n如需保留,请先取消并导出 JSON 备份。重置后可重新导入可信题库。`)) return;
const previousGeneration = libraryGeneration;
const generation = window.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const nextConfig = { ...config, collecting: false };
const staged = [scopedKey(RECORDS_KEY, generation), scopedKey(CONFIG_KEY, generation)];
try {
// Prepare and verify an empty library before publishing the reset marker.
// Failure here leaves the existing library and all its backups untouched.
rawStorage.set(staged[0], []);
rawStorage.set(staged[1], nextConfig);
if (JSON.stringify(rawStorage.get(staged[0], null)) !== '[]' ||
JSON.stringify(rawStorage.get(staged[1], null)) !== JSON.stringify(nextConfig)) throw new Error('空题库未能完整保存');
rawStorage.set(RESET_KEY, { schemaVersion: 1, generation, resetAt: new Date().toISOString() });
if (readGeneration() !== generation) throw new Error('重置状态未能保存或已被另一标签页更新');
} catch (error) {
// A storage provider may throw after writing; follow the published marker
// in that case, rather than letting this tab keep saving pre-reset data.
const unchanged = synchronizeReset();
if (unchanged) for (const key of staged) { try { rawStorage.remove(key); } catch (_) {} }
tell(`重置未完成:${error.message}。请检查油猴存储后重试。`, true);
return;
}
adoptGeneration(generation);
const obsolete = new Set([...LIBRARY_KEYS, CONFIG_KEY].flatMap(key => [key, scopedKey(key, previousGeneration)]));
let cleanupFailed = false;
for (const key of obsolete) {
try {
rawStorage.remove(key);
if (rawStorage.get(key, null) !== null) cleanupFailed = true;
} catch (_) { cleanupFailed = true; }
}
tell(cleanupFailed ? '题库已重置;部分旧存储清理失败,已隔离且不会再读取。请检查油猴存储。' :
'已清空全部题库及历史修复备份,采集与自动答题已关闭。请重新导入可信题库。', cleanupFailed);
}
function persistConfig() { function persistConfig() {
if (!synchronizeReset()) return;
try { storage.set(CONFIG_KEY, { ...config }); } try { storage.set(CONFIG_KEY, { ...config }); }
catch (error) { tell(`设置保存失败:${error.message}`, true); } catch (error) { tell(`设置保存失败:${error.message}`, true); }
} }
function persistRecords() { function persistRecords() {
clearTimeout(saveTimer); if (!synchronizeReset()) return;
saveTimer = null; cancelPendingSave();
if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; }
if (loadError || !recordsDirty) 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.
@@ -311,6 +424,7 @@
JSON.stringify(record.options.map(option => flat(option.text)).sort()); JSON.stringify(record.options.map(option => flat(option.text)).sort());
} }
function receive(incoming, context = {}) { function receive(incoming, context = {}) {
if (!synchronizeReset()) return;
if (!Array.isArray(incoming) || !incoming.length || loadError) return; if (!Array.isArray(incoming) || !incoming.length || loadError) return;
try { try {
incoming = incoming.filter(record => !redundantDOMObservation(record)); incoming = incoming.filter(record => !redundantDOMObservation(record));
@@ -606,6 +720,7 @@
}); });
} }
function runSearch(text) { function runSearch(text) {
if (!synchronizeReset()) return;
query = String(text || '').trim().slice(0, 4000); query = String(text || '').trim().slice(0, 4000);
if (shadow) shadow.getElementById('qb-search').value = query; if (shadow) shadow.getElementById('qb-search').value = query;
renderResults(); renderResults();
@@ -630,6 +745,7 @@
return `${safeFilename(name)}_${count}题_${parts.year}${parts.month}${parts.day}.${format}`; return `${safeFilename(name)}_${count}题_${parts.year}${parts.month}${parts.day}.${format}`;
} }
function exportData(format) { function exportData(format) {
if (!synchronizeReset()) return;
const selected = scopedRecords(); const selected = scopedRecords();
if (!selected.length) return tell('所选范围还没有题目,请先采集或导入。'); if (!selected.length) return tell('所选范围还没有题目,请先采集或导入。');
const date = new Date(); const date = new Date();
@@ -640,24 +756,28 @@
tell(`已导出「${bankName}」${selected.length} 道题,其中 ${selected.filter(record => record.answerStatus === 'confirmed').length} 道有已确认答案。`); tell(`已导出「${bankName}」${selected.length} 道题,其中 ${selected.filter(record => record.answerStatus === 'confirmed').length} 道有已确认答案。`);
} }
function exportCleanupBackup() { function exportCleanupBackup() {
if (!synchronizeReset()) return;
const backup = storage.get(BACKUP_KEY, null); const backup = storage.get(BACKUP_KEY, null);
if (!backup || !Array.isArray(backup.records)) return tell('没有需要恢复的整理前备份。'); if (!backup || !Array.isArray(backup.records)) return tell('没有需要恢复的整理前备份。');
download(JSON.stringify(backup, null, 2), exportFilename('题库整理前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8'); download(JSON.stringify(backup, null, 2), exportFilename('题库整理前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
tell(`已导出整理前的 ${backup.records.length} 条原始记录。`); tell(`已导出整理前的 ${backup.records.length} 条原始记录。`);
} }
function exportOptionRepairBackup() { function exportOptionRepairBackup() {
if (!synchronizeReset()) return;
const backup = storage.get(OPTION_REPAIR_BACKUP_KEY, null); const backup = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
if (!backup || !Array.isArray(backup.records)) return tell('没有答案误报修复前的备份。'); if (!backup || !Array.isArray(backup.records)) return tell('没有答案误报修复前的备份。');
download(JSON.stringify(backup, null, 2), exportFilename('答案误报修复前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8'); download(JSON.stringify(backup, null, 2), exportFilename('答案误报修复前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
tell(`已导出答案误报修复前的 ${backup.records.length} 条原始记录。`); tell(`已导出答案误报修复前的 ${backup.records.length} 条原始记录。`);
} }
function exportImageUpgradeBackup() { function exportImageUpgradeBackup() {
if (!synchronizeReset()) return;
const backup = imageUpgradeBackup(); const backup = imageUpgradeBackup();
if (!backup) return tell('没有内容补全前的备份。'); if (!backup) return tell('没有内容补全前的备份。');
download(JSON.stringify(backup, null, 2), exportFilename('内容补全前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8'); download(JSON.stringify(backup, null, 2), exportFilename('内容补全前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
tell(`已导出内容补全前的 ${backup.records.length} 条原始记录。`); tell(`已导出内容补全前的 ${backup.records.length} 条原始记录。`);
} }
function exportJudgeEvidenceBackup() { function exportJudgeEvidenceBackup() {
if (!synchronizeReset()) return;
const backup = storage.get(JUDGE_EVIDENCE_BACKUP_KEY, null); const backup = storage.get(JUDGE_EVIDENCE_BACKUP_KEY, null);
if (!backup || backup.schemaVersion !== 1 || !Array.isArray(backup.records)) return tell('没有判断题答案核验前的备份。'); if (!backup || backup.schemaVersion !== 1 || !Array.isArray(backup.records)) return tell('没有判断题答案核验前的备份。');
download(JSON.stringify(backup, null, 2), exportFilename('判断题答案核验前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8'); download(JSON.stringify(backup, null, 2), exportFilename('判断题答案核验前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
@@ -685,6 +805,8 @@
} }
async function importFiles(files) { async function importFiles(files) {
if (!files.length) return; if (!files.length) return;
if (!synchronizeReset()) throw new Error('题库已重置,请重新选择导入文件');
const importGeneration = libraryGeneration;
if (loadError) throw new Error('现有本地题库异常,为保留原数据已停止写入。请先从油猴存储备份原数据'); if (loadError) throw new Error('现有本地题库异常,为保留原数据已停止写入。请先从油猴存储备份原数据');
if (files.reduce((sum, file) => sum + file.size, 0) > 50 * 1024 * 1024) throw new Error('本次文件合计超过 50 MB,请分批导入'); if (files.reduce((sum, file) => sum + file.size, 0) > 50 * 1024 * 1024) throw new Error('本次文件合计超过 50 MB,请分批导入');
const incoming = []; const incoming = [];
@@ -692,6 +814,7 @@
for (const file of files) { for (const file of files) {
try { incoming.push(...await parseImportFile(file)); } try { incoming.push(...await parseImportFile(file)); }
catch (error) { throw new Error(`「${file.name}」:${error.message}`); } catch (error) { throw new Error(`「${file.name}」:${error.message}`); }
if (!synchronizeReset() || importGeneration !== libraryGeneration) throw new Error('题库已重置,本次导入已取消,请重新选择文件');
if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入'); if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入');
} }
const before = records.length; const before = records.length;
@@ -740,7 +863,7 @@
<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" class="muted-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></div> <div class="section"><div class="row spread"><h3>当前练习</h3><span class="state" id="qb-capture-state">采集中</span></div><p id="qb-current-bank" class="muted-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></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 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 id="qb-library-summary"></p><div class="bank-list" id="qb-bank-list"></div><button type="button" class="btn small" data-action="reset-library">重置全部题库</button></div>
</div> </div>
<div class="view" id="qb-view-auto" data-view="auto" role="tabpanel" hidden> <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><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>
@@ -748,6 +871,7 @@
<div class="section"><h3>适配范围</h3><p>支持华谊练习页面,以及使用标准单选框/复选框和明确题干容器的网页。网页未识别时可继续使用文字搜索。</p></div> <div class="section"><h3>适配范围</h3><p>支持华谊练习页面,以及使用标准单选框/复选框和明确题干容器的网页。网页未识别时可继续使用文字搜索。</p></div>
</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>清空全部题库、答案、冲突及历史修复备份,同时暂停采集并关闭自动答题。重置前如需保留,请先导出 JSON;重置后重新导入可信题库。</p><button type="button" class="btn small" data-action="reset-library">重置全部题库</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><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>
@@ -799,6 +923,7 @@
case 'export-option-repair-backup': exportOptionRepairBackup(); break; case 'export-option-repair-backup': exportOptionRepairBackup(); break;
case 'export-image-upgrade-backup': exportImageUpgradeBackup(); break; case 'export-image-upgrade-backup': exportImageUpgradeBackup(); break;
case 'export-judge-evidence-backup': exportJudgeEvidenceBackup(); break; case 'export-judge-evidence-backup': exportJudgeEvidenceBackup(); break;
case 'reset-library': resetLibrary(); break;
case 'import': shadow.getElementById('qb-import-file').click(); break; case 'import': shadow.getElementById('qb-import-file').click(); break;
} }
} catch (error) { tell(`操作失败:${error.message}`, true); } } catch (error) { tell(`操作失败:${error.message}`, true); }
@@ -823,13 +948,14 @@
const input = event.currentTarget; const input = event.currentTarget;
if (importing) return; if (importing) return;
const files = Array.from(input.files || []); const files = Array.from(input.files || []);
const attempt = ++importAttempt;
importing = true; importing = true;
stopCollector('开始导入题库,练习补齐已停止。'); stopCollector('开始导入题库,练习补齐已停止。');
const button = shadow.querySelector('[data-action="import"]'); const button = shadow.querySelector('[data-action="import"]');
button.disabled = true; button.disabled = true;
try { await importFiles(files); } try { await importFiles(files); }
catch (error) { tell(`导入失败,题库未替换:${error.message}`, true); } catch (error) { if (attempt === importAttempt) tell(`导入失败,题库未替换:${error.message}`, true); }
finally { input.value = ''; importing = false; button.disabled = false; } finally { if (attempt === importAttempt) { input.value = ''; importing = false; button.disabled = false; } }
}); });
if (cleanupNotice) status = cleanupNotice; if (cleanupNotice) status = cleanupNotice;
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode(); refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode();
@@ -858,18 +984,22 @@
}, true); }, true);
window.addEventListener('pagehide', () => { stopCollector('页面已离开,练习补齐已停止。'); autoAnswer.setEnabled(false); persistRecords(); }); window.addEventListener('pagehide', () => { stopCollector('页面已离开,练习补齐已停止。'); autoAnswer.setEnabled(false); persistRecords(); });
window.addEventListener('pageshow', event => { window.addEventListener('pageshow', event => {
synchronizeReset();
if (event.persisted) { stopCollector('页面已恢复,练习补齐保持关闭。'); autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); } if (event.persisted) { stopCollector('页面已恢复,练习补齐保持关闭。'); autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); }
}); });
window.addEventListener('focus', synchronizeReset);
document.addEventListener('visibilitychange', () => { if (!document.hidden) synchronizeReset(); });
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('重置全部本地题库', resetLibrary);
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup); GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup); GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup);
GM_registerMenuCommand('导出内容补全前原始备份', exportImageUpgradeBackup); GM_registerMenuCommand('导出内容补全前原始备份', exportImageUpgradeBackup);
GM_registerMenuCommand('导出判断题答案核验前原始备份', exportJudgeEvidenceBackup); GM_registerMenuCommand('导出判断题答案核验前原始备份', exportJudgeEvidenceBackup);
} }
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => { function mergeRemoteRecords(value) {
if (!remote || !Array.isArray(value)) return; if (!Array.isArray(value)) return;
try { try {
const valueText = JSON.stringify(value); const valueText = JSON.stringify(value);
if (valueText === storedRecordsText) return; if (valueText === storedRecordsText) return;
@@ -882,6 +1012,27 @@
renderVisible(); renderVisible();
} }
catch (error) { tell(`跨标签题库未合并:${error.message}。当前内存记录仍保留。`, true); } catch (error) { tell(`跨标签题库未合并:${error.message}。当前内存记录仍保留。`, true); }
}
function watchRecords() {
if (recordsListener !== null && typeof GM_removeValueChangeListener === 'function') GM_removeValueChangeListener(recordsListener);
if (typeof GM_addValueChangeListener !== 'function') return;
const generation = libraryGeneration;
recordsListener = GM_addValueChangeListener(scopedKey(RECORDS_KEY, generation), (_key, _old, value, remote) => {
if (remote && synchronizeReset() && generation === libraryGeneration) mergeRemoteRecords(value);
});
}
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RESET_KEY, (_key, _old, _value, remote) => {
if (remote) synchronizeReset();
});
watchRecords();
// The fallback uses origin-local storage; GM storage remains shared across
// supported sites. No polling is needed for either reset notification path.
window.addEventListener('storage', event => {
if (event.key === RESET_KEY) synchronizeReset();
else if (event.key === scopedKey(RECORDS_KEY, libraryGeneration) && synchronizeReset()) {
try { mergeRemoteRecords(event.newValue === null ? [] : JSON.parse(event.newValue)); }
catch (error) { tell(`跨标签题库读取失败:${error.message}`, true); }
}
}); });
if (config.collecting) adapter.start(); if (config.collecting) adapter.start();
adapter.setCopyEnabled(Boolean(config.copyEnabled)); adapter.setCopyEnabled(Boolean(config.copyEnabled));
+20
View File
@@ -833,6 +833,26 @@ test('DOM standard feedback carries bound or known trusted answer evidence', asy
page.adapter.stop(); page.adapter.stop();
}); });
test('library reset clears cached standards and rejects pre-reset answer receipts after capture resumes', async () => {
const page = bankPage(async () => new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }));
page.adapter.start();
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
page.state.feedback = [{ heading: '正确答案', answer: 'B' }];
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'confirmed');
const pending = new page.win.XMLHttpRequest();
pending.open('PUT', '/upm/bank-a/users/u1/answer');
pending.send(JSON.stringify({ answers: [{ quesId: 'q1', answer: ['a1'] }] }));
page.adapter.clearCache();
page.received.length = 0;
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'missing', '重置后裸DOM反馈不能继承旧缓存标准');
page.adapter.start();
const count = page.received.length;
pending.respond([{ answers: ['a2'] }]);
assert.equal(page.received.length, count, '重置前未完成的回执不得在重新采集后写回');
assert.equal(page.adapter.getCurrentQuestion().answerStatus, 'missing');
page.adapter.stop();
});
test('an older answer receipt cannot attach its answer to a newer same-id full snapshot', () => { test('an older answer receipt cannot attach its answer to a newer same-id full snapshot', () => {
const page = fakePage(); const page = fakePage();
page.adapter.start(); page.adapter.start();
+227
View File
@@ -0,0 +1,227 @@
'use strict';
// Exercise the built userscript with real Chromium and normal DOM controls.
// All requests are fulfilled locally; no account or production API is used.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const ROOT = path.resolve(__dirname, '..');
const RECORDS = 'huayi-qb.records.v1', CONFIG = 'huayi-qb.config.v1', RESET = 'huayi-qb.library-reset.v1';
const BACKUPS = ['before-dedup', 'before-duplicate-option-repair', 'before-image-upgrade', 'before-judge-evidence-repair']
.map(name => `huayi-qb.records.${name}.v1`);
const seed = [{ bankId: 'reset-bank', bankName: '旧导入题库', questionId: 'old-question', type: 'single', stem: '旧题库中的错误答案记录。',
options: [{ id: 'a', label: 'A', text: '旧选项一' }, { id: 'b', label: 'B', text: '旧选项二' }],
correctOptionIds: ['a'], answerTexts: ['旧选项一'], answerStatus: 'confirmed' }];
function installStorage(seed, fallback) {
window.unsafeWindow = window;
window.__menus = [];
window.GM_registerMenuCommand = (name, action) => window.__menus.push({ name, action });
if (!fallback) {
window.GM_getValue = (key, defaultValue) => {
const value = localStorage.getItem(key);
return value === null ? defaultValue : JSON.parse(value);
};
window.GM_setValue = (key, value) => {
const blocked = window.__blockWrite;
if (blocked && (blocked === key || (blocked === 'staging' && key.includes('.reset.')))) {
if (window.__silentFailure) return;
throw new Error('fixture storage denied');
}
localStorage.setItem(key, JSON.stringify(value));
};
window.GM_deleteValue = key => {
if (window.__blockCleanup) throw new Error('fixture cleanup denied');
localStorage.removeItem(key);
};
const listeners = new Map(); let nextId = 0;
window.GM_addValueChangeListener = (key, action) => { const id = ++nextId; listeners.set(id, { key, action }); return id; };
window.GM_removeValueChangeListener = id => listeners.delete(id);
window.addEventListener('storage', event => {
if (window.__muteNotifications) return;
for (const entry of [...listeners.values()]) if (entry.key === event.key) {
entry.action(event.key, event.oldValue === null ? null : JSON.parse(event.oldValue), event.newValue === null ? null : JSON.parse(event.newValue), true);
}
});
}
// Simulate a suspended tab that misses both GM and native storage events.
window.addEventListener('storage', event => { if (window.__muteNotifications) event.stopImmediatePropagation(); }, true);
if (!localStorage.getItem('__resetFixtureSeeded')) {
localStorage.setItem('__resetFixtureSeeded', '1');
localStorage.setItem('huayi-qb.records.v1', JSON.stringify(seed));
localStorage.setItem('huayi-qb.config.v1', JSON.stringify({ collecting: false, copyEnabled: false }));
for (const suffix of ['before-dedup', 'before-duplicate-option-repair', 'before-image-upgrade', 'before-judge-evidence-repair']) {
localStorage.setItem(`huayi-qb.records.${suffix}.v1`, JSON.stringify({ schemaVersion: 1, records: seed, migrations: [] }));
}
localStorage.setItem('unrelated-site-data', 'preserve me');
}
}
function activeRecords(page) {
return page.evaluate(({ RECORDS, RESET }) => {
const marker = JSON.parse(localStorage.getItem(RESET) || 'null');
return JSON.parse(localStorage.getItem(marker ? `${RECORDS}.reset.${marker.generation}` : RECORDS) || '[]');
}, { RECORDS, RESET });
}
async function show(page, tab = 'library') {
if (!await page.locator('#qb-panel').isVisible()) await page.keyboard.press('Alt+Shift+Q');
await page.locator(`[data-tab="${tab}"]`).click();
}
async function reset(page, accept = true, tab = 'library') {
await show(page, tab);
const dialog = page.waitForEvent('dialog');
const clicking = page.locator(`#qb-view-${tab} [data-action="reset-library"]`).click();
const prompt = await dialog;
assert.match(prompt.message(), /全部.*道题/);
await (accept ? prompt.accept() : prompt.dismiss());
await clicking;
}
async function importJSON(page, records, name = 'verified.json') {
await show(page);
await page.locator('#qb-import-file').setInputFiles({ name, mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ schemaVersion: 1, records })) });
await page.waitForFunction(() => document.querySelector('#huayi-qb-root').shadowRoot.querySelector('#qb-import-file').files.length === 0);
}
test('重置题库:清空、旧标签隔离、异步取消和写入失败保护', { timeout: 90000 }, async t => {
let playwright;
try { playwright = require(process.env.QB_PLAYWRIGHT_MODULE || 'playwright'); }
catch { playwright = require(path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')); }
const cached = path.join(os.homedir(), 'AppData/Local/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-win64/chrome-headless-shell.exe');
const requested = process.env.QB_CHROMIUM_EXECUTABLE || process.env.QB_CHROMIUM_PATH;
const browser = await playwright.chromium.launch({ headless: true, ...(requested || fs.existsSync(cached) ? { executablePath: requested || cached } : {}) });
const script = fs.readFileSync(path.join(ROOT, 'dist/huayi-question-bank.user.js'), 'utf8');
const errors = [];
async function fixture(fallback = false) {
const context = await browser.newContext();
await context.route('**/*', route => route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><html><body><main>本地题库重置验收</main></body></html>' }));
await context.addInitScript({ content: `(${installStorage.toString()})(${JSON.stringify(seed)},${fallback});\n${script}` });
async function open() {
const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
await page.goto('https://reset.example.test/');
await page.locator('#huayi-qb-root').waitFor({ state: 'attached' });
return page;
}
return { context, open, page: await open() };
}
try {
await t.test('取消保持原库;确认清空题目、冲突和四类备份,保留其他网站数据与复制设置', async () => {
const { context, page } = await fixture();
try {
const before = await activeRecords(page);
await reset(page, false);
assert.deepEqual(await activeRecords(page), before);
assert.equal(await page.evaluate(key => localStorage.getItem(key), RESET), null);
await show(page, 'auto');
await page.locator('#qb-auto-enabled').check();
await reset(page);
assert.deepEqual(await activeRecords(page), []);
assert.equal(await page.locator('#qb-stat-total').innerText(), '0');
for (const key of [RECORDS, ...BACKUPS]) assert.equal(await page.evaluate(key => localStorage.getItem(key), key), null);
assert.equal(await page.evaluate(() => localStorage.getItem('unrelated-site-data')), 'preserve me');
await show(page, 'auto');
assert.equal(await page.locator('#qb-auto-enabled').isChecked(), false);
await show(page, 'settings');
assert.equal(await page.locator('#qb-copy-enabled').isChecked(), false);
await page.reload(); await show(page);
assert.deepEqual(await activeRecords(page), []);
assert.equal(await page.locator('[data-action="toggle-capture"]').innerText(), '继续采集');
assert.ok(await page.evaluate(() => window.__menus.some(entry => entry.name === '重置全部本地题库')));
} finally { await context.close(); }
});
await t.test('跨标签同步;漏通知的旧页在保存前清空,旧版写回与刷新均不能复活数据', async () => {
const { context, page: a, open } = await fixture();
try {
const b = await open(), suspended = await open();
await show(b); await show(suspended);
await suspended.evaluate(() => { window.__muteNotifications = true; });
await reset(a);
await b.waitForFunction(() => document.querySelector('#huayi-qb-root').shadowRoot.querySelector('#qb-stat-total').textContent === '0');
assert.equal(await suspended.locator('#qb-stat-total').innerText(), '1');
await suspended.evaluate(() => window.dispatchEvent(new Event('pagehide')));
assert.equal(await suspended.locator('#qb-stat-total').innerText(), '0');
await a.evaluate(({ RECORDS, CONFIG, BACKUPS, seed }) => {
localStorage.setItem(RECORDS, JSON.stringify(seed));
localStorage.setItem(CONFIG, JSON.stringify({ collecting: true }));
for (const key of BACKUPS) localStorage.setItem(key, JSON.stringify({ schemaVersion: 1, records: seed, migrations: [] }));
}, { RECORDS, CONFIG, BACKUPS, seed });
await a.reload(); await show(a);
assert.deepEqual(await activeRecords(a), []);
assert.equal(await a.locator('#qb-stat-total').innerText(), '0');
assert.equal(await a.locator('[data-action="toggle-capture"]').innerText(), '继续采集');
await show(a, 'settings');
await a.locator('[data-action="export-judge-evidence-backup"]').click();
assert.match(await a.locator('#qb-status').innerText(), /没有判断题/);
} finally { await context.close(); }
});
await t.test('重置取消正在读取的旧文件,新导入不受旧异步完成影响;1884题重新导入导出一致', async () => {
const { context, page: a, open } = await fixture();
try {
const b = await open(); await show(b);
await b.evaluate(() => {
const original = File.prototype.text;
File.prototype.text = function () {
if (this.name !== 'delayed.json') return original.call(this);
return new Promise(resolve => { window.__releaseImport = async () => resolve(await original.call(this)); });
};
});
await b.locator('#qb-import-file').setInputFiles({ name: 'delayed.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ schemaVersion: 1, records: seed })) });
await b.waitForFunction(() => typeof window.__releaseImport === 'function');
await reset(a, true, 'settings');
await b.waitForFunction(() => !document.querySelector('#huayi-qb-root').shadowRoot.querySelector('[data-action="import"]').disabled);
const verified = JSON.parse(fs.readFileSync(path.join(ROOT, '题库/华谊集团化工总控工(高级)-合并题库.json'), 'utf8')).records;
await importJSON(b, verified);
await b.evaluate(() => window.__releaseImport());
assert.equal((await activeRecords(b)).length, 1884);
assert.equal((await activeRecords(b)).some(record => record.questionId === seed[0].questionId), false);
const waiting = b.waitForEvent('download');
await b.locator('[data-action="export-json"]').click();
const download = await waiting;
const exported = JSON.parse(fs.readFileSync(await download.path(), 'utf8'));
assert.equal(exported.records.length, 1884);
assert.ok(exported.records.every(record => record.answerStatus === 'confirmed'));
const oldMarker = await b.evaluate(key => JSON.parse(localStorage.getItem(key)), RESET);
await reset(b);
assert.equal(await b.evaluate(({ RECORDS, generation }) => localStorage.getItem(`${RECORDS}.reset.${generation}`), { RECORDS, generation: oldMarker.generation }), null);
assert.deepEqual(await activeRecords(b), []);
} finally { await context.close(); }
});
await t.test('空库写入失败、标记写入失败及静默失败都保留原题库和备份', async () => {
for (const failure of [{ key: 'staging', silent: false }, { key: RESET, silent: false }, { key: RESET, silent: true }]) {
const { context, page } = await fixture();
try {
const before = await activeRecords(page);
await page.evaluate(failure => { window.__blockWrite = failure.key; window.__silentFailure = failure.silent; }, failure);
await reset(page);
assert.match(await page.locator('#qb-status').innerText(), /重置未完成/);
assert.deepEqual(await activeRecords(page), before);
assert.equal(await page.evaluate(key => localStorage.getItem(key), RESET), null);
for (const key of BACKUPS) assert.ok(await page.evaluate(key => localStorage.getItem(key), key));
} finally { await context.close(); }
}
});
await t.test('原存储损坏仍可重置;清理失败明确提示并隔离,localStorage回退同样有效', async () => {
for (const fallback of [false, true]) {
const { context, page } = await fixture(fallback);
try {
await page.evaluate(({ RECORDS, BACKUPS }) => {
localStorage.setItem(RECORDS, '{broken');
localStorage.setItem(BACKUPS[2], '{broken');
}, { RECORDS, BACKUPS });
await page.reload(); await show(page);
if (!fallback) await page.evaluate(() => { window.__blockCleanup = true; });
await reset(page);
assert.deepEqual(await activeRecords(page), []);
if (!fallback) assert.match(await page.locator('#qb-status').innerText(), /部分旧存储清理失败.*隔离/);
await page.reload(); await show(page);
assert.equal(await page.locator('#qb-stat-total').innerText(), '0');
} finally { await context.close(); }
}
});
assert.deepEqual(errors, []);
} finally { await browser.close(); }
});