feat: publish verified five-bank question dataset
This commit is contained in:
Vendored
+96
-22
@@ -1,7 +1,7 @@
|
||||
// ==UserScript==
|
||||
// @name 华谊题库助手 · CSV导出与划词检索
|
||||
// @namespace local.huayi.question-bank
|
||||
// @version 1.2.3
|
||||
// @version 1.2.4
|
||||
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
|
||||
// @match *://*/*
|
||||
// @run-at document-start
|
||||
@@ -261,6 +261,62 @@ const QBCore = (() => {
|
||||
return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded };
|
||||
}
|
||||
|
||||
// These are deliberately evidenced legacy spellings, not an HTML decoder.
|
||||
// Literal entities elsewhere stay distinct in normalization/search/compaction.
|
||||
function upgradeLegacyMathEntities(existingInput, incomingInput, { bankId = '', expectedCount, fullSnapshot = false } = {}) {
|
||||
if (!Array.isArray(existingInput) || !Array.isArray(incomingInput)) throw new Error('题库必须是数组');
|
||||
const existing = existingInput.map(normalizeRecord), incoming = incomingInput.map(normalizeRecord);
|
||||
const unchanged = () => ({ existing, incoming, upgraded: [] });
|
||||
if (!fullSnapshot || !bankId || !Number.isSafeInteger(expectedCount) || expectedCount < 1 || incoming.length !== expectedCount) return unchanged();
|
||||
const isList = source => source?.kind === 'network' && /\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/u.test(source.url || '') && Number.isFinite(Date.parse(source.observedAt));
|
||||
const batch = incoming[0]?.source.batchId;
|
||||
if (!batch || incoming.some(record => record.bankId !== bankId || !isList(record.source) || record.source.batchId !== batch ||
|
||||
!Number.isSafeInteger(record.questionIndex) || record.questionIndex < 0 || record.questionIndex >= expectedCount || !record.questionId) ||
|
||||
new Set(incoming.map(record => record.questionIndex)).size !== expectedCount || new Set(incoming.map(record => record.questionId)).size !== expectedCount) return unchanged();
|
||||
const content = record => [record.stem, ...record.options.map(option => option.text)];
|
||||
const entities = { '×': '×', '±': '±' };
|
||||
const hasEntity = record => content(record).some(value => /&(?:times|plusmn);/.test(value));
|
||||
const canonical = value => normalized(value.replace(/&(?:times|plusmn);/g, entity => entities[entity]));
|
||||
const signature = record => JSON.stringify([record.type, canonical(record.stem), sorted(record.options.map(option => canonical(option.text)))]);
|
||||
const optionPairs = record => record.options.map(option => JSON.stringify([option.id, canonical(option.text)]));
|
||||
const correctContents = record => {
|
||||
if (record.answerStatus !== 'confirmed' || record.conflicts.length || !record.correctOptionIds.length || record.options.length < 2 ||
|
||||
record.options.some(option => !option.id) || new Set(record.options.map(option => option.id)).size !== record.options.length ||
|
||||
new Set(record.options.map(option => canonical(option.text))).size !== record.options.length) return null;
|
||||
const selected = record.correctOptionIds.map(id => record.options.find(option => option.id === id));
|
||||
return selected.some(option => !option) ? null : selected.map(option => canonical(option.text));
|
||||
};
|
||||
const sameAnswers = (a, b) => {
|
||||
const left = correctContents(a), right = correctContents(b);
|
||||
return left && right && sameArray(left, right) && sameArray(a.correctOptionIds, b.correctOptionIds) &&
|
||||
sameArray(unique(a.answerTexts.map(canonical)), unique(b.answerTexts.map(canonical)));
|
||||
};
|
||||
const sameModern = (record, current) => !hasEntity(record) && record.type === current.type && record.questionIndex === current.questionIndex &&
|
||||
normalized(record.stem) === normalized(current.stem) && sameArray(optionPairs(record), optionPairs(current)) && isList(record.source) && sameAnswers(record, current);
|
||||
const bank = existing.filter(record => record.bankId === bankId), upgraded = [], removed = new Set();
|
||||
for (let index = 0; index < incoming.length; index++) {
|
||||
const current = incoming[index], key = signature(current);
|
||||
if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incoming.filter(record => signature(record) === key).length !== 1) continue;
|
||||
const candidates = bank.filter(record => hasEntity(record) && signature(record) === key);
|
||||
if (candidates.length !== 1) continue;
|
||||
const previous = candidates[0];
|
||||
if (!previous.questionId || previous.questionIndex !== current.questionIndex || !Number.isSafeInteger(previous.questionIndex) ||
|
||||
!isList(previous.source) || (previous.source.batchId ? previous.source.batchId === batch : previous.source.observedAt === current.source.observedAt) ||
|
||||
!sameArray(optionPairs(previous), optionPairs(current)) || !sameAnswers(previous, current)) continue;
|
||||
// A previous failed export may already contain both spellings. Permit only
|
||||
// fully agreeing modern copies at that very same source position; distinct
|
||||
// literal-entity questions and duplicate occurrences remain untouched.
|
||||
if (bank.some(record => (signature(record) === key || record.questionIndex === current.questionIndex) &&
|
||||
record.key !== previous.key && !sameModern(record, current))) continue;
|
||||
const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) });
|
||||
incoming[index] = replacement;
|
||||
removed.add(previous.key);
|
||||
upgraded.push({ original: existingInput[existing.findIndex(record => record.key === previous.key)], replacement,
|
||||
kind: 'math-entity', bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex });
|
||||
}
|
||||
return { existing: existing.filter(record => !removed.has(record.key)), incoming, upgraded };
|
||||
}
|
||||
|
||||
function conflictSnapshot(record, reason) {
|
||||
return { reason, stem: record.stem, options: record.options.map((option) => ({ ...option })), answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds], source: record.answerSource || record.source };
|
||||
}
|
||||
@@ -568,6 +624,19 @@ const QBCore = (() => {
|
||||
if (normalizedStem(record.stem) !== normalizedStem(current.stem) ||
|
||||
(record.type !== 'unknown' && current.type !== 'unknown' && record.type !== current.type)) return result('unmapped');
|
||||
if (record.options.length && !sameArray(optionContents(record), current.options.map((option) => comparableAnswer(option.text, record.type)))) return result('unmapped');
|
||||
const completeIdentity = record.type === current.type && record.type !== 'unknown' && record.options.length > 0 &&
|
||||
record.options.length === current.options.length && record.correctOptionIds.length > 0 &&
|
||||
record.options.every(option => option.id) && current.options.every(option => option.id) &&
|
||||
new Set(record.options.map(option => option.id)).size === record.options.length &&
|
||||
new Set(current.options.map(option => option.id)).size === current.options.length &&
|
||||
sameArray(record.options.map(option => JSON.stringify([option.id, comparableAnswer(option.text, record.type)])),
|
||||
current.options.map(option => JSON.stringify([option.id, comparableAnswer(option.text, record.type)])));
|
||||
if (completeIdentity) {
|
||||
const selected = record.correctOptionIds.map(id => current.options.find(option => option.id === id));
|
||||
if (selected.some(option => !option)) return result('unmapped');
|
||||
if (unique(current.options.map(option => option.label)).length !== current.options.length) return result('ambiguous');
|
||||
return result('matched', selected.map(option => option.label));
|
||||
}
|
||||
const labels = [];
|
||||
for (const value of answerTexts) {
|
||||
const expected = comparableAnswer(value, record.type);
|
||||
@@ -698,7 +767,7 @@ const QBCore = (() => {
|
||||
return records;
|
||||
}
|
||||
|
||||
return { normalizeRecord, repairDuplicateOptionConflicts, upgradeLegacyImageRecords, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
|
||||
return { normalizeRecord, repairDuplicateOptionConflicts, upgradeLegacyImageRecords, upgradeLegacyMathEntities, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
|
||||
})();
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;
|
||||
@@ -1481,9 +1550,8 @@ const QBAuto = (() => {
|
||||
try { question = core.normalizeRecord({ ...current, answerStatus: 'missing' }); }
|
||||
catch (_) { return result('unsupported', '当前题目结构不完整,已跳过。'); }
|
||||
if (!['single', 'multiple', 'judge'].includes(question.type) || question.options.length < 2 || question.options.length > 100) return result('unsupported', '当前题型暂不支持自动选择。');
|
||||
const contents = question.options.map(option => optionKey(option.text, question.type));
|
||||
// Multiple-choice repeated text is checked against explicit correct IDs by mapAnswers.
|
||||
if ((question.type !== 'multiple' && new Set(contents).size !== contents.length) || new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项文字或标记重复,无法唯一对应,已跳过。');
|
||||
// Repeated text requires mapAnswers to prove the complete option identity or correct set.
|
||||
if (new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项标记重复,无法唯一对应,已跳过。');
|
||||
const relevant = [];
|
||||
for (const input of records) {
|
||||
let record;
|
||||
@@ -1766,16 +1834,16 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
storage.set(OPTION_REPAIR_BACKUP_KEY, { schemaVersion: 1, savedAt: existing?.savedAt || new Date().toISOString(), records: originals });
|
||||
return result;
|
||||
}
|
||||
function imageReplacementSignature(value) {
|
||||
function imageReplacementSignature(value, includeQuestionIdentity = true) {
|
||||
const record = QBCore.normalizeRecord(value);
|
||||
return JSON.stringify([record.key, record.type, record.stem,
|
||||
return JSON.stringify([includeQuestionIdentity ? record.key : record.bankId, record.type, record.stem,
|
||||
record.options.map(option => JSON.stringify([option.id, option.text])).sort(),
|
||||
[...record.correctOptionIds].sort(), [...record.answerTexts].sort(), record.answerStatus]);
|
||||
}
|
||||
function imageUpgradeBackup() {
|
||||
const backup = storage.get(IMAGE_UPGRADE_BACKUP_KEY, null);
|
||||
if (backup !== null && (!backup || backup.schemaVersion !== 1 || !Array.isArray(backup.records) || !Array.isArray(backup.migrations))) {
|
||||
throw new Error('图片补全备份结构异常,已停止覆盖');
|
||||
throw new Error('内容补全备份结构异常,已停止覆盖');
|
||||
}
|
||||
return backup;
|
||||
}
|
||||
@@ -1783,7 +1851,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
if (!upgraded.length) return;
|
||||
const previous = imageUpgradeBackup();
|
||||
const stored = storage.get(RECORDS_KEY, []);
|
||||
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止图片补全');
|
||||
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止内容补全');
|
||||
const originals = previous ? [...previous.records] : [], migrations = previous ? [...previous.migrations] : [];
|
||||
const signatures = new Set(originals.map(record => JSON.stringify(QBCore.normalizeRecord(record))));
|
||||
for (const item of upgraded) {
|
||||
@@ -1801,7 +1869,8 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
const original = QBCore.normalizeRecord(value), originalSignature = JSON.stringify(original);
|
||||
if (!signatures.has(originalSignature)) { signatures.add(originalSignature); originals.push(value); }
|
||||
const migration = { originalKey: original.key, originalSignature, replacementKey: item.replacement.key,
|
||||
replacementSignature: imageReplacementSignature(item.replacement), bankId: item.bankId,
|
||||
replacementSignature: imageReplacementSignature(item.replacement), replacementQuestionId: item.replacement.questionId,
|
||||
replacementContentSignature: imageReplacementSignature(item.replacement, false), kind: item.kind || 'image', bankId: item.bankId,
|
||||
batchId: item.batchId, expectedCount: item.expectedCount, questionIndex: item.questionIndex };
|
||||
if (!migrations.some(value => JSON.stringify(value) === JSON.stringify(migration))) migrations.push(migration);
|
||||
}
|
||||
@@ -1818,8 +1887,11 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
return original.filter(value => {
|
||||
const record = QBCore.normalizeRecord(value), signature = JSON.stringify(record);
|
||||
return !backup.migrations.some(item => item.originalKey === record.key && item.originalSignature === signature && backups.has(signature) &&
|
||||
replacements.some(replacement => replacement.answerStatus === 'confirmed' && replacement.key === item.replacementKey &&
|
||||
replacement.questionIdAliases?.includes(record.questionId) && imageReplacementSignature(replacement) === item.replacementSignature));
|
||||
replacements.some(replacement => replacement.answerStatus === 'confirmed' && replacement.questionIdAliases?.includes(record.questionId) &&
|
||||
((replacement.key === item.replacementKey && imageReplacementSignature(replacement) === item.replacementSignature) ||
|
||||
(item.replacementContentSignature && replacement.bankId === item.bankId &&
|
||||
(replacement.questionId === item.replacementQuestionId || replacement.questionIdAliases?.includes(item.replacementQuestionId)) &&
|
||||
imageReplacementSignature(replacement, false) === item.replacementContentSignature))));
|
||||
});
|
||||
}
|
||||
try {
|
||||
@@ -1914,13 +1986,15 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
try {
|
||||
incoming = repairAnswerConflicts(incoming).records;
|
||||
const bank = currentBank();
|
||||
const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, {
|
||||
const upgradeContext = {
|
||||
bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '',
|
||||
expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true
|
||||
});
|
||||
ensureImageUpgradeBackup(imageUpgrade.upgraded);
|
||||
records = imageUpgrade.existing;
|
||||
incoming = imageUpgrade.incoming;
|
||||
};
|
||||
const imageUpgrade = QBCore.upgradeLegacyImageRecords(records, incoming, upgradeContext);
|
||||
const contentUpgrade = QBCore.upgradeLegacyMathEntities(imageUpgrade.existing, imageUpgrade.incoming, upgradeContext);
|
||||
ensureImageUpgradeBackup([...imageUpgrade.upgraded, ...contentUpgrade.upgraded]);
|
||||
records = contentUpgrade.existing;
|
||||
incoming = contentUpgrade.incoming;
|
||||
const namesById = new Map();
|
||||
incoming.forEach(record => {
|
||||
if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return;
|
||||
@@ -2200,9 +2274,9 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
}
|
||||
function exportImageUpgradeBackup() {
|
||||
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} 条原始记录。`);
|
||||
if (!backup) return tell('没有内容补全前的备份。');
|
||||
download(JSON.stringify(backup, null, 2), exportFilename('内容补全前原始备份', backup.records.length, 'json'), 'application/json;charset=utf-8');
|
||||
tell(`已导出内容补全前的 ${backup.records.length} 条原始记录。`);
|
||||
}
|
||||
async function parseImportFile(file) {
|
||||
if (file.size > 20 * 1024 * 1024) throw new Error('文件超过 20 MB,请拆分后导入');
|
||||
@@ -2293,7 +2367,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
<div class="section"><h3>答案规则</h3><p>按标准答案内容保存,再对应当前页面的选项位置。没有标准答案就显示待补;不同答案会保留冲突。所有资料保存在当前浏览器,建议定期导出备份。</p></div>
|
||||
<div class="section"><h3>重复记录整理</h3><p>重复加载的题目按批次对应合并,保留原题库中的同文题和全部来源 ID。首次整理前会保留原始备份。</p><button type="button" class="btn small" data-action="export-dedup-backup">导出整理前备份</button></div>
|
||||
<div class="section"><h3>答案误报恢复</h3><p>只恢复旧版因多个正确选项文字相同产生、且全部冲突证据一致的误报。修复前记录独立备份,真实答案冲突继续保留。</p><button type="button" class="btn small" data-action="export-option-repair-backup">导出误报修复前备份</button></div>
|
||||
<div class="section"><h3>题目图片补全</h3><p>新完整题库批次与页面题数一致、位置和文字答案均能唯一核对时,补全旧版遗漏的图片,并保留原始题目 ID 与独立备份。</p><button type="button" class="btn small" data-action="export-image-upgrade-backup">导出图片补全前备份</button></div>
|
||||
<div class="section"><h3>旧题内容补全</h3><p>新完整题库批次与页面题数一致、位置和文字答案均能唯一核对时,补全旧版遗漏的图片、乘号或正负号实体,并保留原始题目 ID 与独立备份。</p><button type="button" class="btn small" data-action="export-image-upgrade-backup">导出内容补全前备份</button></div>
|
||||
</div>
|
||||
<footer class="footer"><div class="footline"><span class="dot"></span><span>本地题库 · 标准答案优先</span></div><div id="qb-status" role="status"></div></footer>
|
||||
</div>
|
||||
@@ -2393,7 +2467,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
|
||||
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
|
||||
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
|
||||
GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup);
|
||||
GM_registerMenuCommand('导出图片补全前原始备份', exportImageUpgradeBackup);
|
||||
GM_registerMenuCommand('导出内容补全前原始备份', exportImageUpgradeBackup);
|
||||
}
|
||||
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
|
||||
if (!remote || !Array.isArray(value)) return;
|
||||
|
||||
Reference in New Issue
Block a user