From a7d2d30daaebb41c6ea3f062f3b496ca3784404a Mon Sep 17 00:00:00 2001 From: suanyuit-cyber <346708943+suanyuit-cyber@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:55:22 +0800 Subject: [PATCH] feat: add local question bank reset with stale-tab isolation --- README.md | 16 +- build.cjs | 4 +- dist/huayi-question-bank.user.js | 241 ++++++++++++++++++++++++++----- package.json | 4 +- src/adapter.js | 10 +- src/app.js | 227 ++++++++++++++++++++++++----- tests/adapter.test.cjs | 20 +++ tests/reset-browser.test.cjs | 227 +++++++++++++++++++++++++++++ 8 files changed, 666 insertions(+), 83 deletions(-) create mode 100644 tests/reset-browser.test.cjs diff --git a/README.md b/README.md index e2677be..765c83f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # 华谊题库助手 -支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.7**。 +支持在普通 HTTP / HTTPS 网页中通过快捷键打开的本地题库油猴脚本:文字搜索、划词检索、自动选择已确认答案,并保留华谊练习平台的按库采集、CSV 导出与多题库导入。当前版本 **1.2.8**。 + +1.2.8 新增“重置全部题库”,用于清除旧版或错误导入的数据。确认后清空本地全部题库、答案、冲突和四类历史修复备份,取消正在进行的导入及待保存任务,停止自动选择和判断题补齐,并暂停采集。新的题库存储与重置前隔离,旧标签页或旧版脚本再次写入的数据不会混回。升级本身保留已有资料,需要清空时再手动重置。 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. 重置后采集保持暂停,刷新后也不会自动恢复。需要重新采集时,再点击“继续采集”;连续自动选择和判断题补齐仍须手动开启。 + +重置作用于当前浏览器中同一油猴脚本保存的全部题库,不受搜索或导出范围限制。已打开的新版本标签页会同步清空内存,并关闭自动操作;漏收通知的页面在下次读取、保存或恢复前检查重置状态。旧版页面继续写入旧存储时,新版也不会再读取那一份数据。复制开关和面板偏好保留,网页账号的练习记录以及已下载的文件不受影响。 + +重置需要先验证空题库和重置标记写入成功,再清理原数据。准备或标记保存失败时保留原题库并显示错误;如果仅旧存储清理失败,会明确提示旧数据已隔离。原题库或修复备份损坏时,仍可使用重置功能重新初始化。 + ## 隐藏、收起与恢复 | 操作 | 效果 | 恢复方式 | diff --git a/build.cjs b/build.cjs index 85b9a13..4c92239 100644 --- a/build.cjs +++ b/build.cjs @@ -4,7 +4,7 @@ const root = __dirname; const header = `// ==UserScript== // @name 华谊题库助手 · CSV导出与划词检索 // @namespace local.huayi.question-bank -// @version 1.2.7 +// @version 1.2.8 // @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。 // @match *://*/* // @run-at document-start @@ -12,8 +12,10 @@ const header = `// ==UserScript== // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue +// @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_addValueChangeListener +// @grant GM_removeValueChangeListener // ==/UserScript== `; const sources = ['src/core.js', 'src/adapter.js', 'src/autofill.js', 'src/collector.js', 'src/app.js']; diff --git a/dist/huayi-question-bank.user.js b/dist/huayi-question-bank.user.js index 8ca45e1..3733308 100644 --- a/dist/huayi-question-bank.user.js +++ b/dist/huayi-question-bank.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name 华谊题库助手 · CSV导出与划词检索 // @namespace local.huayi.question-bank -// @version 1.2.7 +// @version 1.2.8 // @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。 // @match *://*/* // @run-at document-start @@ -9,8 +9,10 @@ // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue +// @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_addValueChangeListener +// @grant GM_removeValueChangeListener // ==/UserScript== (function () { @@ -1777,7 +1779,15 @@ const QBAdapter = (() => { 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 }; @@ -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 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 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 pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; - const storage = { + const rawStorage = { get(key, fallback) { if (typeof GM_getValue === 'function') return GM_getValue(key, fallback); const raw = localStorage.getItem(key); @@ -2382,8 +2394,30 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto set(key, value) { if (typeof GM_setValue === 'function') return GM_setValue(key, 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; try { rawConfig = storage.get(CONFIG_KEY, {}); } catch (_) { 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. config.mode = 'hidden'; config.keepLauncher = false; + if (generationError) config.collecting = false; let records = []; let recordsDirty = false; - let loadError = ''; + let loadError = generationError; let cleanupNotice = ''; let storedRecordsText = ''; let saveIdle = null; @@ -2506,46 +2541,52 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto imageReplacementSignature(replacement, false) === item.replacementContentSignature)))); }); } - try { - const saved = storage.get(RECORDS_KEY, []); - if (!Array.isArray(saved)) throw new Error('本地题库格式不正确'); - storedRecordsText = JSON.stringify(saved); - const retained = removeBackedUpImagePredecessors(saved, saved); - const judgeRepair = repairJudgeEvidence(retained); - const repaired = repairAnswerConflicts(judgeRepair.records); - const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records)); - records = compacted.records; - const removed = retained.length - records.length; - if (retained.length < saved.length) recordsDirty = true; - if (removed > 0) { - ensureCleanupBackup(saved); - recordsDirty = true; - cleanupNotice = `已合并 ${removed} 条重复加载记录,原始数据已备份。`; - } - if (repaired.repaired.length) { - recordsDirty = true; - cleanupNotice += `${cleanupNotice ? ' ' : ''}已恢复 ${repaired.repaired.length} 道重复选项文字造成的答案误报,修复前记录已备份。`; - } - if (judgeRepair.changed.length) { - recordsDirty = true; - cleanupNotice += `${cleanupNotice ? ' ' : ''}已核验 ${judgeRepair.changed.length} 条判断题答案,缺少标准答案依据的记录已改为待补,原值已独立备份。`; - } - } catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; } + function loadRecords() { + try { + if (generationError) throw new Error(generationError); + const saved = storage.get(RECORDS_KEY, []); + if (!Array.isArray(saved)) throw new Error('本地题库格式不正确'); + storedRecordsText = JSON.stringify(saved); + const retained = removeBackedUpImagePredecessors(saved, saved); + const judgeRepair = repairJudgeEvidence(retained); + const repaired = repairAnswerConflicts(judgeRepair.records); + const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records)); + records = compacted.records; + const removed = retained.length - records.length; + if (retained.length < saved.length) recordsDirty = true; + if (removed > 0) { + ensureCleanupBackup(saved); + recordsDirty = true; + cleanupNotice = `已合并 ${removed} 条重复加载记录,原始数据已备份。`; + } + if (repaired.repaired.length) { + recordsDirty = true; + cleanupNotice += `${cleanupNotice ? ' ' : ''}已恢复 ${repaired.repaired.length} 道重复选项文字造成的答案误报,修复前记录已备份。`; + } + if (judgeRepair.changed.length) { + recordsDirty = true; + cleanupNotice += `${cleanupNotice ? ' ' : ''}已核验 ${judgeRepair.changed.length} 条判断题答案,缺少标准答案依据的记录已改为待补,原值已独立备份。`; + } + } catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; } + } + loadRecords(); let root, shadow, panel, launcher, resultBox, statusBox; 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, importAttempt = 0; let status = loadError || cleanupNotice || '可输入文字或划选题干检索。'; let storageError = false; let mounted = false; + let recordsListener = null; let collector = null, collectorInitError = ''; const createAdapter = () => QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged, observeNetwork: networkEnabled }); let adapter = createAdapter(); - const autoAnswer = QBAuto.create({ window: pageWindow, document, core: QBCore, getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank, + 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(); } }); try { 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, onChange: () => { if (mounted && config.mode === 'open' && config.tab === 'auto') renderAuto(); } }); } 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; 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() { + if (!synchronizeReset()) return; try { storage.set(CONFIG_KEY, { ...config }); } catch (error) { tell(`设置保存失败:${error.message}`, true); } } function persistRecords() { - clearTimeout(saveTimer); - saveTimer = null; - if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; } + if (!synchronizeReset()) return; + cancelPendingSave(); if (loadError || !recordsDirty) return; try { // 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()); } function receive(incoming, context = {}) { + if (!synchronizeReset()) return; if (!Array.isArray(incoming) || !incoming.length || loadError) return; try { incoming = incoming.filter(record => !redundantDOMObservation(record)); @@ -2971,6 +3095,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto }); } function runSearch(text) { + if (!synchronizeReset()) return; query = String(text || '').trim().slice(0, 4000); if (shadow) shadow.getElementById('qb-search').value = query; 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}`; } function exportData(format) { + if (!synchronizeReset()) return; const selected = scopedRecords(); if (!selected.length) return tell('所选范围还没有题目,请先采集或导入。'); 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} 道有已确认答案。`); } function exportCleanupBackup() { + if (!synchronizeReset()) return; const backup = storage.get(BACKUP_KEY, null); if (!backup || !Array.isArray(backup.records)) return tell('没有需要恢复的整理前备份。'); download(JSON.stringify(backup, null, 2), exportFilename('题库整理前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8'); tell(`已导出整理前的 ${backup.records.length} 条原始记录。`); } function exportOptionRepairBackup() { + if (!synchronizeReset()) return; const backup = storage.get(OPTION_REPAIR_BACKUP_KEY, null); if (!backup || !Array.isArray(backup.records)) return tell('没有答案误报修复前的备份。'); download(JSON.stringify(backup, null, 2), exportFilename('答案误报修复前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8'); tell(`已导出答案误报修复前的 ${backup.records.length} 条原始记录。`); } function exportImageUpgradeBackup() { + if (!synchronizeReset()) return; const backup = imageUpgradeBackup(); if (!backup) return tell('没有内容补全前的备份。'); download(JSON.stringify(backup, null, 2), exportFilename('内容补全前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8'); tell(`已导出内容补全前的 ${backup.records.length} 条原始记录。`); } function exportJudgeEvidenceBackup() { + if (!synchronizeReset()) return; const backup = storage.get(JUDGE_EVIDENCE_BACKUP_KEY, null); 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'); @@ -3050,6 +3180,8 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto } async function importFiles(files) { if (!files.length) return; + if (!synchronizeReset()) throw new Error('题库已重置,请重新选择导入文件'); + const importGeneration = libraryGeneration; if (loadError) throw new Error('现有本地题库异常,为保留原数据已停止写入。请先从油猴存储备份原数据'); if (files.reduce((sum, file) => sum + file.size, 0) > 50 * 1024 * 1024) throw new Error('本次文件合计超过 50 MB,请分批导入'); const incoming = []; @@ -3057,6 +3189,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto for (const file of files) { try { incoming.push(...await parseImportFile(file)); } catch (error) { throw new Error(`「${file.name}」:${error.message}`); } + if (!synchronizeReset() || importGeneration !== libraryGeneration) throw new Error('题库已重置,本次导入已取消,请重新选择文件'); if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入'); } const before = records.length; @@ -3105,7 +3238,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBCollecto