fix: deduplicate practice snapshots and compact white panel

This commit is contained in:
suanyuit-cyber
2026-10-29 18:49:42 +08:00
parent 83ca85cc17
commit 762408a347
14 changed files with 1996 additions and 90 deletions
+334 -41
View File
@@ -1,7 +1,7 @@
// ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank
// @version 1.2.0
// @version 1.2.1
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
// @match *://*/*
// @run-at document-start
@@ -64,14 +64,18 @@ const QBCore = (() => {
function sourceInfo(value) {
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
return { kind: clean(source.kind) || 'unknown', url: clean(source.url), observedAt: clean(source.observedAt) || new Date().toISOString() };
const result = { kind: clean(source.kind) || 'unknown', url: clean(source.url), observedAt: clean(source.observedAt) || new Date().toISOString() };
if (clean(source.batchId)) result.batchId = idText(source.batchId, '采集批次ID');
return result;
}
function recordKey(record) {
const bank = record.bankId || record.bankName;
if (record.questionId) return `id:${JSON.stringify([bank, record.questionId])}`;
// Keep the complete signature, rather than a short hash whose collisions could merge answers.
return `fp:${JSON.stringify([bank, record.type, normalizedStem(record.stem), sorted(optionContents(record))])}`;
const fingerprint = [bank, record.type, normalizedStem(record.stem), sorted(optionContents(record))];
if (record.contentOccurrence != null) fingerprint.push(record.contentOccurrence);
return `fp:${JSON.stringify(fingerprint)}`;
}
function normalizeRecord(input) {
@@ -122,6 +126,13 @@ const QBCore = (() => {
correctOptionIds: answerStatus === 'missing' ? [] : correctOptionIds, answerTexts, answerStatus,
explanation: clean(input.explanation), source, updatedAt: clean(input.updatedAt) || source.observedAt, conflicts,
};
for (const field of ['questionIndex', 'contentOccurrence']) {
if (input[field] != null) {
if (!Number.isSafeInteger(input[field]) || input[field] < 0) throw new Error(`${field} 必须为非负整数`);
record[field] = input[field];
}
}
if (own(input, 'questionIdAliases')) record.questionIdAliases = unique([...stringArray(input.questionIdAliases, '题目ID别名'), record.questionId].filter(Boolean));
if (input.answerSource || answerStatus === 'confirmed') record.answerSource = sourceInfo(input.answerSource || source);
record.key = recordKey(record);
return record;
@@ -151,7 +162,14 @@ const QBCore = (() => {
if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录');
const contentMatches = sameContent(oldRecord, newRecord);
const explanation = newRecord.explanation || oldRecord.explanation;
const base = { ...newRecord, explanation };
const metadata = {};
for (const field of ['questionIndex', 'contentOccurrence']) {
if (newRecord[field] != null || oldRecord[field] != null) metadata[field] = newRecord[field] ?? oldRecord[field];
}
if (own(oldRecord, 'questionIdAliases') || own(newRecord, 'questionIdAliases')) metadata.questionIdAliases = unique([
...(oldRecord.questionIdAliases || []), ...(newRecord.questionIdAliases || []), oldRecord.questionId, newRecord.questionId
].filter(Boolean));
const base = { ...newRecord, ...metadata, explanation };
const conflicts = [...oldRecord.conflicts, ...newRecord.conflicts];
const conflictAlready = oldRecord.answerStatus === 'conflict' || newRecord.answerStatus === 'conflict';
const answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' && answerSignature(oldRecord) !== answerSignature(newRecord);
@@ -167,7 +185,7 @@ const QBCore = (() => {
}
if (newRecord.answerStatus === 'missing' && oldRecord.answerStatus === 'confirmed') {
// The incoming DOM may use different IDs/order. Keep the original answer's own options and IDs together.
return normalizeRecord({ ...oldRecord, bankName: newRecord.bankName || oldRecord.bankName, explanation,
return normalizeRecord({ ...oldRecord, ...metadata, bankName: newRecord.bankName || oldRecord.bankName, explanation,
updatedAt: newRecord.updatedAt, source: newRecord.source, answerSource: oldRecord.answerSource || oldRecord.source });
}
return normalizeRecord({ ...base, conflicts });
@@ -182,6 +200,25 @@ const QBCore = (() => {
records.set(record.key, records.has(record.key) ? mergeRecord(records.get(record.key), record) : record);
lastSeen.set(record.key, index);
}
// Explicit aliases are produced only by content/batch repair. Ordinary
// records with different IDs are still kept separate until that repair.
const bankIdentity = bankResolver([...records.values()]);
for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) {
if (!records.has(target.key)) continue;
for (const other of [...records.values()]) {
if (other.key === target.key || !records.has(other.key) || !other.questionId) continue;
const current = records.get(target.key);
const aliases = new Set(current.questionIdAliases || []);
if (!aliases.has(other.questionId) || bankIdentity(current) !== bankIdentity(other) || !sameContent(current, other) || current.type !== other.type) continue;
if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue;
records.set(target.key, foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence }));
records.delete(other.key);
}
}
for (const record of [...records.values()]) {
if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue;
if ([...records.values()].some(other => other.questionIdAliases?.length > 1 && bankIdentity(other) === bankIdentity(record) && other.type === record.type && sameContent(other, record))) records.delete(record.key);
}
// The saved DOM has no stable question ID. Upgrade it only when the complete
// content and bank identify one unique server record in both directions.
const anonymous = [...records.values()].filter((record) => !record.questionId);
@@ -191,7 +228,8 @@ const QBCore = (() => {
if (!a.bankName || !b.bankName || a.bankName === '未命名题库' || b.bankName === '未命名题库') return false;
return normalized(a.bankName) === normalized(b.bankName);
};
const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && a.type === b.type && sameBank(a, b) && sameContent(a, b);
const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && a.type === b.type && sameBank(a, b) && sameContent(a, b) &&
(a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence);
const candidates = new Map(anonymous.map((record) => [record.key, identified.filter((other) => possiblePair(record, other))]));
for (const record of anonymous) {
const matches = candidates.get(record.key);
@@ -207,6 +245,125 @@ const QBCore = (() => {
return [...records.values()];
}
function contentSignature(record) {
return JSON.stringify([record.type, normalizedStem(record.stem), sorted(optionContents(record))]);
}
function bankResolver(records) {
const namedIds = new Map();
for (const record of records) if (record.bankId && record.bankName !== '未命名题库') {
const name = normalized(record.bankName);
if (!namedIds.has(name)) namedIds.set(name, new Set());
namedIds.get(name).add(record.bankId);
}
return record => {
if (record.bankId) return `id:${record.bankId}`;
const name = normalized(record.bankName);
if (!name || record.bankName === '未命名题库') return `unresolved:${record.key}`;
const ids = namedIds.get(name);
return ids?.size === 1 ? `id:${[...ids][0]}` : `name:${name}`;
};
}
function foldEquivalent(group, identity = {}) {
const existing = group.find(record => record.questionIdAliases?.length > 1);
const first = existing || group.find(record => record.questionId) || group[0];
const questionId = identity.questionId ?? first.questionId;
const bankId = identity.bankId ?? (group.find(record => record.bankId)?.bankId || first.bankId);
const bankName = identity.bankName || first.bankName;
const ids = unique(group.flatMap(record => [record.questionId, ...(record.questionIdAliases || [])]).filter(Boolean));
const meta = { bankId, bankName, questionId };
if (ids.length > 1 || group.some(record => own(record, 'questionIdAliases'))) meta.questionIdAliases = ids;
const occurrence = identity.contentOccurrence ?? first.contentOccurrence ?? group.find(record => record.contentOccurrence != null)?.contentOccurrence;
if (occurrence != null) meta.contentOccurrence = occurrence;
// Process missing observations first so the selected options and the answer
// IDs always come together from an answer-bearing record.
const ordered = group.filter(record => record.answerStatus === 'missing').concat(group.filter(record => record.answerStatus !== 'missing'));
let result;
for (const record of ordered) {
const rebased = normalizeRecord({ ...record, ...meta });
result = result ? mergeRecord(result, rebased) : rebased;
}
return result;
}
function compactRecords(input) {
if (!Array.isArray(input)) throw new Error('题库必须是数组');
const entries = input.map((value, index) => ({ record: normalizeRecord(value), index, batch: '', occurrence: null }));
const bankIdentity = bankResolver(entries.map(entry => entry.record));
const listSource = record => [record.answerSource, record.source].find(source => source?.kind === 'network' &&
/\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/u.test(source.url || '') && Number.isFinite(Date.parse(source.observedAt)));
const legacySources = new Map();
for (const entry of entries) {
const record = entry.record;
if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence;
const explicit = record.source.batchId || record.answerSource?.batchId;
if (explicit) entry.batch = `${bankIdentity(record)}:${explicit}`;
else if (entry.occurrence == null) {
const source = listSource(record);
if (!source) continue;
const key = JSON.stringify([bankIdentity(record), source.url]);
if (!legacySources.has(key)) legacySources.set(key, []);
legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) });
}
}
// Older versions timestamped individual rows inside each list response.
// Infer only network list-response clusters; DOM/answer observations are
// never assumed to be full snapshots. Their original order gives duplicate
// occurrence numbers and keeps legitimate repeated questions in each list.
for (const [key, items] of legacySources) {
items.sort((a, b) => a.time - b.time || a.entry.index - b.entry.index);
let previous = -Infinity, batch = '';
for (const item of items) {
if (item.time - previous > 1000) batch = `legacy:${key}:${item.time}`;
item.entry.batch = batch; previous = item.time;
}
}
const counters = new Map();
for (const entry of entries) {
if (!entry.batch || entry.occurrence != null) continue;
const key = JSON.stringify([entry.batch, contentSignature(entry.record)]);
entry.occurrence = counters.get(key) || 0;
counters.set(key, entry.occurrence + 1);
}
const groups = new Map();
const byContent = new Map();
const add = (key, entry) => {
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(entry);
};
for (const entry of entries) {
const content = JSON.stringify([bankIdentity(entry.record), contentSignature(entry.record)]);
entry.content = content;
if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue;
const key = JSON.stringify([content, entry.occurrence]);
add(key, entry);
if (!byContent.has(content)) byContent.set(content, new Set());
byContent.get(content).add(key);
}
for (const entry of entries) {
if (entry.occurrence != null && entry.record.options.length >= 2 && entry.record.type !== 'unknown') continue;
const matches = [...(byContent.get(entry.content) || [])];
let key;
const matchingAlias = matches.filter(candidate => groups.get(candidate).some(item => item.record.questionId === entry.record.questionId || item.record.questionIdAliases?.includes(entry.record.questionId)));
if (entry.record.questionId && matchingAlias.length === 1) key = matchingAlias[0];
else if (matches.length === 1) key = matches[0];
else if (matches.length > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue;
else key = `unresolved:${entry.record.key}`;
add(key, entry);
}
const records = [];
let duplicateGroups = 0;
for (const group of groups.values()) {
if (group.length > 1) duplicateGroups++;
const identity = {};
const occurrence = group.find(entry => entry.occurrence != null)?.occurrence;
if (occurrence != null) identity.contentOccurrence = occurrence;
records.push(foldEquivalent(group.map(entry => entry.record), identity));
}
return { records, removed: input.length - records.length, groups: duplicateGroups, conflictCount: records.filter(record => record.answerStatus === 'conflict').length };
}
function bigrams(value) {
const chars = Array.from(value);
if (chars.length < 2) return new Set(chars);
@@ -412,7 +569,7 @@ const QBCore = (() => {
return records;
}
return { normalizeRecord, mergeRecord, upsert, search, mapAnswers, toCSV, fromCSV };
return { normalizeRecord, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;
@@ -478,13 +635,17 @@ const QBAdapter = (() => {
function recordFromNode(node, context) {
const rawType = has(node, 'quesType') ? node.quesType : node.type;
let type = TYPES[rawType];
const id = idString(node.quesId || node.questionId || node.id || context.questionId);
// The practice client submits listQues[i].id as answers[].quesId. A
// single-answer response may have its own receipt ID, so its request wins.
const id = idString((context.answerResponse && context.questionId) ||
(context.collection === 'listQues' && node.id) || node.quesId || node.questionId || node.id || context.questionId);
const previous = knownById(context, id);
if (!type && previous) type = previous.type;
if (!type) return null;
let stem = text(node.content);
if (!stem && previous) stem = previous.stem;
if (!stem || stem.length > 100000) return null;
if (context.answerResponse && previous && (type !== previous.type || flat(stem) !== flat(previous.stem))) return null;
// Do not treat an arbitrary content/type business object as a question.
if (!previous && !has(node, 'quesType') && !Array.isArray(node.choiceItems) && !has(node, 'judgeAnswer')) return null;
let options;
@@ -499,16 +660,24 @@ const QBAdapter = (() => {
} else if (previous) options = previous.options.map(o => ({ ...o }));
else return null;
const answers = standardAnswer(node, type, options, context);
return {
const record = {
bankId: idString(node.praId || context.bankId || (previous && previous.bankId)),
bankName: text(context.bankName || (previous && previous.bankName)), questionId: id,
type, stem, options, ...answers,
explanation: text((node.resultInfo && node.resultInfo.explainText) || node.explainText),
source: { kind: context.kind || 'network', url: String(context.url || ''), observedAt: context.observedAt || new Date().toISOString() }
source: { kind: context.kind || 'network', url: String(context.url || ''), observedAt: context.observedAt || new Date().toISOString(),
...(context.batchId ? { batchId: context.batchId } : {}) }
};
if (context.answerResponse && previous) {
if (Number.isSafeInteger(previous.questionIndex)) record.questionIndex = previous.questionIndex;
if (Number.isSafeInteger(previous.contentOccurrence)) record.contentOccurrence = previous.contentOccurrence;
if (previous.source?.batchId) record.source.batchId = previous.source.batchId;
} else if (context.fullList && has(node, 'content')) record._fullList = true;
return record;
}
function extractQuestions(payload, context = {}) {
context = { ...context, observedAt: context.observedAt || new Date().toISOString() };
const output = [];
const visited = new WeakSet();
let count = 0;
@@ -522,9 +691,13 @@ const QBAdapter = (() => {
return;
}
const next = { ...inherited, bankId: idString(node.praId || inherited.bankId), bankName: text(node.praName || inherited.bankName) };
const candidate = recordFromNode(node, next);
const isListContainer = ['listQues', 'questionList', 'questions'].some(key => Array.isArray(node[key]));
const candidate = isListContainer ? null : recordFromNode(node, next);
if (candidate) output.push(candidate);
for (const key of childKeys) if (has(node, key)) visit(node[key], next, depth + 1);
for (const key of childKeys) if (has(node, key)) {
const list = ['listQues', 'questionList', 'questions'].includes(key) && Array.isArray(node[key]) && !next.answerResponse;
visit(node[key], list ? { ...next, fullList: true, collection: key } : next, depth + 1);
}
}
visit(payload, context, 0);
const unique = new Map();
@@ -533,7 +706,18 @@ const QBAdapter = (() => {
const old = unique.get(key);
if (!old || (old.answerStatus === 'missing' && q.answerStatus === 'confirmed')) unique.set(key, q);
}
return [...unique.values()];
const records = [...unique.values()];
let questionIndex = 0;
const occurrences = new Map();
for (const record of records) {
if (!record._fullList) continue;
delete record._fullList;
const contentKey = `${record.bankId}\u001f${signature(record)}`;
record.questionIndex = questionIndex++;
record.contentOccurrence = occurrences.get(contentKey) || 0;
occurrences.set(contentKey, record.contentOccurrence + 1);
}
return records;
}
function classifyURL(value, base) {
@@ -565,6 +749,35 @@ const QBAdapter = (() => {
} catch (_) { return ''; }
}
function responseQuestionCount(payload) {
const visited = new WeakSet();
function countItems(items) {
let total = 0;
for (const node of items) {
if (!node || typeof node !== 'object') return null;
const type = Number(has(node, 'quesType') ? node.quesType : node.type);
if ([0, 1, 2, 3, 4].includes(type) && has(node, 'content')) total++;
else if (type === 5 && Array.isArray(node.subQuesList)) {
const children = countItems(node.subQuesList);
if (children === null) return null;
total += children;
} else return null;
}
return total <= 100000 ? total : null;
}
function find(node, depth) {
if (!node || typeof node !== 'object' || depth > 8 || visited.has(node)) return null;
visited.add(node);
for (const key of ['listQues', 'questionList']) if (Array.isArray(node[key])) return countItems(node[key]);
for (const key of ['data', 'result']) if (node[key]) {
const count = find(node[key], depth + 1);
if (count !== null) return count;
}
return null;
}
return find(payload, 0);
}
function create({ window: win, document: doc, onQuestions = () => {}, onStatus = () => {}, onBankChange = () => {}, observeNetwork = true }) {
let active = false;
let copyEnabled = false;
@@ -579,11 +792,14 @@ const QBAdapter = (() => {
let xhrSendWrapped = null;
let lastDOMSignature = '';
let networkGeneration = 0;
let requestSequence = 0;
let batchSequence = 0;
const known = new Map();
const xhrContext = new WeakMap();
const pendingXHR = new Set();
const banksById = new Map();
const banksByTask = new Map();
const knownSnapshotOrder = new Map();
let currentBank = null;
let pendingSelection = '';
let blockedForm = null;
@@ -613,6 +829,14 @@ const QBAdapter = (() => {
} catch (_) { return ''; }
}
function displayedQuestionCount() {
const progress = doc.querySelector('.yxtulcdsdk-answer-card__top');
const match = text(progress?.textContent).match(/(\d+)\s*\/\s*(\d+)(?!\d)/);
if (!match) return null;
const done = Number(match[1]), total = Number(match[2]);
return total >= 0 && total <= 100000 && done <= total ? total : null;
}
function bankDOM() {
const selected = doc.querySelector('.task-content-active .group-title, .task-content-active .task-content-color-active');
const selectedName = text(selected?.textContent);
@@ -636,7 +860,7 @@ const QBAdapter = (() => {
function syncBank() {
if (!isSupportedPage()) {
const empty = { bankId: '', bankName: '', taskId: '', loading: false };
const empty = { bankId: '', bankName: '', taskId: '', loading: false, questionCount: null };
const changed = !currentBank || JSON.stringify(currentBank) !== JSON.stringify(empty);
currentBank = empty;
pendingSelection = '';
@@ -669,7 +893,11 @@ const QBAdapter = (() => {
const named = [...banksById.values()].filter(bank => flat(bank.bankName) === flat(state.bankName));
if (named.length === 1) bankId = named[0].bankId;
}
const next = { bankId, bankName: state.bankName, taskId: state.taskId, loading: state.mismatch || Boolean(blockedForm) };
const loading = state.mismatch || Boolean(blockedForm);
const displayed = loading ? null : displayedQuestionCount();
const remembered = bankId && banksById.get(bankId)?.questionCount;
const questionCount = displayed !== null ? displayed : Number.isSafeInteger(remembered) ? remembered : null;
const next = { bankId, bankName: state.bankName, taskId: state.taskId, loading, questionCount };
const changed = !currentBank || JSON.stringify(next) !== JSON.stringify(currentBank);
currentBank = next;
lastFormSnapshot = snapshot;
@@ -700,6 +928,7 @@ const QBAdapter = (() => {
const useCurrent = !saved || sameTask || (!context.answerResponse && !current.loading);
context.bankName = useCurrent ? current.bankName : saved.bankName;
context.taskId = useCurrent ? current.taskId : saved.taskId;
context.requestSequence = ++requestSequence;
return context;
}
@@ -707,18 +936,30 @@ const QBAdapter = (() => {
const distinct = new Map();
for (const record of records) if (record.bankId) distinct.set(record.bankId, record);
for (const record of distinct.values()) {
const previous = banksById.get(record.bankId);
if (!context.answerResponse && previous?.requestSequence > context.requestSequence) continue;
const name = record.bankName || context.bankName;
const taskId = flat(name) === flat(context.bankName) ? context.taskId || '' : '';
const value = { bankId: record.bankId, bankName: name, taskId };
const value = { bankId: record.bankId, bankName: name, taskId,
questionCount: Number.isSafeInteger(context.questionCount) ? context.questionCount : previous?.questionCount ?? null,
requestSequence: Math.max(previous?.requestSequence || 0, context.requestSequence || 0) };
banksById.set(record.bankId, value);
if (value.taskId && distinct.size === 1) banksByTask.set(value.taskId, value);
}
}
function emit(records) {
function emit(records, context = {}) {
if (!records.length) return;
const staleBanks = new Set();
if (context.fullSnapshot) {
for (const bankId of new Set(records.map(record => record.bankId))) {
if ((knownSnapshotOrder.get(bankId) || 0) > (context.requestSequence || 0)) { staleBanks.add(bankId); continue; }
for (const [key, old] of known) if (old.bankId === bankId) known.delete(key);
knownSnapshotOrder.set(bankId, context.requestSequence || 0);
}
}
for (const q of records) {
if (q.questionId) known.set(`${q.bankId}\u001f${q.questionId}`, q);
if (q.questionId && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q);
}
try { onQuestions(records); } catch (error) { status(`保存题目失败:${error.message || error}`); }
}
@@ -761,12 +1002,23 @@ const QBAdapter = (() => {
correctOptionIds: [], answerTexts: [], answerStatus: 'missing', explanation: '',
source: { kind: 'dom', url: locationURL().split('?')[0].split('#')[0], observedAt: new Date().toISOString() }
};
const matches = [...known.values()].filter(item => signature(item) === signature(q) &&
const number = text(form.querySelector('.yxtulcdsdk-form__sub')?.textContent).match(/^\s*(\d+)/);
const questionIndex = number && Number(number[1]) >= 1 && Number(number[1]) <= 100000 ? Number(number[1]) - 1 : null;
if (questionIndex !== null) q.questionIndex = questionIndex;
let matches = [...known.values()].filter(item => signature(item) === signature(q) &&
(q.bankId ? item.bankId === q.bankId : flat(item.bankName) === flat(q.bankName)));
if (questionIndex !== null) {
const indexed = matches.filter(item => item.questionIndex === questionIndex);
if (indexed.length) matches = indexed;
else if (matches.length && matches.every(item => Number.isSafeInteger(item.questionIndex))) return null;
}
if (blockedForm && (!bank.bankId || matches.length !== 1)) return null;
if (matches.length === 1) {
q.questionId = matches[0].questionId;
q.bankId = matches[0].bankId;
if (Number.isSafeInteger(matches[0].questionIndex)) q.questionIndex = matches[0].questionIndex;
if (Number.isSafeInteger(matches[0].contentOccurrence)) q.contentOccurrence = matches[0].contentOccurrence;
if (matches[0].source?.batchId) q.source.batchId = matches[0].source.batchId;
if (blockedForm) { blockedForm = null; syncBank(); }
}
// Only a row explicitly labelled standard answer can establish correctness.
@@ -807,9 +1059,14 @@ const QBAdapter = (() => {
function observePayload(payload, context) {
if (!active) return;
try {
const records = extractQuestions(payload, { ...context, knownQuestions: known });
rememberBanks(context, records);
emit(records);
const observedAt = new Date().toISOString();
const batchId = `${observedAt}:${++batchSequence}`;
const snapshotContext = { ...context, observedAt, batchId,
questionCount: context.answerResponse ? null : responseQuestionCount(payload) };
const records = extractQuestions(payload, { ...snapshotContext, knownQuestions: known });
snapshotContext.fullSnapshot = !context.answerResponse && records.some(record => Number.isSafeInteger(record.questionIndex));
rememberBanks(snapshotContext, records);
emit(records, snapshotContext);
syncBank();
if (records.length) {
const names = [...new Set(records.map(record => record.bankName).filter(Boolean))];
@@ -1236,6 +1493,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
(() => {
const CONFIG_KEY = 'huayi-qb.config.v1';
const RECORDS_KEY = 'huayi-qb.records.v1';
const BACKUP_KEY = 'huayi-qb.records.before-dedup.v1';
const DEFAULTS = { mode: 'hidden', keepLauncher: false, copyEnabled: true, collecting: true, tab: 'search' };
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const storage = {
@@ -1261,16 +1519,29 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
let records = [];
let recordsDirty = false;
let loadError = '';
let cleanupNotice = '';
function ensureCleanupBackup(original) {
const existing = storage.get(BACKUP_KEY, null);
if (existing === null) storage.set(BACKUP_KEY, { schemaVersion: 1, savedAt: new Date().toISOString(), records: original });
else if (!existing || !Array.isArray(existing.records)) throw new Error('去重备份结构异常,已保留原题库并停止覆盖');
}
try {
const saved = storage.get(RECORDS_KEY, []);
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
records = QBCore.upsert([], saved);
const compacted = QBCore.compactRecords(QBCore.upsert([], saved));
records = compacted.records;
const removed = saved.length - records.length;
if (removed > 0) {
ensureCleanupBackup(saved);
recordsDirty = true;
cleanupNotice = `已合并 ${removed} 条重复加载记录,原始数据已备份。`;
}
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
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 status = loadError || '准备就绪,可输入文字搜索,也可选中题干检索。';
let status = loadError || cleanupNotice || '可输入文字或划选题干检索。';
let storageError = false;
let mounted = false;
const createAdapter = () => QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged, observeNetwork: networkEnabled });
@@ -1322,7 +1593,9 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
// Merge other tabs before writing; GM change events also refresh an open panel.
const stored = storage.get(RECORDS_KEY, []);
if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖');
records = QBCore.upsert(stored, records);
const compacted = QBCore.compactRecords(QBCore.upsert(stored, records));
if (compacted.removed > 0) ensureCleanupBackup(stored);
records = compacted.records;
storage.set(RECORDS_KEY, records);
recordsDirty = false;
storageError = false;
@@ -1346,7 +1619,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
const names = record.bankId && namesById.get(record.bankId);
return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record;
});
records = QBCore.upsert(records, incoming);
const expanded = QBCore.upsert(records, incoming);
const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded);
records = compacted.records;
recordsDirty = true;
clearTimeout(saveTimer);
saveTimer = setTimeout(persistRecords, 250);
@@ -1500,6 +1776,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
shadow.getElementById('qb-context-name').title = name;
const pool = scopedRecords('__current__');
shadow.getElementById('qb-context-count').textContent = known ? `${bank?.loading ? '切换中 · ' : ''}本库已采集 ${pool.length} 题 · 已确认 ${pool.filter(record => record.answerStatus === 'confirmed').length} 题` : `本地共 ${records.length} 题 · 可划词或输入文字搜索`;
shadow.getElementById('qb-page-total').textContent = known && Number.isSafeInteger(bank?.questionCount) && bank.questionCount > 0 ? ` · 页面 ${bank.questionCount} 题` : '';
shadow.getElementById('qb-current-title').textContent = bank?.loading ? '题库切换中,等待当前题目加载。' : current?.stem || '打开练习题后,这里显示当前题目。';
shadow.getElementById('qb-current-bank').textContent = name;
}
@@ -1595,6 +1872,12 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
else download(JSON.stringify({ schemaVersion: 1, exportedAt: date.toISOString(), bankName, records: selected }, null, 2), filename, 'application/json;charset=utf-8');
tell(`已导出「${bankName}」${selected.length} 道题,其中 ${selected.filter(record => record.answerStatus === 'confirmed').length} 道有已确认答案。`);
}
function exportCleanupBackup() {
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} 条原始记录。`);
}
async function parseImportFile(file) {
if (file.size > 20 * 1024 * 1024) throw new Error('文件超过 20 MB,请拆分后导入');
const text = (await file.text()).replace(/^\uFEFF/, '');
@@ -1627,7 +1910,10 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入');
}
const before = records.length;
const merged = QBCore.upsert(records, incoming);
const expanded = QBCore.upsert(records, incoming);
const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded);
const merged = compacted.records;
records = merged;
recordsDirty = true;
persistRecords();
@@ -1637,16 +1923,18 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
}
const css = `
:host{all:initial;position:fixed;right:18px;bottom:18px;z-index:2147483646;color:#17283d;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;pointer-events:none;color-scheme:light}
*,*:before,*:after{box-sizing:border-box} [hidden]{display:none!important} button,input,select{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid #63d3c7;outline-offset:2px}
#qb-panel{pointer-events:auto;width:390px;max-width:calc(100vw - 24px);max-height:calc(100dvh - 36px);display:flex;flex-direction:column;border:1px solid #dbe4ed;border-radius:18px;background:#f7f9fc;box-shadow:0 16px 60px #0d24362c;overflow:hidden}
.header{background:#152a40;color:#fff;padding:15px 16px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-shrink:0}.brand{font-size:16px;font-weight:700;letter-spacing:.5px}.subtitle{font-size:11px;color:#bed0df;margin-top:2px}.tools{display:flex;gap:5px}.tools button{border:1px solid #526578;color:#e8f4ff;background:transparent;padding:5px 8px;border-radius:7px;font-size:12px;white-space:nowrap}.tools button:hover{background:#294359}
.body{overflow:auto;overscroll-behavior:contain;min-height:0}.collapsed .body{display:none}.tabs{display:flex;gap:4px;padding:10px 12px 0;background:#fff;border-bottom:1px solid #e7edf3}.tabs button{flex:1;padding:9px 4px;background:none;border:0;border-bottom:3px solid transparent;color:#6b7b8f;font-weight:600}.tabs button.active{border-bottom-color:#008d82;color:#00786e}
.view{padding:14px}.search-wrap{display:flex;gap:7px}.search-wrap input{width:100%;min-width:0;border:1px solid #cdd9e4;border-radius:9px;padding:10px 11px;background:#fff;color:#17283d}.btn{border:1px solid #ccd8e4;border-radius:8px;padding:8px 11px;background:#fff;color:#284059;white-space:nowrap}.btn:hover{background:#edf5f8}.btn.primary{background:#00877d;border-color:#00877d;color:white}.btn.primary:hover{background:#006f66}.btn.small{padding:5px 9px;font-size:12px}
.context{padding:10px 15px;background:#eaf2f6;border-bottom:1px solid #dce6ee}.context-label{color:#718599;font-size:10px}.context strong{display:block;color:#25445b;font-size:12px;line-height:1.6;overflow-wrap:anywhere}.context-count{font-size:10px;color:#587487;margin-top:3px}.search-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:#3e5d74;margin-bottom:8px}.text-button{border:0;background:none;color:#6a7f92;font-size:11px;padding:3px}.text-button:hover{color:#007b70}.search-filters{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:8px;margin-top:10px}.search-filters label{min-width:0;color:#72869a;font-size:10px}.search-filters select{display:block;width:100%;min-width:0;border:1px solid #d1dce6;background:#fff;padding:7px 4px;margin-top:3px;border-radius:7px;color:#37556c;font-size:11px}.export-name{font-size:13px!important;color:#244e65!important;font-weight:600;overflow-wrap:anywhere;margin:4px 0 10px!important}.filename{font-size:10px!important;word-break:break-all;color:#758c9d!important}.bank-list{max-height:130px;overflow:auto;margin-top:8px}.bank-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;border-top:1px solid #edf1f5;padding:7px 0;font-size:11px;color:#657f94}.bank-row span{overflow-wrap:anywhere}.bank-row strong{flex-shrink:0;color:#486b80;font-weight:500}
.hint{font-size:11px;color:#738298;margin:8px 0 12px}.empty{padding:24px 14px;text-align:center;color:#597087;border:1px dashed #cedce5;border-radius:12px;background:#fff}.empty strong{color:#29475f;font-size:15px}.empty p{font-size:12px;line-height:1.8;margin:8px 0 0}.result{border:1px solid #e0e7ee;border-radius:12px;background:#fff;padding:13px;margin-bottom:10px}.meta{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:#8090a2}.badge{padding:2px 7px;border-radius:5px;background:#edf1f6;color:#536b82}.badge.confirmed{background:#e3f6ef;color:#137e55}.badge.missing{background:#fff4d8;color:#966819}.badge.conflict{background:#ffe9e7;color:#b74740}.stem{font-size:13px;line-height:1.75;margin:10px 0;white-space:pre-wrap;overflow-wrap:anywhere}.answer{border-radius:9px;background:#eaf7f3;padding:10px 11px;display:flex;flex-direction:column;gap:4px;overflow-wrap:anywhere}.answer.pending{background:#fff6e7}.answer-label{font-size:10px;letter-spacing:.5px;color:#517467}.answer strong{color:#086550;font-size:16px;line-height:1.6;white-space:pre-wrap}.answer.pending strong{color:#936426;font-size:14px}.mapping{font-size:11px;color:#567367}.source{font-size:10px;color:#8291a1;margin-top:10px;overflow-wrap:anywhere}.explanation{margin-top:9px;color:#617387;font-size:12px}.explanation summary{cursor:pointer}.explanation p{white-space:pre-wrap;overflow-wrap:anywhere}
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-bottom:12px}.stat{background:#fff;border:1px solid #e3eaf1;border-radius:9px;padding:9px 3px;text-align:center}.stat strong{display:block;font-size:21px;color:#234257}.stat span{font-size:10px;color:#7a8ca1}.section{padding:13px;border:1px solid #e0e7ee;background:#fff;border-radius:12px;margin-bottom:12px}.section h3{font-size:13px;margin:0 0 9px}.section p{font-size:12px;color:#718297;margin:5px 0;line-height:1.8;overflow-wrap:anywhere}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.spread{justify-content:space-between}.state{font-size:11px;color:#088574;background:#e7f6ee;border-radius:9px;padding:2px 8px}.state.paused{color:#8c672e;background:#fff3dd}#qb-current-title{font-size:12px;color:#344f66;margin:8px 0;max-height:80px;overflow:auto;white-space:pre-wrap}.scope{display:block;font-size:12px;color:#62778b;margin-bottom:10px}.scope select{margin-top:5px;width:100%;padding:8px;border:1px solid #cfdae5;border-radius:8px;background:#fff;color:#263f57}.check{display:flex;gap:9px;align-items:flex-start;margin:12px 0;color:#304e67;font-size:13px}.check input{accent-color:#00877d;margin-top:4px}.check small{display:block;font-size:11px;color:#7b8da0;margin-top:3px}.kbd{background:#edf1f5;border:1px solid #dce3eb;border-radius:4px;padding:2px 6px;font-family:monospace;font-size:12px;color:#374e62}.footer{padding:9px 14px 12px;font-size:11px;color:#718297;background:#eff4f8;overflow-wrap:anywhere;line-height:1.6;border-top:1px solid #e1e8ef}#qb-status[data-error="true"]{color:#b0473c}.footline{display:flex;gap:6px;align-items:center;margin-bottom:5px}.dot{width:5px;height:5px;background:#079a87;border-radius:50%}#qb-launcher{pointer-events:auto;border:1px solid #1b776f;background:#153f42;color:#fff;box-shadow:0 6px 22px #173b3c33;border-radius:50%;width:54px;height:54px;font-size:13px;font-weight:600}#qb-launcher:hover{background:#086f65}
@media(max-width:480px){:host{right:12px;bottom:12px}#qb-panel{max-height:calc(100dvh - 24px)}.view{padding:12px}.header{padding:12px}.brand{font-size:15px}}
:host{all:initial;position:fixed;right:12px;bottom:12px;z-index:2147483646;color:#555;font:12px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;pointer-events:none;color-scheme:light}
*,*:before,*:after{box-sizing:border-box}[hidden]{display:none!important}button,input,select{font:inherit}button{cursor:pointer}button:disabled{cursor:default;color:#aaa}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:1px solid #999;outline-offset:1px}
#qb-panel{pointer-events:auto;width:300px;max-width:calc(100vw - 24px);max-height:min(440px,calc(100dvh - 24px));display:flex;flex-direction:column;border:1px solid #e7e7e7;border-radius:5px;background:#fff;box-shadow:0 1px 5px #00000009;overflow:hidden}
.header{background:#fff;color:#555;padding:6px 9px;display:flex;align-items:center;justify-content:space-between;gap:6px;flex-shrink:0;border-bottom:1px solid #eee}.brand{font-size:12px;font-weight:500}.subtitle{display:none}.tools{display:flex;gap:5px}.tools button{border:0;color:#888;background:#fff;padding:2px 4px;font-size:10px;white-space:nowrap}.tools button:hover{color:#444;background:#f7f7f7}
.body{overflow:auto;overscroll-behavior:contain;min-height:0;scrollbar-width:thin;scrollbar-color:#ddd #fff}.collapsed .body{display:none}.context{padding:6px 9px;background:#fff;border-bottom:1px solid #eee}.context strong{display:block;color:#555;font-size:11px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.context-count{font-size:10px;color:#999;margin-top:2px;line-height:1.35}
.tabs{display:flex;padding:0 6px;background:#fff;border-bottom:1px solid #eee}.tabs button{flex:1;padding:6px 2px;background:#fff;border:0;border-bottom:1px solid transparent;color:#999;font-size:11px;white-space:nowrap}.tabs button.active{border-bottom-color:#888;color:#555}.view{padding:8px}
.search-label{display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#888;margin-bottom:5px}.text-button{border:0;background:#fff;color:#999;font-size:10px;padding:1px 3px}.text-button:hover{color:#555}.search-wrap{display:flex;gap:5px}.search-wrap input{width:100%;min-width:0;border:1px solid #ddd;border-radius:3px;padding:5px 6px;background:#fff;color:#555;font-size:11px}.btn{border:1px solid #ddd;border-radius:3px;padding:5px 8px;background:#fff;color:#666;font-size:11px;white-space:nowrap}.btn.primary{background:#fff;border-color:#ccc;color:#555}.btn:hover,.btn.primary:hover{background:#f7f7f7}.btn.small{padding:3px 6px;font-size:10px}
.search-filters{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:5px;margin-top:6px}.search-filters label{min-width:0;color:#999;font-size:10px}.search-filters select{display:block;width:100%;min-width:0;border:1px solid #e2e2e2;background:#fff;padding:3px 2px;margin-top:2px;border-radius:3px;color:#777;font-size:10px}.hint{font-size:10px;color:#999;margin:5px 0 8px;line-height:1.4}
.empty{padding:14px 8px;text-align:center;color:#999;border:1px dashed #e8e8e8;border-radius:3px;background:#fff}.empty strong{color:#888;font-size:12px;font-weight:400}.empty p{font-size:10px;line-height:1.6;margin:5px 0 0}.result{border:1px solid #eee;border-radius:3px;background:#fff;padding:8px;margin-bottom:6px}.meta{display:flex;align-items:center;justify-content:space-between;gap:5px;font-size:10px;color:#999}.badge{padding:0 3px;border:1px solid #e4e4e4;border-radius:2px;background:#fff;color:#888}.badge.confirmed,.badge.missing,.badge.conflict{background:#fff;color:#777}.badge.conflict{font-weight:600}.stem{font-size:11px;line-height:1.65;margin:6px 0;white-space:pre-wrap;overflow-wrap:anywhere}.answer,.answer.pending{border-radius:2px;background:#fff;border-left:2px solid #ddd;padding:3px 7px;display:flex;flex-direction:column;gap:2px;overflow-wrap:anywhere}.answer-label{font-size:10px;color:#999}.answer strong{color:#555;font-size:13px;line-height:1.5;white-space:pre-wrap}.answer.pending strong{color:#777;font-size:11px}.mapping{font-size:10px;color:#999}.source{font-size:9px;color:#aaa;margin-top:6px;overflow-wrap:anywhere}.explanation{margin-top:5px;color:#888;font-size:10px}.explanation summary{cursor:pointer}.explanation p{white-space:pre-wrap;overflow-wrap:anywhere}
.section{padding:8px;border:1px solid #eee;background:#fff;border-radius:3px;margin-bottom:7px}.section h3{font-size:11px;font-weight:500;margin:0 0 5px;color:#555}.section p{font-size:10px;color:#999;margin:4px 0;line-height:1.55;overflow-wrap:anywhere}.row{display:flex;align-items:center;gap:5px;flex-wrap:wrap}.row.spread{justify-content:space-between}.scope{display:block;font-size:10px;color:#999;margin-bottom:5px}.scope select{margin-top:3px;width:100%;padding:4px 3px;border:1px solid #ddd;border-radius:3px;background:#fff;color:#666;font-size:11px}.export-name{font-size:10px!important;color:#777!important;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:3px 0 5px!important}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:3px;margin-bottom:6px}.stat{background:#fff;border:0;border-radius:0;padding:3px 0;text-align:center}.stat strong{display:block;font-size:16px;color:#666;font-weight:400}.stat span{font-size:9px;color:#aaa}.filename{font-size:9px!important;word-break:break-all;color:#aaa!important;line-height:1.4!important}.state,.state.paused{font-size:9px;color:#999;background:#fff;border-radius:2px;border:1px solid #eee;padding:0 3px}#qb-current-title{font-size:10px;color:#888;margin:5px 0;max-height:31px;overflow:auto;white-space:pre-wrap}.muted-bank{display:none}.bank-list{max-height:75px;overflow:auto;margin-top:5px}.bank-row{display:flex;align-items:flex-start;justify-content:space-between;gap:6px;border-top:1px solid #f1f1f1;padding:4px 0;font-size:10px;color:#999}.bank-row span{overflow-wrap:anywhere}.bank-row strong{flex-shrink:0;color:#888;font-weight:400}
.check{display:flex;gap:6px;align-items:flex-start;margin:7px 0;color:#666;font-size:11px}.check input{accent-color:#888;margin-top:2px}.check small{display:block;font-size:10px;color:#aaa;margin-top:2px}.kbd{background:#fff;border:1px solid #eee;border-radius:2px;padding:1px 4px;font-family:monospace;font-size:10px;color:#777}.footer{padding:5px 9px;font-size:9px;color:#aaa;background:#fff;overflow-wrap:anywhere;line-height:1.4;border-top:1px solid #eee;max-height:38px;overflow:auto}#qb-status[data-error="true"]{color:#777;font-weight:500}.footline{display:none}#qb-launcher{display:none!important}
@media(max-width:340px){:host{right:8px;bottom:8px}#qb-panel{max-width:calc(100vw - 16px);max-height:min(440px,calc(100dvh - 16px))}}
`;
function mount() {
@@ -1661,13 +1949,13 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
<section id="qb-panel" role="region" aria-label="华谊题库助手">
<header class="header"><div><div class="brand">题库助手</div><div class="subtitle">HUAYI · 本地练习资料</div></div><div class="tools"><button type="button" data-action="collapse" title="收起或展开内容" aria-expanded="true">收起</button><button type="button" data-action="hide" title="隐藏面板,Alt + Shift + Q 恢复">隐藏</button></div></header>
<div class="body">
<div class="context"><span class="context-label">当前题库</span><strong id="qb-context-name">正在识别题库…</strong><div class="context-count" id="qb-context-count"></div></div>
<div class="context"><strong id="qb-context-name">正在识别题库…</strong><div class="context-count"><span id="qb-context-count"></span><span id="qb-page-total"></span></div></div>
<nav class="tabs" role="tablist" aria-label="助手功能"><button type="button" data-tab="search" role="tab" aria-controls="qb-view-search">文字搜索</button><button type="button" data-tab="library" role="tab" aria-controls="qb-view-library">题库 / 导出</button><button type="button" data-tab="auto" role="tab" aria-controls="qb-view-auto">自动答题</button><button type="button" data-tab="settings" role="tab" aria-controls="qb-view-settings">设置</button></nav>
<div class="view" id="qb-view-search" data-view="search" role="tabpanel"><div class="search-label"><label for="qb-search">输入或粘贴文字查答案</label><button type="button" class="text-button" data-action="clear-search">清空</button></div><div class="search-wrap"><input id="qb-search" type="search" maxlength="4000" placeholder="题干、选项或答案关键词…" aria-label="文字搜索"><button type="button" class="btn primary" data-action="search">搜索</button></div><div class="search-filters"><label>搜索题库<select id="qb-search-scope" aria-label="搜索题库"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><label>搜索内容<select id="qb-search-field" aria-label="搜索内容"><option value="all">全部文字</option><option value="stem">仅题干</option><option value="options">仅选项</option><option value="answer">仅标准答案</option></select></label></div><p class="hint" id="qb-query-state"></p><div id="qb-results" aria-live="polite"></div></div>
<div class="view" id="qb-view-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"><div class="row spread"><h3>当前练习</h3><span class="state" id="qb-capture-state">采集中</span></div><p id="qb-current-bank"></p><div id="qb-current-title"></div><div class="row"><button type="button" class="btn small primary" data-action="capture-current">采集当前题</button><button type="button" class="btn small" data-action="toggle-capture">暂停采集</button></div><p>记录正常加载的练习题与标准答案。答案尚未返回的题目会标为待补,可在后续练习中继续补齐。</p></div>
<div class="section"><div class="row spread"><h3>已保存题库</h3><button type="button" class="btn small" data-action="import">导入多个题库</button></div><input id="qb-import-file" type="file" accept=".csv,.json,text/csv,application/json" multiple hidden><p>可一次选择多个 CSV / JSON,分别保留题库名称。无名称的文件使用文件名作为题库名。</p><p id="qb-library-summary"></p><div class="bank-list" id="qb-bank-list"></div></div>
<div class="section"><div class="row spread"><h3>当前练习</h3><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>
<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>
@@ -1677,6 +1965,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
<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><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>
<footer class="footer"><div class="footline"><span class="dot"></span><span>本地题库 · 标准答案优先</span></div><div id="qb-status" role="status"></div></footer>
</div>
@@ -1714,6 +2003,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
break;
case 'export-csv': exportData('csv'); break;
case 'export-json': exportData('json'); break;
case 'export-dedup-backup': exportCleanupBackup(); break;
case 'import': shadow.getElementById('qb-import-file').click(); break;
}
} catch (error) { tell(`操作失败:${error.message}`, true); }
@@ -1741,6 +2031,7 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
catch (error) { tell(`导入失败,题库未替换:${error.message}`, true); }
finally { input.value = ''; importing = false; button.disabled = false; }
});
if (cleanupNotice) status = cleanupNotice;
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode();
setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000);
}
@@ -1770,14 +2061,16 @@ if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
}
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
if (!remote || !Array.isArray(value)) return;
try { records = QBCore.upsert(records, value); if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } }
try { records = QBCore.compactRecords(QBCore.upsert(records, value)).records; if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } }
catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ }
});
if (config.collecting) adapter.start();
adapter.setCopyEnabled(Boolean(config.copyEnabled));
if (recordsDirty && !loadError) saveTimer = setTimeout(persistRecords, 250);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
else mount();
})();