Files
huayi-question-bank/dist/huayi-question-bank.user.js
T
2026-11-03 23:14:49 +08:00

3440 lines
222 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank
// @version 1.2.10
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
// @match http://*/*
// @match https://*/*
// @match file:///*
// @run-at document-start
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// ==/UserScript==
(function () {
'use strict';
// ---- src/core.js ----
/* Pure question-bank operations. No page access or network requests. */
const QBCore = (() => {
'use strict';
const TYPES = new Set(['single', 'multiple', 'judge', 'unknown']);
const STATUSES = new Set(['confirmed', 'missing', 'conflict']);
const ANSWER_EVIDENCE = new Set(['submitted-list', 'answer-response', 'bound-dom', 'explicit-standard']);
const TYPE_NAMES = { single: '单选题', multiple: '多选题', judge: '判断题', unknown: '未知' };
const STATUS_NAMES = { confirmed: '已确认', missing: '待补', conflict: '冲突' };
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
const text = (value) => value == null ? '' : String(value);
const unique = (values) => [...new Set(values)];
const clean = (value) => text(value).replace(/\u00a0/g, ' ').trim();
// Do not use NFKC: it changes superscripts, units and some mathematical symbols.
const normalized = (value) => clean(value).replace(/\s+/gu, '');
const normalizedStem = (value) => normalized(clean(value).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, ''));
const sorted = (values) => [...values].sort();
const sameArray = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b));
function idText(value, name) {
if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error(`${name} 是不安全的数字,请以字符串提供`);
if (value != null && !['string', 'number'].includes(typeof value)) throw new Error(`${name} 必须是字符串`);
return clean(value);
}
function stringArray(value, name) {
if (value == null) return [];
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' && typeof item !== 'number')) {
throw new Error(`${name} 必须是字符串数组`);
}
return unique(value.map((item) => idText(item, name)).filter(Boolean));
}
function judgeText(value) {
const result = clean(value);
if (/^(正确|对|是|√|true)$/i.test(result)) return '正确';
if (/^(错误|错|否|×|false)$/i.test(result)) return '错误';
return result;
}
const answerText = (value, type) => type === 'judge' ? judgeText(value) : clean(value);
const comparableAnswer = (value, type) => normalized(answerText(value, type));
const optionContents = (record) => record.options.map((option) => comparableAnswer(option.text, record.type));
const sameContent = (a, b) => normalizedStem(a.stem) === normalizedStem(b.stem) &&
(a.type === b.type || a.type === 'unknown' || b.type === 'unknown') && sameArray(optionContents(a), optionContents(b));
function sourceInfo(value) {
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
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.
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) {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('题目记录必须是对象');
if (typeof input.stem !== 'string' || !input.stem.trim()) throw new Error('题干不能为空');
const type = input.type == null ? 'unknown' : input.type;
if (!TYPES.has(type)) throw new Error(`不支持的题型:${text(type)}`);
if (input.answerStatus != null && !STATUSES.has(input.answerStatus)) throw new Error(`无效答案状态:${text(input.answerStatus)}`);
if (input.answerEvidence != null && !ANSWER_EVIDENCE.has(input.answerEvidence)) throw new Error(`无效答案证据:${text(input.answerEvidence)}`);
if (input.options != null && !Array.isArray(input.options)) throw new Error('选项必须是数组');
const options = (input.options || []).map((option, index) => {
if (typeof option === 'string') option = { text: option };
if (!option || typeof option !== 'object' || typeof option.text !== 'string' || !option.text.trim()) throw new Error('选项内容不能为空');
return { id: idText(option.id, '选项ID'), text: clean(option.text), label: clean(option.label) || (index < 26 ? String.fromCharCode(65 + index) : String(index + 1)) };
});
const correctOptionIds = stringArray(input.correctOptionIds, '正确选项ID');
let answerTexts = stringArray(input.answerTexts, '正确答案内容').map((value) => answerText(value, type));
answerTexts = unique(answerTexts);
let answerStatus = input.answerStatus || (correctOptionIds.length || answerTexts.length ? 'confirmed' : 'missing');
const conflicts = Array.isArray(input.conflicts) ? input.conflicts.filter((item) => item && typeof item === 'object').map((item) => JSON.parse(JSON.stringify(item))) : [];
let inconsistency = '';
const derivedTexts = [];
if (correctOptionIds.length && options.length) {
for (const id of correctOptionIds) {
const matches = options.filter((option) => option.id === id);
if (matches.length !== 1) inconsistency = '正确选项ID无法唯一对应已保存选项';
else derivedTexts.push(answerText(matches[0].text, type));
}
if (!inconsistency && answerTexts.length && !sameArray(unique(answerTexts.map((value) => comparableAnswer(value, type))), unique(derivedTexts.map((value) => comparableAnswer(value, type))))) {
inconsistency = '正确选项ID与正确答案内容不一致';
}
if (!answerTexts.length && !inconsistency) answerTexts = unique(derivedTexts);
}
if (['single', 'judge'].includes(type) && (answerTexts.length > 1 || correctOptionIds.length > 1)) inconsistency = '单选或判断题存在多个正确答案';
if (options.length && ['single', 'multiple', 'judge'].includes(type) && answerTexts.some((answer) => !options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(answer, type)))) {
inconsistency = '正确答案内容不属于已保存选项';
}
if (answerStatus === 'confirmed' && !answerTexts.length && !correctOptionIds.length) answerStatus = 'missing';
if (answerStatus !== 'missing' && inconsistency) {
answerStatus = 'conflict';
if (!conflicts.some((item) => item.reason === inconsistency)) conflicts.push({ reason: inconsistency, answerTexts: [...answerTexts], correctOptionIds: [...correctOptionIds] });
}
// Explicitly missing data must never carry an apparently confirmed answer forward.
if (answerStatus === 'missing') answerTexts = [];
const source = sourceInfo(input.source);
const record = {
bankId: idText(input.bankId, '题库ID'), bankName: clean(input.bankName) || '未命名题库',
questionId: idText(input.questionId, '题目ID'), type, stem: clean(input.stem), options,
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.answerEvidence != null) record.answerEvidence = input.answerEvidence;
if (input.answerSource || answerStatus === 'confirmed') record.answerSource = sourceInfo(input.answerSource || source);
record.key = recordKey(record);
return record;
}
function answerSignature(record) {
// IDs retain how many identical-text options are correct; answerTexts is a set.
if (record.correctOptionIds.length && record.options.length) {
const correct = record.options.filter((option) => record.correctOptionIds.includes(option.id));
return JSON.stringify(sorted(correct.map((option) => comparableAnswer(option.text, record.type))));
}
if (record.answerTexts.length) return JSON.stringify(sorted(record.answerTexts.map((value) => comparableAnswer(value, record.type))));
return JSON.stringify(sorted(record.correctOptionIds.map((value) => `id:${value}`)));
}
const answerProvenance = record => record?.answerSource || record?.source;
const legacyJudgeListSource = source => source?.kind === 'network' && /\/upm\/(?:wrong\/)?start(?:[?#]|$)/u.test(source.url || '');
const judgeAnswerResponseSource = source => source?.kind === 'network' && /\/upm\/[^/]+\/users\/[^/]+\/(?:wrong\/)?answer(?:[?#]|$)/u.test(source.url || '');
const trustedAnswerProvenance = record => ANSWER_EVIDENCE.has(record?.answerEvidence) || judgeAnswerResponseSource(answerProvenance(record)) ||
/^(?:csv-import|json-import|manual|manual-import|import)$/u.test(answerProvenance(record)?.kind || '');
// Old practice-list judgeAnswer values contained unsubmitted placeholders.
// This explicit migration preserves the raw input for a durable backup and
// touches only judge records with that evidenced legacy provenance or an
// unverified old DOM answer that could have inherited the same placeholder.
function repairLegacyJudgeAnswers(input) {
if (!Array.isArray(input)) throw new Error('题库必须是数组');
const changed = [];
const unverifiedLegacySource = record => (legacyJudgeListSource(answerProvenance(record)) || answerProvenance(record)?.kind === 'dom') && !trustedAnswerProvenance(record);
let records = input;
for (let index = 0; index < input.length; index++) {
const original = input[index];
if (original?.type !== 'judge') continue;
const snapshots = Array.isArray(original.conflicts) ? original.conflicts : [];
const contaminated = unverifiedLegacySource(original) && original.answerStatus !== 'missing' || snapshots.some(unverifiedLegacySource);
if (!contaminated) continue;
const record = normalizeRecord(original);
const candidates = [], unresolved = [];
const addCandidate = (candidate, snapshot) => {
if (unverifiedLegacySource(candidate)) return;
// An old DOM observation may merely have inherited the list value.
// Its presence in a contaminated record is not independent evidence.
if (!trustedAnswerProvenance(candidate)) {
const kind = answerProvenance(candidate)?.kind || 'unknown';
if (!['dom', 'unknown'].includes(kind)) unresolved.push(snapshot || conflictSnapshot(record, '未确认的判断题答案来源'));
return;
}
if (!candidate.answerTexts?.length && !candidate.correctOptionIds?.length) {
if (snapshot) unresolved.push(snapshot);
return;
}
try {
const source = answerProvenance(candidate);
const restored = normalizeRecord({ ...record, ...candidate, bankId: record.bankId, bankName: record.bankName,
questionId: record.questionId, source: record.source, answerSource: source,
answerEvidence: candidate.answerEvidence || (judgeAnswerResponseSource(source) ? 'answer-response' : undefined),
answerStatus: 'confirmed', conflicts: [] });
if (restored.answerStatus !== 'confirmed') {
if (snapshot) unresolved.push(snapshot);
else unresolved.push(conflictSnapshot(restored, '标准答案证据与选项不一致'));
} else candidates.push(restored);
} catch (_) { if (snapshot) unresolved.push(snapshot); }
};
for (const snapshot of snapshots) {
if (!snapshot || typeof snapshot !== 'object') continue;
addCandidate({ ...snapshot, answerEvidence: snapshot.answerEvidence }, snapshot);
}
if (record.answerStatus !== 'missing') addCandidate(record);
let repaired;
for (const candidate of candidates) repaired = repaired ? mergeNormalizedRecords(repaired, candidate) : candidate;
if (!repaired) repaired = normalizeRecord({ ...record, answerStatus: unresolved.length ? 'conflict' : 'missing',
answerTexts: [], correctOptionIds: [], answerSource: undefined, answerEvidence: undefined, conflicts: [] });
if (unresolved.length) repaired = normalizeRecord({ ...repaired, answerStatus: 'conflict',
conflicts: distinctConflicts([...repaired.conflicts, ...unresolved]) });
// Restore collection/identity metadata even when an older answer snapshot
// supplied the trustworthy stem, options and answer IDs atomically.
repaired = normalizeRecord({ ...record, ...repaired, source: record.source, updatedAt: record.updatedAt,
answerSource: repaired.answerSource, answerEvidence: repaired.answerEvidence,
questionIdAliases: record.questionIdAliases, questionIndex: record.questionIndex, contentOccurrence: record.contentOccurrence });
if (!own(record, 'questionIdAliases')) delete repaired.questionIdAliases;
if (JSON.stringify(repaired) === JSON.stringify(record)) continue;
if (records === input) records = input.slice();
records[index] = repaired;
changed.push({ index, key: record.key, reason: repaired.answerStatus === 'confirmed' ? 'recovered-proven-judge-answer' :
repaired.answerStatus === 'conflict' ? 'retained-proven-judge-conflict' : 'unverified-legacy-judge-answer', original });
}
return { records, changed };
}
// Old versions compared the list of ID-derived answers with a text set. When
// two correct options shared text, that comparison produced a false conflict.
// Repair only that evidenced shape; normalizeRecord keeps all other explicit
// conflicts conservative. The original inputs are returned for durable backup.
function repairDuplicateOptionConflicts(input) {
if (!Array.isArray(input)) throw new Error('题库必须是数组');
const repaired = [];
const legacyReason = '正确选项ID与正确答案内容不一致';
const mergeReason = '同一题目出现不同或不一致的标准答案';
const ids = values => Array.isArray(values) && values.length > 0 && values.every(value => typeof value === 'string' && clean(value)) &&
new Set(values.map(clean)).size === values.length ? values.map(clean) : null;
const answers = values => Array.isArray(values) && values.length > 0 && values.every(value => typeof value === 'string' && normalized(value))
? unique(values.map(normalized)) : null;
const optionSignature = options => {
if (!Array.isArray(options) || !options.length || options.some(option => !option || typeof option !== 'object' ||
typeof option.id !== 'string' || !clean(option.id) || typeof option.text !== 'string' || !normalized(option.text))) return null;
if (new Set(options.map(option => clean(option.id))).size !== options.length) return null;
return options.map(option => JSON.stringify([clean(option.id), normalized(option.text)]));
};
const records = input.map((original, index) => {
const record = normalizeRecord(original);
if (record.type !== 'multiple' || record.answerStatus !== 'conflict' || !Array.isArray(original.conflicts) || !original.conflicts.length) return record;
const optionIds = optionSignature(original.options);
const correctIds = ids(original.correctOptionIds);
const answerSet = answers(original.answerTexts);
if (!optionIds || !correctIds || !answerSet) return record;
const derived = correctIds.map(id => record.options.find(option => option.id === id));
if (derived.some(option => !option)) return record;
const derivedAnswers = derived.map(option => normalized(option.text));
if (new Set(derivedAnswers).size === derivedAnswers.length || !sameArray(unique(derivedAnswers), answerSet)) return record;
const source = original.answerSource || original.source;
const sameSource = candidate => candidate && typeof candidate === 'object' && !Array.isArray(candidate) && source &&
typeof source === 'object' && !Array.isArray(source) && (clean(source.batchId) || (clean(source.url) && clean(source.observedAt))) &&
['kind', 'url', 'observedAt', 'batchId'].every(field => clean(candidate[field]) === clean(source[field]));
const valid = original.conflicts.every(snapshot => {
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot) || ![legacyReason, mergeReason].includes(snapshot.reason)) return false;
const snapshotIds = ids(snapshot.correctOptionIds), snapshotAnswers = answers(snapshot.answerTexts);
if (!snapshotIds || !snapshotAnswers || !sameArray(snapshotIds, correctIds) || !sameArray(snapshotAnswers, answerSet)) return false;
if ((own(snapshot, 'stem') || own(snapshot, 'options')) && (!own(snapshot, 'stem') || !own(snapshot, 'options'))) return false;
if (own(snapshot, 'stem') && (typeof snapshot.stem !== 'string' || normalized(snapshot.stem) !== normalized(record.stem))) return false;
if (own(snapshot, 'options')) {
const signature = optionSignature(snapshot.options);
if (!signature || !sameArray(signature, optionIds)) return false;
}
for (const field of ['type', 'bankId', 'bankName', 'questionId']) if (own(snapshot, field) && snapshot[field] !== record[field]) return false;
if (own(snapshot, 'answerStatus') && !['confirmed', 'conflict'].includes(snapshot.answerStatus)) return false;
for (const field of ['source', 'answerSource']) if (own(snapshot, field) && !sameSource(snapshot[field])) return false;
for (const field of ['batchId', 'batch']) if (own(snapshot, field) &&
(typeof snapshot[field] !== 'string' || !clean(snapshot[field]) || clean(snapshot[field]) !== clean(source?.batchId))) return false;
return true;
});
if (!valid || !original.conflicts.some(snapshot => snapshot.reason === legacyReason)) return record;
const restored = normalizeRecord({ ...record, answerStatus: 'confirmed', conflicts: [] });
if (restored.answerStatus !== 'confirmed') return record;
repaired.push({ index, key: record.key, bankId: record.bankId, questionId: record.questionId, reason: 'duplicate-correct-option-text', original });
return restored;
});
return { records, repaired };
}
// Upgrade the old text-only representation only against an evidenced complete
// current network snapshot. A matching text fragment alone cannot prove that
// two image questions are equivalent.
function upgradeLegacyImageRecords(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) ||
new Set(incoming.map(record => record.questionIndex)).size !== expectedCount || new Set(incoming.map(record => record.questionId)).size !== expectedCount || incoming.some(record => !record.questionId)) return unchanged();
const marker = /\[图片:https?:\/\/[^\]\s]+\]/gu;
const strip = value => normalized(value.replace(marker, ''));
const imageCount = record => [record.stem, ...record.options.map(option => option.text)].reduce((count, value) => count + (value.match(marker)?.length || 0), 0);
const signature = record => JSON.stringify([record.type, strip(record.stem), sorted(record.options.map(option => strip(option.text)))]);
const existingBank = existing.filter(record => record.bankId === bankId);
const countSignatures = records => {
const map = new Map();
for (const record of records) { const key = signature(record); map.set(key, (map.get(key) || 0) + 1); }
return map;
};
const oldCounts = countSignatures(existingBank), newCounts = countSignatures(incoming);
const previousBySignature = new Map(), indexCounts = new Map(), originalByKey = new Map();
existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); });
for (const record of existingBank) {
const key = signature(record);
if (!previousBySignature.has(key)) previousBySignature.set(key, record);
indexCounts.set(record.questionIndex, (indexCounts.get(record.questionIndex) || 0) + 1);
}
const correctContents = record => {
if (record.answerStatus !== 'confirmed' || record.conflicts.length || !record.correctOptionIds.length || !record.options.length ||
record.options.some(option => !option.id) || new Set(record.options.map(option => option.id)).size !== record.options.length) return null;
const options = record.correctOptionIds.map(id => record.options.find(option => option.id === id));
return options.some(option => !option) ? null : options.map(option => strip(option.text));
};
const upgraded = [], removed = new Set();
for (let index = 0; index < incoming.length; index++) {
const current = incoming[index], key = signature(current);
if (!imageCount(current) || current.type === 'unknown' || current.options.length < 2 || !strip(current.stem) ||
oldCounts.get(key) !== 1 || newCounts.get(key) !== 1 || new Set(current.options.map(option => strip(option.text))).size !== current.options.length ||
current.options.some(option => !strip(option.text))) continue;
const previous = previousBySignature.get(key);
if (!previous.questionId || imageCount(previous) || previous.type !== current.type ||
!Number.isSafeInteger(previous.questionIndex) || previous.questionIndex !== current.questionIndex || !isList(previous.source) ||
(previous.source.batchId ? previous.source.batchId === batch : previous.source.observedAt === current.source.observedAt) ||
indexCounts.get(current.questionIndex) !== 1) continue;
const oldAnswers = correctContents(previous), newAnswers = correctContents(current);
if (!oldAnswers || !newAnswers || !sameArray(oldAnswers, newAnswers) ||
!sameArray(unique(previous.answerTexts.map(strip)), unique(current.answerTexts.map(strip)))) continue;
const replacement = normalizeRecord({ ...current, questionIdAliases: unique([...(current.questionIdAliases || []), previous.questionId, ...(previous.questionIdAliases || [])]) });
incoming[index] = replacement;
removed.add(previous.key);
upgraded.push({ original: originalByKey.get(previous.key), replacement,
bankId, expectedCount, batchId: batch, questionIndex: current.questionIndex });
}
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 = { '&times;': '×', '&plusmn;': '±' };
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();
const incomingCounts = new Map(), bankBySignature = new Map(), legacyBySignature = new Map(), bankByIndex = new Map(), originalByKey = new Map();
existing.forEach((record, index) => { if (!originalByKey.has(record.key)) originalByKey.set(record.key, existingInput[index]); });
const add = (map, key, record) => {
if (!map.has(key)) map.set(key, []);
map.get(key).push(record);
};
for (const record of incoming) {
const key = signature(record);
incomingCounts.set(key, (incomingCounts.get(key) || 0) + 1);
}
for (const record of bank) {
const key = signature(record);
add(bankBySignature, key, record);
add(bankByIndex, record.questionIndex, record);
if (hasEntity(record)) add(legacyBySignature, key, record);
}
for (let index = 0; index < incoming.length; index++) {
const current = incoming[index], key = signature(current);
if (hasEntity(current) || current.type === 'unknown' || !correctContents(current) || incomingCounts.get(key) !== 1) continue;
const candidates = legacyBySignature.get(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 ([...(bankBySignature.get(key) || []), ...(bankByIndex.get(current.questionIndex) || [])].some(record =>
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: originalByKey.get(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) {
const snapshot = { reason, stem: record.stem, options: record.options.map((option) => ({ ...option })), answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds], source: record.answerSource || record.source };
if (record.answerEvidence) snapshot.answerEvidence = record.answerEvidence;
return snapshot;
}
function distinctConflicts(conflicts) {
const byContent = new Map();
for (const item of conflicts) {
const key = JSON.stringify([item.reason, item.stem, item.options, item.answerTexts, item.correctOptionIds,
item.answerEvidence || '', item.source?.kind || '', item.source?.url || '']);
if (!byContent.has(key)) byContent.set(key, item);
}
return [...byContent.values()].slice(-40);
}
function mergeRecord(existing, incoming) {
return mergeNormalizedRecords(normalizeRecord(existing), normalizeRecord(incoming));
}
// Internal callers already validated both records. Keep validation at the
// public boundary and on the merged result without cloning both inputs again.
function mergeNormalizedRecords(oldRecord, newRecord) {
if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录');
const contentMatches = sameContent(oldRecord, newRecord);
const explanation = newRecord.explanation || oldRecord.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 sameOptionIdentities = oldRecord.options.length > 0 && oldRecord.options.every((option) => option.id) &&
unique(oldRecord.options.map((option) => option.id)).length === oldRecord.options.length &&
sameArray(oldRecord.options.map((option) => JSON.stringify([option.id, comparableAnswer(option.text, oldRecord.type)])),
newRecord.options.map((option) => JSON.stringify([option.id, comparableAnswer(option.text, newRecord.type)])));
const exactIdsDiffer = sameOptionIdentities && oldRecord.correctOptionIds.length && newRecord.correctOptionIds.length &&
!sameArray(oldRecord.correctOptionIds, newRecord.correctOptionIds);
const answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' &&
(answerSignature(oldRecord) !== answerSignature(newRecord) || exactIdsDiffer);
const contentChanged = !contentMatches && oldRecord.answerStatus !== 'missing';
if (conflictAlready || answersDiffer || contentChanged) {
const reason = contentChanged ? '同一题目ID的题干或选项发生变化' : '同一题目出现不同或不一致的标准答案';
if (oldRecord.answerStatus !== 'missing') conflicts.push(conflictSnapshot(oldRecord, reason));
if (newRecord.answerStatus !== 'missing') conflicts.push(conflictSnapshot(newRecord, reason));
const answerRecord = newRecord.answerStatus !== 'missing' ? newRecord : oldRecord;
return normalizeRecord({ ...base, answerTexts: answerRecord.answerTexts, correctOptionIds: answerRecord.correctOptionIds,
answerSource: answerRecord.answerSource || answerRecord.source, answerEvidence: answerRecord.answerEvidence, answerStatus: 'conflict',
conflicts: distinctConflicts(conflicts) });
}
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, ...metadata, bankName: newRecord.bankName || oldRecord.bankName, explanation,
updatedAt: newRecord.updatedAt, source: newRecord.source, answerSource: oldRecord.answerSource || oldRecord.source });
}
if (oldRecord.type === 'judge' && oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' &&
trustedAnswerProvenance(oldRecord) && !trustedAnswerProvenance(newRecord)) {
// Re-observing the same answer without evidence cannot replace the
// independently verified answer's provenance or its own option IDs.
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 });
}
function upsert(recordsArray, incomingRecords) {
if (!Array.isArray(recordsArray) || !Array.isArray(incomingRecords)) throw new Error('题库必须是数组');
const records = new Map();
const lastSeen = new Map();
for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) {
const record = normalizeRecord(item);
records.set(record.key, records.has(record.key) ? mergeNormalizedRecords(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()]);
const signatures = new WeakMap();
const signature = record => {
if (!signatures.has(record)) signatures.set(record, contentSignature(record));
return signatures.get(record);
};
const byBankAndId = new Map();
const positions = new Map();
for (const [index, record] of [...records.values()].entries()) {
positions.set(record.key, index);
if (!record.questionId) continue;
const key = JSON.stringify([bankIdentity(record), record.questionId]);
if (!byBankAndId.has(key)) byBankAndId.set(key, []);
byBankAndId.get(key).push(record.key);
}
for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) {
if (!records.has(target.key)) continue;
const queued = new Set(), knownAliases = new Set(), candidates = [];
let cursor = -1;
const enqueue = aliases => {
for (const alias of aliases) {
if (knownAliases.has(alias)) continue;
knownAliases.add(alias);
for (const key of byBankAndId.get(JSON.stringify([bankIdentity(target), alias])) || []) {
if (key === target.key || queued.has(key) || positions.get(key) <= cursor) continue;
queued.add(key);
// Retain the old Map traversal order, including when a merged
// record introduces further aliases during this pass.
let low = 0, high = candidates.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (positions.get(candidates[middle]) < positions.get(key)) low = middle + 1;
else high = middle;
}
candidates.splice(low, 0, key);
}
}
};
enqueue(records.get(target.key).questionIdAliases || []);
while (candidates.length) {
const key = candidates.shift();
cursor = positions.get(key);
const other = records.get(key);
if (!other) continue;
const current = records.get(target.key);
if (signature(current) !== signature(other)) continue;
if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue;
const merged = foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence });
records.set(target.key, merged);
records.delete(other.key);
enqueue(merged.questionIdAliases || []);
}
}
const repairedContents = new Set([...records.values()].filter(record => record.questionIdAliases?.length > 1)
.map(record => JSON.stringify([bankIdentity(record), signature(record)])));
for (const record of [...records.values()]) {
if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue;
if (repairedContents.has(JSON.stringify([bankIdentity(record), signature(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);
const identified = [...records.values()].filter((record) => record.questionId);
const sameBank = (a, b) => {
if (a.bankId && b.bankId) return a.bankId === b.bankId;
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 && sameBank(a, b) &&
(a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence);
const identifiedByContent = new Map();
for (const record of identified) {
const key = signature(record);
if (!identifiedByContent.has(key)) identifiedByContent.set(key, []);
identifiedByContent.get(key).push(record);
}
const candidates = new Map(), candidateCounts = new Map();
for (const record of anonymous) {
const matches = (identifiedByContent.get(signature(record)) || []).filter(other => possiblePair(record, other));
candidates.set(record.key, matches);
for (const match of matches) candidateCounts.set(match.key, (candidateCounts.get(match.key) || 0) + 1);
}
for (const record of anonymous) {
const matches = candidates.get(record.key);
if (matches.length !== 1) continue;
const target = matches[0];
if (candidateCounts.get(target.key) !== 1) continue;
const promoted = normalizeRecord({ ...record, bankId: target.bankId, bankName: target.bankName, questionId: target.questionId });
const currentTarget = records.get(target.key);
const merged = lastSeen.get(record.key) < lastSeen.get(target.key) ? mergeNormalizedRecords(promoted, currentTarget) : mergeNormalizedRecords(currentTarget, promoted);
records.set(target.key, merged);
records.delete(record.key);
}
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'));
if (group.length === 1 && first.bankId === meta.bankId && first.bankName === meta.bankName && first.questionId === meta.questionId &&
(meta.contentOccurrence == null || meta.contentOccurrence === first.contentOccurrence) &&
(!own(meta, 'questionIdAliases') || JSON.stringify(meta.questionIdAliases) === JSON.stringify(first.questionIdAliases))) return first;
let result;
for (const record of ordered) {
const rebased = normalizeRecord({ ...record, ...meta });
result = result ? mergeNormalizedRecords(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;
entry.signature = contentSignature(record);
entry.bank = bankIdentity(record);
if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence;
const explicit = record.source.batchId || record.answerSource?.batchId;
if (explicit) entry.batch = `${entry.bank}:${explicit}`;
else if (entry.occurrence == null) {
const source = listSource(record);
if (!source) continue;
const key = JSON.stringify([entry.bank, 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, entry.signature]);
entry.occurrence = counters.get(key) || 0;
counters.set(key, entry.occurrence + 1);
}
const groups = new Map();
const byContent = new Map();
const byContentAlias = new Map();
const indexAliases = (key, entry) => {
if (!byContentAlias.has(entry.content)) byContentAlias.set(entry.content, new Map());
const aliases = byContentAlias.get(entry.content);
for (const id of [entry.record.questionId, ...(entry.record.questionIdAliases || [])]) {
if (!id) continue;
if (!aliases.has(id)) aliases.set(id, new Set());
aliases.get(id).add(key);
}
};
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([entry.bank, entry.signature]);
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);
indexAliases(key, entry);
}
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 = byContentAlias.get(entry.content)?.get(entry.record.questionId);
if (entry.record.questionId && matchingAlias?.size === 1) key = matchingAlias.values().next().value;
else if (matches?.size === 1) key = matches.values().next().value;
else if (matches?.size > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue;
else key = `unresolved:${entry.record.key}`;
add(key, entry);
if (matches?.has(key)) indexAliases(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);
return new Set(chars.slice(0, -1).map((char, index) => char + chars[index + 1]));
}
function search(records, query, { limit = 8, field = 'all', offset = 0 } = {}) {
const queryText = clean(query).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, '');
const needle = normalized(queryText);
if (needle.length < 2) return [];
const selectedField = ['all', 'stem', 'options', 'answer'].includes(field) ? field : 'all';
const terms = unique(queryText.split(/\s+/u).map(normalized).filter(Boolean));
const queryGrams = bigrams(needle);
const result = [];
for (const record of records) {
const haystack = normalizedStem(record.stem);
// Each option/answer stays a separate searchable segment. Joining option A
// and option B would create phrases that never existed in either option.
const fields = {
stem: [haystack],
options: (record.options || []).map((option) => normalized(option.text)),
answer: record.answerStatus === 'confirmed' ? (record.answerTexts || []).map(normalized) : [],
};
const allowed = selectedField === 'all' ? ['stem', 'options', 'answer'] : [selectedField];
let match, score = 0, matchedField;
const prefer = (candidateField, candidateScore, candidateMatch = 'partial') => {
if (candidateScore > score) { matchedField = candidateField; score = candidateScore; match = candidateMatch; }
};
if (allowed.includes('stem')) {
if (haystack === needle) prefer('stem', 1, 'exact');
else if (haystack.includes(needle)) prefer('stem', 0.86 + 0.08 * needle.length / haystack.length);
}
for (const candidateField of ['options', 'answer']) {
if (!allowed.includes(candidateField)) continue;
const matching = fields[candidateField].filter((segment) => segment.includes(needle));
if (matching.length) {
const coverage = Math.max(...matching.map((segment) => needle.length / segment.length));
prefer(candidateField, (candidateField === 'options' ? 0.72 : 0.60) + 0.06 * coverage);
}
}
if (terms.length > 1) {
const allIn = (names) => terms.every((term) => names.some((name) => fields[name].some((segment) => segment.includes(term))));
if (allIn(allowed)) {
if (allowed.includes('stem') && allIn(['stem'])) prefer('stem', 0.82);
else if (allowed.includes('options') && allIn(['options'])) prefer('options', 0.70);
else if (allowed.includes('answer') && allIn(['answer'])) prefer('answer', 0.58);
else {
// Cross-field AND is available only with the explicit all-fields scope.
const firstField = allowed.find((name) => terms.some((term) => fields[name].some((segment) => segment.includes(term))));
prefer(firstField, firstField === 'stem' ? 0.80 : firstField === 'options' ? 0.68 : 0.56);
}
}
}
if (!match && allowed.includes('stem') && needle.length >= 4 && terms.length === 1) {
const grams = bigrams(haystack);
const intersection = [...queryGrams].filter((gram) => grams.has(gram)).length;
const similarity = 2 * intersection / (queryGrams.size + grams.size || 1);
if (similarity >= 0.45) prefer('stem', similarity * 0.50, 'similar');
}
if (match) result.push({ record, match, score, matchedField });
}
const stableKey = (record) => JSON.stringify([record.bankId || '', record.bankName || '', record.questionId || '', record.key || '']);
result.sort((a, b) => b.score - a.score || a.record.stem.localeCompare(b.record.stem, 'zh-CN') || stableKey(a.record).localeCompare(stableKey(b.record), 'zh-CN'));
const start = Math.max(0, Math.floor(Number(offset) || 0));
const count = Math.max(0, Math.min(100, Math.floor(Number(limit) || 0)));
return result.slice(start, start + count);
}
function mapAnswers(recordInput, currentInput) {
let record, current;
try { record = normalizeRecord(recordInput); current = normalizeRecord(currentInput); }
catch (_) { return { status: 'unmapped', labels: [], answerTexts: [] }; }
const answerTexts = [...record.answerTexts];
const result = (status, labels = []) => ({ status, labels, answerTexts });
if (record.answerStatus === 'conflict') return result('ambiguous');
if (record.answerStatus !== 'confirmed' || !answerTexts.length) return result('unmapped');
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);
const candidates = current.options.filter((option) => comparableAnswer(option.text, record.type) === expected);
if (!candidates.length) return result('unmapped');
const originalCorrect = record.options.filter((option) => record.correctOptionIds.includes(option.id) && comparableAnswer(option.text, record.type) === expected);
for (const original of originalCorrect) {
const idCandidates = original.id ? current.options.filter((option) => option.id === original.id) : [];
if (idCandidates.length > 1) return result('ambiguous');
if (idCandidates.length === 1 && comparableAnswer(idCandidates[0].text, record.type) !== expected) return result('unmapped');
}
if (candidates.length > 1) {
const originalGroup = record.options.filter((option) => comparableAnswer(option.text, record.type) === expected);
// Repeated text is safe only when every occurrence is explicitly correct.
if (record.type !== 'multiple' || originalCorrect.length !== originalGroup.length || originalCorrect.length !== candidates.length) return result('ambiguous');
labels.push(...candidates.map((option) => option.label));
} else labels.push(candidates[0].label);
}
if (unique(labels).length !== labels.length) return result('ambiguous');
return result('matched', labels);
}
function csvSafe(value) {
const result = text(value);
// CSV quoting alone does not stop spreadsheet formula evaluation or long-ID rounding.
return /^(?:\s*[=+\-@]|[\t\r]|\d{16,}$)/u.test(result) ? `'${result}` : result;
}
function csvRestore(value) {
return value.startsWith("'") && csvSafe(value.slice(1)) === value ? value.slice(1) : value;
}
function csvCell(value) { return `"${csvSafe(value).replace(/"/g, '""')}"`; }
function toCSV(records) {
const canonical = records.map(normalizeRecord);
const optionCount = Math.max(0, ...canonical.map((record) => record.options.length));
const optionNames = Array.from({ length: optionCount }, (_, index) => `选项${index < 26 ? String.fromCharCode(65 + index) : index + 1}`);
const headers = ['来源题库', '题库ID', '题目ID', '题型', '题干', ...optionNames, '正确答案内容', '采集时答案字母', '解析', '答案状态', '答案来源', '采集时间', '完整记录JSON'];
const rows = [headers];
for (const record of canonical) {
const mapped = mapAnswers(record, record);
rows.push([record.bankName, record.bankId, record.questionId, TYPE_NAMES[record.type], record.stem,
...Array.from({ length: optionCount }, (_, index) => record.options[index]?.text || ''),
record.answerTexts.join('\n'), mapped.status === 'matched' ? mapped.labels.join(',') : '', record.explanation,
STATUS_NAMES[record.answerStatus], record.answerSource?.kind || record.source.kind, record.answerSource?.observedAt || record.source.observedAt, JSON.stringify(record)]);
}
return '\uFEFF' + rows.map((row) => row.map(csvCell).join(',')).join('\r\n') + '\r\n';
}
function parseCSV(input) {
if (typeof input !== 'string') throw new Error('CSV内容必须是文字');
const value = input.replace(/^\uFEFF/, '');
const rows = [];
let row = [], cell = '', quoted = false, closed = false;
const finishCell = () => { row.push(cell); cell = ''; closed = false; };
const finishRow = () => { finishCell(); if (row.some((item) => item !== '')) rows.push(row); row = []; };
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
if (quoted) {
if (char === '"') {
if (value[index + 1] === '"') { cell += '"'; index += 1; }
else { quoted = false; closed = true; }
} else cell += char;
} else if (char === ',') finishCell();
else if (char === '\n' || char === '\r') { finishRow(); if (char === '\r' && value[index + 1] === '\n') index += 1; }
else if (char === '"') {
if (cell || closed) throw new Error('CSV引号位置错误');
quoted = true;
} else {
if (closed) throw new Error('CSV引号后包含非法字符');
cell += char;
}
}
if (quoted) throw new Error('CSV存在未闭合引号');
if (cell || row.length || closed) finishRow();
return rows;
}
function importedArray(value, type, options) {
const trimmed = clean(value);
if (!trimmed) return [];
if (trimmed.startsWith('[')) {
let result;
try { result = JSON.parse(trimmed); } catch (_) { throw new Error('正确答案内容JSON格式错误'); }
return stringArray(result, '正确答案内容');
}
if (type !== 'multiple' || options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(trimmed, type))) return [trimmed];
const answers = trimmed.split(/\r?\n/).map(clean).filter(Boolean);
if (answers.some((answer) => !options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(answer, type)))) throw new Error('多选答案请使用JSON数组,或每行填写一个完整选项内容');
return answers;
}
function fromCSV(value) {
const rows = parseCSV(value);
if (!rows.length) throw new Error('CSV为空');
const headers = rows.shift().map((item) => clean(csvRestore(item)));
if (unique(headers).length !== headers.length) throw new Error('CSV存在重复列名');
if (!headers.includes('完整记录JSON') && (!headers.includes('题干') || !headers.includes('题型'))) throw new Error('CSV需要“题干、题型”列或“完整记录JSON”列');
const typeAliases = { ...Object.fromEntries(Object.entries(TYPE_NAMES).map(([key, name]) => [name, key])), 单选: 'single', 多选: 'multiple', 判断: 'judge', single: 'single', multiple: 'multiple', judge: 'judge', unknown: 'unknown' };
const statusAliases = { ...Object.fromEntries(Object.entries(STATUS_NAMES).map(([key, name]) => [name, key])), confirmed: 'confirmed', missing: 'missing', conflict: 'conflict', 无答案: 'missing', 待核验: 'missing' };
const records = [];
for (let index = 0; index < rows.length; index += 1) {
const cells = rows[index];
try {
if (cells.length !== headers.length) throw new Error(`列数应为${headers.length},实际为${cells.length}`);
const row = Object.fromEntries(headers.map((header, column) => [header, csvRestore(cells[column])]));
if (clean(row['完整记录JSON'])) {
let raw;
try { raw = JSON.parse(row['完整记录JSON']); } catch (_) { throw new Error('完整记录JSON格式错误'); }
records.push(normalizeRecord(raw));
continue;
}
const type = typeAliases[clean(row['题型'])];
if (!type) throw new Error('题型必须为单选题、多选题、判断题或未知');
const options = headers.filter((header) => /^选项(?:[A-Z]+|\d+)$/u.test(header) && clean(row[header])).map((header) => ({ id: '', label: header.slice(2), text: row[header] }));
const answers = importedArray(row['正确答案内容'], type, options);
const statusText = clean(row['答案状态']);
if (statusText && !own(statusAliases, statusText)) throw new Error('无效答案状态');
const answerStatus = statusText ? statusAliases[statusText] : answers.length ? 'confirmed' : 'missing';
const record = normalizeRecord({ bankId: row['题库ID'], bankName: row['来源题库'], questionId: row['题目ID'], type,
stem: row['题干'], options, answerTexts: answers, answerStatus, explanation: row['解析'],
source: { kind: clean(row['答案来源']) || 'csv-import', observedAt: row['采集时间'] } });
if (record.answerStatus === 'confirmed' && record.options.length && mapAnswers(record, record).status !== 'matched') throw new Error('正确答案内容不能唯一对应选项');
records.push(record);
} catch (error) { throw new Error(`CSV第${index + 2}行:${error.message}`); }
}
return records;
}
return { normalizeRecord, repairLegacyJudgeAnswers, repairDuplicateOptionConflicts, upgradeLegacyImageRecords, upgradeLegacyMathEntities, mergeRecord, upsert, compactRecords, search, mapAnswers, toCSV, fromCSV };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBCore;
// ---- src/adapter.js ----
/* Site adapter: observe existing practice responses; never submit or alter answers. */
const QBAdapter = (() => {
'use strict';
const TYPES = { 0: 'single', 1: 'multiple', 2: 'judge' };
const ROOT_SELECTOR = '.yxtulcdsdk-pc-marking-main';
const MAX_RESPONSE = 8 * 1024 * 1024;
const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
const ENTITIES = { nbsp: ' ', lt: '<', LT: '<', gt: '>', GT: '>', amp: '&', AMP: '&', quot: '"', QUOT: '"', apos: "'",
colon: ':', sol: '/', copy: '©', reg: '®', times: '×', divide: '÷', plusmn: '±', le: '≤', ge: '≥', ne: '≠', deg: '°', micro: 'µ' };
let entityDecoder = null;
const decodeEntities = value => value.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (entity, name) => {
if (name[0] !== '#') {
if (has(ENTITIES, name)) return ENTITIES[name];
// Only an entity token reaches this detached textarea; no markup is inserted.
try {
if (typeof document !== 'undefined' && document.createElement) {
entityDecoder ||= document.createElement('textarea');
entityDecoder.innerHTML = entity;
return entityDecoder.value;
}
} catch (_) { /* A restrictive page may disable DOM entity decoding. */ }
return entity;
}
const code = name[1].toLowerCase() === 'x' ? parseInt(name.slice(2), 16) : Number(name.slice(1));
return code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff) ? String.fromCodePoint(code) : '';
});
function imageMarker(tag) {
const attributes = tag.replace(/^<img(?=[\s/>])/i, '').replace(/>$/, '');
const attribute = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
let match;
while ((match = attribute.exec(attributes))) {
if (match[1].toLowerCase() !== 'src') continue;
const value = decodeEntities(match[2] ?? match[3] ?? match[4] ?? '').trim();
if (!/^https?:\/\//i.test(value)) return '';
try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return '';
return `[图片:${url.href.replace(/\[/g, '%5B').replace(/\]/g, '%5D')}]`;
} catch (_) { return ''; }
}
return '';
}
function richText(value) {
// DOM and response HTML must retain the same src, never a responsive currentSrc.
if (value && typeof value === 'object') value = typeof value.innerHTML === 'string' ? value.innerHTML : value.textContent;
const html = String(value == null ? '' : value)
.replace(/<(script|style)(?=[\s/>])(?:[^"'<>]|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1\s*>/gi, '');
const tags = /<!--[\s\S]*?-->|<\/?[a-z][\w:-]*(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi;
let result = '', end = 0, match;
while ((match = tags.exec(html))) {
result += decodeEntities(html.slice(end, match.index));
if (/^<img(?=[\s/>])/i.test(match[0])) result += imageMarker(match[0]);
else if (/^<br(?=[\s/>])/i.test(match[0])) result += '\n';
end = tags.lastIndex;
}
return (result + decodeEntities(html.slice(end))).replace(/\u00a0/g, ' ').trim();
}
const text = richText;
const flat = value => String(value == null ? '' : value).replace(/\s+/g, '');
const idString = value => typeof value === 'string' || Number.isSafeInteger(value) ? String(value) : '';
const signature = q => [q.type, flat(q.stem), (q.options || []).map(o => flat(o.text)).sort().join('\u001f')].join('\u001e');
const labelFor = index => String.fromCharCode(65 + index);
function standardAnswer(node, type, options, context) {
const result = node.resultInfo && typeof node.resultInfo === 'object' ? node.resultInfo : null;
const answerNode = result || node;
if (context.hideStandardAnswers || [node, answerNode].some(value => value.allowViewSingleQuesAnswer === false || value.allowViewSingleQuesAnswer === 0)) {
return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
}
const hasSubmitted = Array.isArray(node.submitQuesAnswers) && node.submitQuesAnswers.length > 0;
const verifiedResponse = context.answerResponse && context.questionId && context.knownQuestion;
const evidence = verifiedResponse ? 'answer-response' : hasSubmitted || result ? 'submitted-list' : 'explicit-standard';
if (context.answerResponse && !verifiedResponse) return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
if (type === 'judge') {
// /upm/start sends judgeAnswer=0 for unanswered listQues as well. The
// real client creates resultInfo only for non-empty submitQuesAnswers;
// reading that placeholder as an answer silently labels every new item false.
if ((context.practiceList || context.collection === 'listQues') && !hasSubmitted && !result && !verifiedResponse) {
return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
}
const raw = answerNode.judgeAnswer;
if (raw === 0 || raw === '0' || raw === 1 || raw === '1') {
const value = String(raw);
return { correctOptionIds: [value], answerTexts: [value === '1' ? '正确' : '错误'], answerStatus: 'confirmed', answerEvidence: evidence };
}
} else {
let ids = [];
if (Array.isArray(node.correctAnswers)) ids = node.correctAnswers.map(idString).filter(Boolean);
else if (result && Array.isArray(result.answers)) ids = result.answers.map(idString).filter(Boolean);
else if (context.answerResponse && Array.isArray(node.answers)) ids = node.answers.map(idString).filter(Boolean);
else if (Array.isArray(node.choiceItems)) ids = node.choiceItems.filter(o => o.answer === 1).map(o => idString(o.id));
ids = [...new Set(ids)].filter(Boolean);
const resolved = ids.map(id => options.filter(o => o.id === id));
if (ids.length && resolved.every(matches => matches.length === 1) && (type !== 'single' || ids.length === 1)) {
return { correctOptionIds: ids, answerTexts: resolved.map(matches => matches[0].text), answerStatus: 'confirmed', answerEvidence: evidence };
}
}
return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
}
function knownById(context, id) {
if (!id || !context.knownQuestions) return null;
const values = context.knownQuestions instanceof Map ? [...context.knownQuestions.values()] : context.knownQuestions;
if (!Array.isArray(values)) return null;
const matches = values.filter(q => q.questionId === id && (!context.bankId || !q.bankId || q.bankId === context.bankId));
return matches.length === 1 ? matches[0] : null;
}
function recordFromNode(node, context) {
const rawType = has(node, 'quesType') ? node.quesType : node.type;
let type = TYPES[rawType];
// 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;
if (type === 'judge') {
options = [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }];
} else if (Array.isArray(node.choiceItems)) {
if (node.choiceItems.some(option => !option || typeof option !== 'object')) return null;
options = node.choiceItems.slice(0, 100).map((option, index) => ({
id: idString(option.id), text: text(option.itemContent) || text(option.content), label: labelFor(index)
}));
if (options.length < 2 || options.some(o => !o.text)) return null;
} else if (previous) options = previous.options.map(o => ({ ...o }));
else return null;
const answers = standardAnswer(node, type, options, { ...context, knownQuestion: previous });
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(),
...(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;
const childKeys = ['data', 'result', 'listQues', 'questionList', 'questions', 'subQuesList', 'list', 'items'];
function visit(node, inherited, depth) {
if (!node || typeof node !== 'object' || depth > 12 || ++count > 16000 || visited.has(node)) return;
visited.add(node);
if (Array.isArray(node)) {
const arrayContext = inherited.answerResponse && node.length !== 1 ? { ...inherited, questionId: '' } : inherited;
for (const item of node.slice(0, 10000)) visit(item, arrayContext, depth + 1);
return;
}
const hidden = inherited.hideStandardAnswers || [node, node.info].some(value => value &&
(value.allowViewSingleQuesAnswer === false || value.allowViewSingleQuesAnswer === 0));
const next = { ...inherited, hideStandardAnswers: Boolean(hidden), bankId: idString(node.praId || inherited.bankId), bankName: text(node.praName || inherited.bankName) };
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)) {
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();
for (const q of output) {
const key = `${q.bankId}\u001f${q.questionId || signature(q)}`;
const old = unique.get(key);
if (!old || (old.answerStatus === 'missing' && q.answerStatus === 'confirmed')) unique.set(key, q);
}
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) {
try {
const url = new URL(value, base);
const host = url.hostname;
const allowed = host === 'www.shhuayi-elearning.cn' || host === 'shhuayi-elearning.cn' ||
/(^|\.)(yunxuetang\.cn|yxt\.com)$/.test(host) || url.origin === new URL(base).origin;
if (!allowed) return null;
const path = url.pathname;
const list = /\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/.test(path);
const answerMatch = path.match(/\/upm\/([^/]+)\/users\/[^/]+\/(?:wrong\/)?answer\/?$/);
if (!list && !answerMatch) return null;
const praMatch = path.match(/\/pra\/([^/]+)\/questions/);
return {
// Retain no token-bearing query parameters in saved source URLs.
url: url.origin + url.pathname.replace(/\/users\/[^/]+(?=\/)/, '/users/_'),
bankId: url.searchParams.get('praId') || (answerMatch && answerMatch[1]) || (praMatch && praMatch[1]) || '',
answerResponse: Boolean(answerMatch),
practiceList: /\/upm\/(?:wrong\/)?start\/?$/.test(path)
};
} catch (_) { return null; }
}
function requestQuestionId(body) {
try {
if (typeof body !== 'string' || body.length > 1024 * 1024) return '';
const value = JSON.parse(body);
return Array.isArray(value.answers) && value.answers.length === 1 ? idString(value.answers[0].quesId) : '';
} 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;
let observer = null;
let timer = null;
let copyStyle = null;
let fetchOriginal = null;
let fetchWrapped = null;
let xhrOpenOriginal = null;
let xhrSendOriginal = null;
let xhrOpenWrapped = null;
let xhrSendWrapped = null;
let lastDOMSignature = '';
let networkGeneration = 0;
let requestSequence = 0;
let batchSequence = 0;
const known = new Map();
let knownContentIndex = null;
const xhrContext = new WeakMap();
const pendingXHR = new Set();
const banksById = new Map();
const banksByTask = new Map();
const knownSnapshotOrder = new Map();
const deferredSnapshots = new Map();
let currentBank = null;
let pendingSelection = '';
let blockedForm = null;
let lastFormSnapshot = null;
let bankListeners = false;
const status = message => { try { onStatus(message); } catch (_) { /* UI callbacks must not affect the page. */ } };
const locationURL = () => String(win.location && win.location.href || '');
function isSupportedPage() {
try {
const host = new URL(locationURL()).hostname;
if (host === 'www.shhuayi-elearning.cn' || host === 'shhuayi-elearning.cn') return true;
} catch (_) { /* An incomplete location still allows an explicit exercise root. */ }
return Boolean(doc.querySelector(ROOT_SELECTOR));
}
function formSnapshot() {
const element = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].find(visible);
return element ? { element, text: flat(text(element)) } : null;
}
function taskFromURL() {
try {
const url = new URL(locationURL());
const hashQuery = url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : '';
return new URLSearchParams(hashQuery).get('taskId') || url.searchParams.get('taskId') || '';
} catch (_) { return ''; }
}
function displayedQuestionCount() {
const progress = doc.querySelector('.yxtulcdsdk-answer-card__top');
const match = progress && visible(progress) && text(progress.textContent).match(/(\d+)\s*\/\s*(\d+)(?!\d)/);
if (match) {
const done = Number(match[1]), total = Number(match[2]);
if (total >= 0 && total <= 100000 && done <= total) return total;
}
const preview = [...doc.querySelectorAll('.yxtulcdsdk-uexam-preview-content__base-row-flex--practice .info-left .info-text')]
.filter(visible).map(node => text(node.textContent).match(/^试题总数\s*[::]\s*(\d+)\s*题$/)).filter(Boolean);
if (preview.length !== 1) return null;
const total = Number(preview[0][1]);
return total >= 0 && total <= 100000 ? 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);
if (pendingSelection && flat(pendingSelection) === flat(selectedName)) pendingSelection = '';
const sidebarName = pendingSelection || selectedName;
const playerName = text(doc.querySelector('header.o2oPlayFrame-right .title .yxtf-tooltip')?.textContent);
const examName = text(doc.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip')?.textContent);
const container = doc.querySelector('.yxtulcdsdk-exam-container[task-id]');
const containerTask = idString(container?.getAttribute('task-id'));
const routeTask = taskFromURL();
const selectedTask = idString(selected?.closest('.task-content-active')?.getAttribute('data-task-id'));
const pending = pendingSelection && flat(pendingSelection) !== flat(selectedName);
const taskId = pending ? '' : selectedTask || routeTask || containerTask;
const bankName = text(sidebarName || playerName || examName || String(doc.title || '').replace(/ - 华谊集团$/, ''));
const mismatch = Boolean(pending || (sidebarName && playerName && flat(sidebarName) !== flat(playerName)) ||
(sidebarName && examName && flat(sidebarName) !== flat(examName)) ||
(playerName && examName && flat(playerName) !== flat(examName)) ||
(routeTask && containerTask && routeTask !== containerTask));
return { bankName, taskId, mismatch };
}
function syncBank() {
if (!isSupportedPage()) {
const empty = { bankId: '', bankName: '', taskId: '', loading: false, questionCount: null };
const changed = !currentBank || JSON.stringify(currentBank) !== JSON.stringify(empty);
currentBank = empty;
pendingSelection = '';
blockedForm = null;
lastFormSnapshot = null;
if (changed) {
try { onBankChange({ ...empty }); } catch (_) { /* The host page remains independent. */ }
}
return { ...empty };
}
const state = bankDOM();
const identity = JSON.stringify([state.taskId, flat(state.bankName)]);
const oldIdentity = currentBank && JSON.stringify([currentBank.taskId, flat(currentBank.bankName)]);
const snapshot = formSnapshot();
const stillPreviousForm = snapshot && lastFormSnapshot && snapshot.element === lastFormSnapshot.element && snapshot.text === lastFormSnapshot.text;
// During a SPA transition the sidebar changes before the old question is
// replaced. Keep that old form out of the new bank even if titles update first.
if (currentBank && identity !== oldIdentity && flat(currentBank.bankName) !== flat(state.bankName)) {
blockedForm = stillPreviousForm ? snapshot : null;
lastDOMSignature = '';
} else if (currentBank && identity !== oldIdentity && currentBank.taskId && state.taskId && currentBank.taskId !== state.taskId) {
if (!(currentBank.loading && !blockedForm)) blockedForm = stillPreviousForm ? snapshot : null;
lastDOMSignature = '';
}
if (blockedForm && (!snapshot || blockedForm.element !== snapshot.element || blockedForm.text !== snapshot.text)) blockedForm = null;
let bankId = '';
const taskBank = state.taskId && banksByTask.get(state.taskId);
if (taskBank && flat(taskBank.bankName) === flat(state.bankName)) bankId = taskBank.bankId;
if (!bankId && !state.taskId) {
const named = [...banksById.values()].filter(bank => flat(bank.bankName) === flat(state.bankName));
if (named.length === 1) bankId = named[0].bankId;
}
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;
if (changed) {
try { onBankChange({ ...next }); } catch (_) { /* Consumers must not affect site navigation. */ }
}
flushDeferredSnapshots(next);
return { ...next };
}
function getCurrentBank() { return syncBank(); }
function ensureBankListeners() {
if (bankListeners || !isSupportedPage()) return;
bankListeners = true;
win.addEventListener?.('hashchange', syncBank);
win.addEventListener?.('popstate', syncBank);
win.addEventListener?.('click', event => {
const item = event.target?.closest?.('.task-content, .task-content-active');
const name = text(item?.querySelector('.group-title')?.textContent);
if (name && flat(name) !== flat(currentBank?.bankName)) { pendingSelection = name; syncBank(); }
}, true);
}
function requestContext(context) {
const current = syncBank();
const saved = context.bankId && banksById.get(context.bankId);
const sameTask = saved && saved.taskId && saved.taskId === current.taskId;
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;
}
function rememberBanks(context, records) {
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,
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 emissionContext(context, current, stale = false) {
const samePage = !current.loading && context.bankId && context.bankId === current.bankId &&
(!context.taskId || context.taskId === current.taskId) && flat(context.bankName) === flat(current.bankName) && !stale;
return { ...context, currentBankId: samePage ? current.bankId : '',
pageQuestionCount: samePage ? displayedQuestionCount() : null };
}
function deliver(records, context) {
try { onQuestions(records, context); } catch (error) { status(`保存题目失败:${error.message || error}`); }
}
function flushDeferredSnapshots(current) {
if (!active || !current.bankId || current.loading) return;
const pending = deferredSnapshots.get(current.bankId);
if (!pending) return;
if ((knownSnapshotOrder.get(current.bankId) || 0) > (pending.context.requestSequence || 0)) {
deferredSnapshots.delete(current.bankId);
return;
}
const context = emissionContext(pending.context, current);
if (context.pageQuestionCount === null) return;
deferredSnapshots.delete(current.bankId);
deliver(pending.records, context);
}
function emit(records, context = {}) {
if (!records.length) return;
if (!context.fullSnapshot && records.some(record => deferredSnapshots.has(record.bankId))) 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 && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q);
}
knownContentIndex = null;
const current = syncBank();
const metadata = emissionContext(context, current, staleBanks.has(context.bankId));
const containsImages = records.some(record => /\[图片:https?:\/\//.test(record.stem) || record.options.some(option => /\[图片:https?:\/\//.test(option.text)));
if (context.fullSnapshot && context.bankId && !staleBanks.has(context.bankId) && containsImages && metadata.pageQuestionCount === null) {
deferredSnapshots.set(context.bankId, { records, context });
return;
}
deliver(records, metadata);
}
function visible(node) {
if (!node || node.closest?.('#huayi-qb-root')) return false;
for (let at = node; at && at.nodeType === 1; at = at.parentElement) {
if (at.hidden || at.getAttribute('aria-hidden') === 'true' || at.style?.display === 'none') return false;
}
return true;
}
function matchingKnown(question) {
if (!knownContentIndex) {
knownContentIndex = new Map();
for (const record of known.values()) {
const content = signature(record);
const keys = [`id:${record.bankId}\u001d${content}`, `name:${flat(record.bankName)}\u001d${content}`];
for (const key of keys) {
if (!knownContentIndex.has(key)) knownContentIndex.set(key, []);
knownContentIndex.get(key).push(record);
}
}
}
const key = question.bankId ? `id:${question.bankId}` : `name:${flat(question.bankName)}`;
return knownContentIndex.get(`${key}\u001d${signature(question)}`) || [];
}
function getCurrentQuestion() {
if (!isSupportedPage()) return null;
const bank = syncBank();
if (bankDOM().mismatch) return null;
const forms = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].filter(visible);
if (forms.length !== 1) return null;
const form = forms[0];
const content = form.querySelector('.yxtulcdsdk-form__content');
const stemNode = content && [...content.children].find(n => n.matches('[data-rich-text]'));
const stem = stemNode && text(stemNode);
if (!stem) return null;
const labels = [...form.querySelectorAll('.yxtulcdsdk-ques-radio, .yxtulcdsdk-ques-checkbox')];
if (labels.length < 2) return null;
const options = labels.map((label, index) => {
const input = label.querySelector('input');
const labelNode = label.querySelector('.yxtf-radio__label, .yxtf-checkbox__label');
const rich = labelNode && labelNode.querySelector('[data-rich-text]');
const full = text(labelNode || label);
return { id: input ? idString(input.value) : '', text: rich ? text(rich) : full.replace(/^[A-Z][.、.]\s*/, ''), label: labelFor(index) };
});
if (options.some(option => !option.text)) return null;
let type = form.querySelector('input[type=checkbox], [role=checkbox]') ? 'multiple' : 'single';
if (options.length === 2 && options.some(o => o.text === '正确') && options.some(o => o.text === '错误')) {
type = 'judge';
options.forEach(o => { o.label = o.text; });
}
const q = {
bankId: bank.bankId, bankName: bank.bankName, questionId: '', type, stem, options,
correctOptionIds: [], answerTexts: [], answerStatus: 'missing', explanation: '',
source: { kind: 'dom', url: locationURL().split('?')[0].split('#')[0], observedAt: new Date().toISOString() }
};
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 = matchingKnown(q);
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) {
// Vue may update the question text before the old radio values. Those
// transitional controls must not be attached to the new server identity.
const expectedOptions = matches[0].options;
if (expectedOptions.every(option => option.id) && options.some(option => option.id &&
!expectedOptions.some(expected => expected.id === option.id && flat(expected.text) === flat(option.text)))) return null;
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(); }
}
// A letter-only result row can survive the previous question during a
// render. Its heading alone does not prove it belongs to this question.
const main = form.closest(ROOT_SELECTOR);
const rows = [...main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible);
for (const row of rows) {
const heading = text(row.querySelector('.yxtulcdsdk-examing__result-line-left')?.textContent);
const answer = text(row.querySelector('.yxtulcdsdk-examing__result-line-right'));
if (heading === '正确答案' || heading === '标准答案') {
let selected = [];
if (type === 'judge') selected = options.filter(o => o.text === answer);
else if (/^[A-Z](?:[、,,\s]*[A-Z])*$/.test(answer)) {
const requested = [...new Set(answer.match(/[A-Z]/g))];
selected = requested.map(letter => options.find(o => o.label === letter)).filter(Boolean);
if (selected.length !== requested.length) selected = [];
}
if (selected.length && (type !== 'single' || selected.length === 1)) {
const feedbackId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id']
.map(name => idString(row.getAttribute?.(name))).find(Boolean);
const knownAnswer = matches.length === 1 && matches[0].answerStatus === 'confirmed' &&
['submitted-list', 'answer-response', 'bound-dom', 'explicit-standard'].includes(matches[0].answerEvidence) ? matches[0] : null;
const agreesWithKnown = knownAnswer && knownAnswer.answerTexts.length === selected.length &&
selected.every(option => knownAnswer.answerTexts.some(answerText => flat(answerText) === flat(option.text)));
const boundToCurrent = q.questionId && feedbackId && feedbackId === q.questionId;
if ((feedbackId && feedbackId !== q.questionId) || (!boundToCurrent && !agreesWithKnown)) continue;
q.correctOptionIds = selected.map(o => o.id).filter(Boolean);
q.answerTexts = selected.map(o => o.text);
q.answerStatus = 'confirmed';
q.answerEvidence = boundToCurrent ? 'bound-dom' : knownAnswer.answerEvidence;
}
} else if (heading === '解析' || heading === '试题解析' || heading === '答案解析') {
const feedbackId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id']
.map(name => idString(row.getAttribute?.(name))).find(Boolean);
if (q.answerStatus === 'confirmed' && (!feedbackId || feedbackId === q.questionId)) q.explanation = answer;
}
}
return q;
}
function captureCurrent() {
if (!isSupportedPage()) {
status('当前为通用网页,可使用本地题库搜索;本站采集未开启。');
return null;
}
const current = getCurrentQuestion();
if (current) emit([current]);
else status('当前页面未发现可采集的练习题,请进入题目页面。');
return current;
}
function observePayload(payload, context) {
if (!active) return;
// A full list can replace the meaning of even an unchanged question ID.
// An older answer receipt has no independent stem/options to bind it to
// that new version; never fill it from the newer cached question.
if (context.answerResponse && (knownSnapshotOrder.get(context.bankId) || 0) > (context.requestSequence || 0)) return;
try {
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))];
const sourceName = names.length === 1 ? names[0] : context.bankName || '来源题库';
status(`已读取「${sourceName}」${records.length} 道题,标准答案 ${records.filter(q => q.answerStatus === 'confirmed').length} 道。`);
}
} catch (_) { status('本次响应结构暂未识别,题目仍可从页面采集。'); }
}
function installNetwork() {
if (!observeNetwork || !isSupportedPage()) return;
const generation = ++networkGeneration;
if (typeof win.fetch === 'function') {
const originalFetch = win.fetch;
fetchOriginal = originalFetch;
fetchWrapped = function (...args) {
const request = args[0];
const requestURL = typeof request === 'string' || (request && typeof request.href === 'string') ? String(request) : request && request.url;
const context = classifyURL(requestURL, locationURL());
if (context) {
requestContext(context);
context.questionId = requestQuestionId(args[1] && args[1].body);
}
const promise = originalFetch.apply(this, args);
if (context) Promise.resolve(promise).then(response => {
if (!active || generation !== networkGeneration || !response.ok) return;
const contentType = response.headers.get('content-type') || '';
const length = Number(response.headers.get('content-length') || 0);
if (!/\bjson\b/i.test(contentType) || length > MAX_RESPONSE) return;
response.clone().text().then(body => {
if (active && generation === networkGeneration && body.length <= MAX_RESPONSE) observePayload(JSON.parse(body), context);
}).catch(() => {});
}).catch(() => {});
return promise;
};
win.fetch = fetchWrapped;
}
const proto = win.XMLHttpRequest && win.XMLHttpRequest.prototype;
if (!proto) return;
const originalOpen = proto.open;
const originalSend = proto.send;
xhrOpenOriginal = originalOpen;
xhrSendOriginal = originalSend;
xhrOpenWrapped = function (method, url, ...args) {
const result = originalOpen.call(this, method, url, ...args);
const context = classifyURL(url, locationURL());
if (context) requestContext(context);
xhrContext.set(this, context);
return result;
};
xhrSendWrapped = function (body) {
const context = xhrContext.get(this);
if (active && generation === networkGeneration && context) {
context.questionId = requestQuestionId(body);
const xhr = this;
const listener = () => {
pendingXHR.delete(entry);
if (!active || generation !== networkGeneration || xhr.status < 200 || xhr.status >= 300) return;
try {
const type = xhr.getResponseHeader('content-type') || '';
const length = Number(xhr.getResponseHeader('content-length') || 0);
if (!/\bjson\b/i.test(type) || length > MAX_RESPONSE) return;
if (xhr.responseType === 'json') observePayload(xhr.response, context);
else if (!xhr.responseType || xhr.responseType === 'text') {
const bodyText = xhr.responseText;
if (bodyText.length <= MAX_RESPONSE) observePayload(JSON.parse(bodyText), context);
}
} catch (_) { /* Unsupported payloads never interfere with the site's requests. */ }
};
const entry = { xhr, listener };
pendingXHR.add(entry);
xhr.addEventListener('load', listener, { once: true });
}
return originalSend.call(this, body);
};
proto.open = xhrOpenWrapped;
proto.send = xhrSendWrapped;
}
function uninstallNetwork() {
networkGeneration++;
if (fetchWrapped && win.fetch === fetchWrapped) win.fetch = fetchOriginal;
const proto = win.XMLHttpRequest && win.XMLHttpRequest.prototype;
if (proto && proto.open === xhrOpenWrapped) proto.open = xhrOpenOriginal;
if (proto && proto.send === xhrSendWrapped) proto.send = xhrSendOriginal;
for (const { xhr, listener } of pendingXHR) xhr.removeEventListener('load', listener);
pendingXHR.clear();
}
function copyEvent(event) {
const target = event.target && (event.target.nodeType === 1 ? event.target : event.target.parentElement);
if (!copyEnabled || !target?.closest(ROOT_SELECTOR) || target.closest('#huayi-qb-root')) return;
// Preserve the browser default copy/selection action, suppress only downstream blockers.
event.stopImmediatePropagation();
}
function ensureCopyStyle() {
if (!copyEnabled || copyStyle?.isConnected || !isSupportedPage()) return;
const parent = doc.head || doc.documentElement;
if (!parent) return;
copyStyle = doc.createElement('style');
copyStyle.id = 'huayi-qb-copy-style';
copyStyle.textContent = `${ROOT_SELECTOR},${ROOT_SELECTOR} *{-webkit-user-select:text!important;user-select:text!important}`;
parent.appendChild(copyStyle);
}
function ensureObserver() {
if (observer || !win.MutationObserver || !isSupportedPage()) return;
const contextSelectors = '.task-content, .task-content-active, header.o2oPlayFrame-right, .yxtulcdsdk-exam-header, .yxtulcdsdk-answer-card__top, .yxtulcdsdk-uexam-preview-content__base-row-flex--practice, title';
const questionSelectors = '.yxtulcdsdk-form__content, .yxtulcdsdk-form__sub, .yxtulcdsdk-examing__result-line';
const insertedSelectors = `${ROOT_SELECTOR}, .yxtulcdsdk-form, .yxtulcdsdk-exam-container, ${contextSelectors}, ${questionSelectors}`;
function relevantMutation(mutation) {
const node = mutation.target.nodeType === 1 ? mutation.target : mutation.target.parentElement;
if (!node || node.closest?.('#huayi-qb-root') || node.id === 'huayi-qb-copy-style') return false;
const inQuestion = node.closest?.(ROOT_SELECTOR);
if (inQuestion) {
if (mutation.type === 'attributes') {
// checked/disabled/selected and feedback classes change on every
// answer. They do not change the question or its standard answer.
if (node.closest?.('.yxtulcdsdk-ques-radio, .yxtulcdsdk-ques-checkbox')) return false;
return Boolean(node.matches?.(`${ROOT_SELECTOR}, .yxtulcdsdk-form`) ||
node.closest?.('.yxtulcdsdk-examing__result-line'));
}
if (node.closest?.(questionSelectors)) return true;
}
if (node.closest?.(contextSelectors)) return true;
if (mutation.type === 'attributes' && mutation.attributeName === 'task-id' && node.matches?.('.yxtulcdsdk-exam-container')) return true;
if (mutation.type !== 'childList') return false;
// Structural additions/removals can introduce an exercise root, but
// unrelated timers, chat widgets, answer-card cells and our UI cannot.
return [...(mutation.addedNodes || []), ...(mutation.removedNodes || [])].some(changed => changed.nodeType === 1 &&
changed.id !== 'huayi-qb-root' && changed.id !== 'huayi-qb-copy-style' &&
(changed.matches?.(insertedSelectors) || changed.querySelector?.(insertedSelectors)));
}
observer = new win.MutationObserver(mutations => {
if (!mutations.some(relevantMutation) || timer !== null) return;
timer = win.setTimeout(() => {
timer = null;
syncBank();
if (copyEnabled) ensureCopyStyle();
if (!active) return;
const q = getCurrentQuestion();
if (!q) return;
const next = JSON.stringify([q.bankId, q.questionId, signature(q), q.correctOptionIds, q.answerTexts, q.explanation]);
if (next !== lastDOMSignature) { lastDOMSignature = next; emit([q]); }
}, 120);
});
observer.observe(doc, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'task-id', 'data-task-id'] });
}
function setCopyEnabled(value) {
const supported = isSupportedPage();
if (!supported && !copyEnabled && !copyStyle) return;
copyEnabled = Boolean(value) && supported;
for (const type of ['copy', 'cut', 'contextmenu', 'selectstart']) {
win.removeEventListener?.(type, copyEvent, true);
if (copyEnabled) win.addEventListener?.(type, copyEvent, true);
}
if (copyEnabled) { ensureCopyStyle(); ensureObserver(); }
else if (copyStyle) { copyStyle.remove(); copyStyle = null; }
ensureBankListeners();
ensureObserver();
syncBank();
}
function start() {
if (active) return;
active = true;
if (!isSupportedPage()) {
syncBank();
status('当前为通用网页,可使用本地题库搜索;本站采集未开启。');
return;
}
ensureBankListeners();
ensureObserver();
installNetwork();
captureCurrent();
status('采集已开启;切换练习或正常作答时,自动保存收到的题目与标准答案。');
}
function stop() {
active = false;
deferredSnapshots.clear();
win.clearTimeout(timer);
timer = null;
uninstallNetwork();
status('采集已暂停,已保存题库和选择复制功能仍可使用。');
}
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 };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
// ---- src/autofill.js ----
/* Select confirmed local answers. This module never submits forms or changes pages. */
const QBAuto = (() => {
'use strict';
const tidy = value => String(value == null ? '' : value).replace(/\u00a0/g, ' ').trim();
const flat = value => tidy(value).replace(/\s+/gu, '');
const stemKey = value => flat(tidy(value).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, ''));
const judgeText = value => /^(正确|对|是|√|true)$/i.test(tidy(value)) ? '正确' : /^(错误|错|否|×|false)$/i.test(tidy(value)) ? '错误' : tidy(value);
const optionKey = (value, type) => flat(type === 'judge' ? judgeText(value) : value);
const sorted = values => [...values].sort();
const optionSet = question => JSON.stringify(sorted(question.options.map(option => optionKey(option.text, question.type))));
const localCore = supplied => supplied || (typeof QBCore !== 'undefined' ? QBCore : typeof require === 'function' ? require('./core.js') : null);
const richText = value => {
const adapter = typeof QBAdapter !== 'undefined' ? QBAdapter : typeof require === 'function' ? require('./adapter.js') : null;
return adapter ? adapter.richText(value) : tidy(value && typeof value === 'object' ? value.textContent : value);
};
const recordIndexes = new WeakMap();
function candidateRecords(records, question, core) {
let index = recordIndexes.get(records);
if (!index || index.core !== core) {
index = { core, entries: [], byQuestion: new Map(), normalized: new WeakMap() };
recordIndexes.set(records, index);
}
let changed = index.entries.length !== records.length;
const entries = records.map((record, position) => {
const rawStem = record && typeof record.stem === 'string' ? record.stem : '';
const type = record && typeof record.type === 'string' ? record.type : '';
const old = index.entries[position];
if (old?.record === record && old.rawStem === rawStem && old.type === type) return old;
changed = true;
return { record, rawStem, type, key: rawStem ? JSON.stringify([type, stemKey(rawStem)]) : '' };
});
if (changed) {
index.entries = entries;
index.byQuestion = new Map();
for (const entry of entries) {
if (!entry.key || !entry.record || typeof entry.record !== 'object') continue;
if (!index.byQuestion.has(entry.key)) index.byQuestion.set(entry.key, []);
index.byQuestion.get(entry.key).push(entry.record);
}
}
const matches = index.byQuestion.get(JSON.stringify([question.type, stemKey(question.stem)])) || [];
return matches.map(input => {
// Only exact-stem candidates need full answer validation. This signature
// also detects in-place edits to answers/options, not just array replacement.
let signature;
try {
signature = JSON.stringify([input.bankId, input.bankName, input.questionId, input.type, input.stem,
input.options, input.correctOptionIds, input.answerTexts, input.answerStatus, input.conflicts,
input.questionIndex, input.contentOccurrence, input.questionIdAliases]);
} catch (_) { return null; }
const saved = index.normalized.get(input);
if (saved && saved.signature === signature) return saved.record;
try {
const record = core.normalizeRecord(input);
index.normalized.set(input, { signature, record });
return record;
} catch (_) { return null; }
}).filter(Boolean);
}
function planAnswer(records, current, suppliedCore) {
const result = (status, message, more = {}) => ({ status, message, labels: [], answerTexts: [], ...more });
const core = localCore(suppliedCore);
if (!core || !current || !Array.isArray(records)) return result('unsupported', '当前网页未识别到可自动选择的题目。');
let question;
try { question = core.normalizeRecord({ ...current, answerStatus: 'missing' }); }
catch (_) { return result('unsupported', '当前题目结构不完整,已跳过。'); }
if (!['single', 'multiple', 'judge'].includes(question.type) || question.options.length < 2 || question.options.length > 100) return result('unsupported', '当前题型暂不支持自动选择。');
// 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 record of candidateRecords(records, question, core)) {
if (record.type !== question.type || stemKey(record.stem) !== stemKey(question.stem) || optionSet(record) !== optionSet(question)) continue;
if (question.bankId && record.bankId && question.bankId !== record.bankId) continue;
if (!record.bankId && question.bankName && question.bankName !== '未命名题库' && record.bankName !== '未命名题库' && flat(record.bankName) !== flat(question.bankName)) continue;
relevant.push(record);
}
let candidates = relevant;
if (question.bankId) {
const exactBank = relevant.filter(record => record.bankId === question.bankId);
if (exactBank.length) candidates = relevant.filter(record => record.bankId === question.bankId || !record.bankId);
} else if (question.bankName && question.bankName !== '未命名题库') {
const sameName = relevant.filter(record => flat(record.bankName) === flat(question.bankName));
if (sameName.length) candidates = sameName;
}
if (candidates.some(record => record.answerStatus === 'conflict')) return result('ambiguous', '本地题库存在该题的冲突答案,已跳过。');
const confirmed = candidates.filter(record => record.answerStatus === 'confirmed');
if (!confirmed.length) return result('missing', '未找到题干、题型和全部选项一致的已确认答案。');
const signatures = new Set(confirmed.map(record => JSON.stringify(sorted(record.answerTexts.map(value => optionKey(value, record.type))))));
if (signatures.size !== 1) return result('ambiguous', '相同题目对应多个不同标准答案,已跳过。');
const mapped = confirmed.map(record => ({ record, mapping: core.mapAnswers(record, question) }));
if (mapped.some(item => item.mapping.status !== 'matched' || !item.mapping.labels.length)) return result('ambiguous', '标准答案无法安全对应当前选项,已跳过。');
const labelSets = new Set(mapped.map(item => JSON.stringify(sorted(item.mapping.labels))));
if (labelSets.size !== 1) return result('ambiguous', '标准答案对应的选项位置不一致,已跳过。');
return result('ready', '已找到唯一对应的标准答案。', { labels: [...mapped[0].mapping.labels], answerTexts: [...mapped[0].mapping.answerTexts], record: mapped[0].record });
}
function create({ window: win, document: doc, core: suppliedCore, getRecords = () => [], getQuestion = () => null, getBank = () => null, onStatus = () => {}, onChange = () => {} }) {
const core = localCore(suppliedCore);
const SITE_FORM = '.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form';
const GENERIC = 'fieldset, .question, .question-item, [data-question]';
let state = { enabled: false, busy: false, status: 'idle', message: '自动答题未开启。', answeredCount: 0, skippedCount: 0, lastStem: '' };
let observer = null, timer = null, interval = null, generation = 0, destroyed = false;
let attempts = new WeakMap();
const setTimer = (fn, delay) => win.setTimeout(fn, delay);
function emit(patch) {
const before = state;
state = { ...state, ...patch };
if (JSON.stringify(before) === JSON.stringify(state)) return;
try { onChange({ ...state }); } catch (_) { /* UI failures must not affect a page. */ }
if (before.message !== state.message) try { onStatus(state.message); } catch (_) { /* Ignore callback errors. */ }
}
function visible(element) {
if (!element || !element.isConnected || element.closest('#huayi-qb-root')) return false;
for (let at = element; at && at.nodeType === 1; at = at.parentElement) {
if (at.hidden || at.getAttribute('aria-hidden') === 'true') return false;
const style = win.getComputedStyle?.(at);
if (style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse') return false;
}
return true;
}
function labelText(input, container) {
let label = input.labels && [...input.labels].find(item => container.contains(item));
if (!label) label = input.closest('label');
if (label) {
const rich = label.querySelector('.yxtf-radio__label [data-rich-text], .yxtf-checkbox__label [data-rich-text]');
if (rich) return richText(rich);
const clone = (label.querySelector('.yxtf-radio__label, .yxtf-checkbox__label') || label).cloneNode(true);
clone.querySelectorAll('input,button,script,style').forEach(node => node.remove());
return richText(clone).replace(/^(?:[A-Z][.、.)::]|\[[A-Z]\])\s*/u, '');
}
const labelled = tidy(input.getAttribute('aria-labelledby')).split(/\s+/u).filter(Boolean).map(id => doc.getElementById(id)).filter(node => node && container.contains(node));
return tidy(labelled.length ? labelled.map(node => richText(node)).join(' ') : input.getAttribute('aria-label'));
}
function controls(container, site) {
const selector = site ? '.yxtulcdsdk-ques-radio input[type="radio"],.yxtulcdsdk-ques-checkbox input[type="checkbox"]' : 'input[type="radio"],input[type="checkbox"]';
const inputs = [...container.querySelectorAll(selector)].filter(input => site || input.closest(GENERIC) === container);
if (inputs.length < 2 || inputs.length > 100) return null;
const types = new Set(inputs.map(input => input.type));
if (types.size !== 1 || inputs.some(input => input.disabled || input.matches(':disabled') || input.closest('[aria-disabled="true"]'))) return null;
if (inputs.some(input => !visible(input) && !visible(input.closest('label')))) return null;
if (!site && inputs[0].type === 'radio' && new Set(inputs.map(input => input.name)).size > 1) return null;
const texts = inputs.map(input => labelText(input, container));
if (texts.some(value => !value)) return null;
return { inputs, texts, type: inputs[0].type === 'checkbox' ? 'multiple' : 'single' };
}
function readSite(container) {
if (!visible(container)) return null;
const current = getQuestion();
const bank = getBank();
if (!current || bank?.loading) return null;
const set = controls(container, true);
if (!set || set.inputs.length !== current.options?.length || set.texts.some((value, index) => optionKey(value, current.type) !== optionKey(current.options[index].text, current.type))) return null;
return { container, inputs: set.inputs, question: current, site: true, bankIdentity: JSON.stringify([bank?.taskId || '', bank?.bankId || '']) };
}
function readGeneric(container) {
if (!visible(container)) return null;
const set = controls(container, false);
if (!set) return null;
const titles = [...container.querySelectorAll(':scope > legend, :scope > .question-title, :scope > [data-question-title], :scope > .question-stem, :scope > .stem, :scope > h2, :scope > h3, :scope > p')].filter(node => visible(node) && !node.querySelector('input'));
if (titles.length !== 1) return null;
const stem = richText(titles[0]);
if (!stem || stem.length > 100000) return null;
const judged = set.type === 'single' && set.texts.length === 2 && set.texts.map(judgeText).includes('正确') && set.texts.map(judgeText).includes('错误');
const type = judged ? 'judge' : set.type;
const question = {
bankId: tidy(container.getAttribute('data-bank-id')), bankName: tidy(container.getAttribute('data-bank-name')),
questionId: tidy(container.getAttribute('data-question-id') || container.getAttribute('data-ques-id')), stem, type, options: set.inputs.map((input, index) => ({ id: input.value || input.id || '', text: judged ? judgeText(set.texts[index]) : set.texts[index], label: judged ? judgeText(set.texts[index]) : String.fromCharCode(65 + index) })),
answerStatus: 'missing', answerTexts: [], correctOptionIds: []
};
return { container, inputs: set.inputs, question, site: false };
}
function allCandidates() {
const siteForms = [...doc.querySelectorAll(SITE_FORM)].filter(visible);
if (siteForms.length) return siteForms.length === 1 ? [readSite(siteForms[0])].filter(Boolean) : [];
return [...doc.querySelectorAll(GENERIC)].map(readGeneric).filter(Boolean);
}
function fingerprint(candidate) {
const question = candidate.question;
return JSON.stringify([question.bankId || '', question.bankName || '', candidate.bankIdentity || '', question.questionId || '',
question.questionIndex ?? null, question.contentOccurrence ?? null, question.type, stemKey(question.stem),
question.options.map(option => [option.id || '', option.label, optionKey(option.text, question.type)]),
candidate.inputs.map(input => [input.value, input.id, input.name, input.type])]);
}
function stillSame(candidate, mark) {
if (!candidate.container.isConnected || candidate.inputs.some(input => !input.isConnected || input.disabled)) return false;
const current = candidate.site ? readSite(candidate.container) : readGeneric(candidate.container);
return current && fingerprint(current) === mark && current.inputs.length === candidate.inputs.length && current.inputs.every((input, index) => input === candidate.inputs[index]);
}
function attempted(candidate, plan) { return `${fingerprint(candidate)}\u001f${plan.status}\u001f${JSON.stringify(sorted(plan.answerTexts || []))}`; }
function desired(candidate, plan) { return candidate.question.options.map(option => plan.labels.includes(option.label)); }
function alreadyChosen(candidate, flags) { return candidate.inputs.every((input, index) => Boolean(input.checked) === flags[index]); }
async function settled(candidate, mark, runGeneration, continuous) {
// A single Promise only waits one microtask. Vue and native event handlers
// can schedule further microtasks or a later render before changing values.
// Observe two distinct task turns before selecting any next option.
for (let check = 0; check < 2; check++) {
await new Promise(resolve => globalThis.setTimeout(resolve, 50));
if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) return 'cancelled';
if (!stillSame(candidate, mark)) return 'changed';
}
return 'stable';
}
function skipped(message, stem = '', count = true) {
emit({ busy: false, status: 'skipped', message, lastStem: stem, skippedCount: state.skippedCount + (count ? 1 : 0) });
return { status: 'skipped', message };
}
async function answerCurrent(options = {}) {
const continuous = options.continuous === true;
if (destroyed) return { status: 'cancelled', message: '自动答题已关闭。' };
if (state.busy) return { status: 'busy', message: '正在处理当前题目。' };
if (continuous && !state.enabled) return { status: 'cancelled', message: '连续自动选择已暂停。' };
const candidates = allCandidates();
if (!candidates.length) return continuous ? { status: 'idle', message: '等待可识别的选择题。' } : skipped('当前网页未识别到明确的题干和选项,已跳过。');
const prioritized = [...candidates].sort((a, b) => Number(Boolean(b.container.matches('.current,.is-active,[aria-current="true"]'))) - Number(Boolean(a.container.matches('.current,.is-active,[aria-current="true"]'))));
let candidate, plan, attempt;
for (const item of prioritized) {
const proposal = planAnswer(getRecords(), item.question, core);
const key = attempted(item, proposal);
if (continuous && attempts.get(item.container) === key) continue;
candidate = item; plan = proposal; attempt = key; break;
}
if (!candidate) return { status: 'idle', message: '当前识别的题目已处理。' };
const questionStem = candidate.question.stem;
attempts.set(candidate.container, attempt);
if (plan.status !== 'ready') return skipped(plan.message, questionStem);
const flags = desired(candidate, plan);
if (candidate.question.type !== 'multiple' && flags.filter(Boolean).length !== 1) return skipped('单选或判断题的目标选项不唯一,已跳过。', questionStem);
if (alreadyChosen(candidate, flags)) {
emit({ status: 'answered', message: '当前题已选择正确选项。', lastStem: questionStem });
return { status: 'answered', alreadySelected: true, labels: plan.labels, answerTexts: plan.answerTexts };
}
const mark = fingerprint(candidate);
const runGeneration = generation;
emit({ busy: true, status: 'matched', message: `正在选择:${plan.answerTexts.join(';')}`, lastStem: questionStem });
try {
const initial = await settled(candidate, mark, runGeneration, continuous);
if (initial === 'cancelled') return { status: 'cancelled', message: '自动选择已暂停。' };
if (initial !== 'stable') return skipped('题目仍在切换或选项身份变化,已停止本次选择。', questionStem);
const actions = candidate.question.type === 'multiple'
? candidate.inputs.map((input, index) => ({ input, index })).filter(({ input, index }) => Boolean(input.checked) !== flags[index])
: candidate.inputs.map((input, index) => ({ input, index })).filter(({ index }) => flags[index]);
for (const { input } of actions) {
if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) {
emit({ busy: false, status: 'idle', message: '自动选择已暂停。' });
return { status: 'cancelled', message: '自动选择已暂停。' };
}
if (!stillSame(candidate, mark)) return skipped('点击前题目或选项已变化,已停止本次选择。', questionStem);
input.click();
const next = await settled(candidate, mark, runGeneration, continuous);
if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) {
emit({ busy: false, status: 'idle', message: '自动选择已暂停。' });
return { status: 'cancelled', message: '自动选择已暂停。' };
}
if (next !== 'stable') return skipped('页面在选择过程中换题或替换控件,已停止后续点击。', questionStem);
}
if (!alreadyChosen(candidate, flags)) return skipped('网页未保留预期的选中状态,请人工核对。', questionStem);
emit({ busy: false, status: 'answered', message: `已选择正确选项:${plan.labels.join('、')}`, answeredCount: state.answeredCount + 1, lastStem: questionStem });
return { status: 'answered', labels: plan.labels, answerTexts: plan.answerTexts };
} catch (error) {
return skipped(`自动选择未完成:${error.message || '控件操作失败'}`, questionStem);
} finally { if (state.busy) emit({ busy: false }); }
}
function schedule() {
if (!state.enabled || destroyed) return;
win.clearTimeout(timer);
timer = setTimer(() => { timer = null; void answerCurrent({ continuous: true }); }, 160);
}
function stopObservers() {
win.clearTimeout(timer); win.clearInterval(interval); timer = null; interval = null;
if (observer) observer.disconnect(); observer = null;
}
function setEnabled(value) {
if (destroyed) return;
const enabled = Boolean(value);
if (state.enabled === enabled) {
// A page can be hidden while a one-shot selection is awaiting a UI
// microtask. Calling false must cancel that run even without continuous mode.
if (!enabled) {
generation++;
stopObservers();
if (state.busy) emit({ busy: false, status: 'idle', message: '自动选择已暂停。' });
}
return;
}
generation++;
stopObservers();
emit({ enabled, busy: false, status: 'idle', message: enabled ? '连续自动选择已开启,仅处理明确匹配的题目。' : '连续自动选择已暂停。' });
if (!enabled) return;
attempts = new WeakMap();
if (win.MutationObserver) {
observer = new win.MutationObserver(mutations => {
if (mutations.some(mutation => {
const element = mutation.target.nodeType === 1 ? mutation.target : mutation.target.parentElement;
return element && !element.closest('#huayi-qb-root');
})) schedule();
});
observer.observe(doc, { subtree: true, childList: true, characterData: true, attributes: true,
attributeFilter: ['class', 'hidden', 'disabled', 'aria-disabled', 'value', 'data-question-id', 'data-ques-id', 'data-bank-id'] });
}
interval = win.setInterval(schedule, 1500);
schedule();
}
function destroy() { setEnabled(false); stopObservers(); destroyed = true; generation++; }
return { answerCurrent, setEnabled, isEnabled: () => state.enabled, getState: () => ({ ...state }), destroy };
}
return { planAnswer, create };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBAuto;
// ---- src/collector.js ----
/* Opt-in completion of one Huayi judgment practice, through ordinary DOM clicks. */
const QBCollector = (() => {
'use strict';
const MAIN = '.yxtulcdsdk-pc-marking-main';
const FORM = `${MAIN} .yxtulcdsdk-form[practice]`;
const EVIDENCE = new Set(['submitted-list', 'answer-response', 'bound-dom', 'explicit-standard']);
const trim = value => String(value == null ? '' : value).replace(/\u00a0/g, ' ').trim();
const flat = value => trim(value).replace(/\s+/gu, '');
const optionsKey = question => JSON.stringify((question.options || []).map(option => [trim(option.id), flat(option.text)]).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))));
const identity = question => JSON.stringify([question.bankId, question.questionId, question.questionIndex, flat(question.stem), optionsKey(question)]);
const evidence = record => EVIDENCE.has(record.answerEvidence) || (record.answerSource || record.source)?.kind === 'network' &&
/\/upm\/[^/]+\/users\/[^/]+\/(?:wrong\/)?answer(?:[?#]|$)/u.test((record.answerSource || record.source)?.url || '');
function create({ window: win, document: doc, core, getRecords = () => [], getQuestion = () => null,
getBank = () => null, isCollecting = () => false, onChange = () => {} }) {
let state = { active: false, phase: 'idle', message: '练习补齐未启动。', reason: '', answered: 0, skipped: 0,
current: 0, total: 0, bankId: '', bankName: '', taskId: '' };
let destroyed = false, timer = null, generation = 0, binding = null, pending = null, navigation = null;
let indexSource = null, indexLength = -1, recordIndex = null, idIndex = null;
const visited = new Map();
const now = () => win.Date?.now ? win.Date.now() : Date.now();
const getState = () => ({ ...state });
function update(patch) {
const old = state;
state = { ...state, ...patch };
if (old.phase !== state.phase || old.active !== state.active) {
try { onChange(getState()); } catch (_) { /* UI code cannot grant further actions. */ }
}
}
function stop(reason = '已停止练习补齐。') {
generation++;
if (timer !== null) win.clearTimeout(timer);
timer = null; pending = null; navigation = null;
update({ active: false, phase: 'stopped', reason: trim(reason), message: trim(reason) || '已停止练习补齐。' });
return getState();
}
function visible(node) {
if (!node || node.isConnected === false || node.closest?.('#huayi-qb-root')) return false;
for (let current = node; current && current.nodeType === 1; current = current.parentElement) {
if (current.hidden || current.getAttribute?.('aria-hidden') === 'true') return false;
const style = win.getComputedStyle?.(current);
if (style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse') return false;
}
return true;
}
function context() {
const url = new URL(String(win.location?.href || ''));
if (!['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(url.hostname) ||
url.pathname !== '/o2o/' || !/^#\/playinfo(?:\?|$)/u.test(url.hash)) throw new Error('仅支持华谊培训大纲中的判断练习页面。');
const params = new URLSearchParams(url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : '');
if (params.get('wq') === '1' || url.searchParams.get('wq') === '1' || /wrong/iu.test(url.hash)) throw new Error('错题重做不属于本次完整题库补齐范围。');
if (!isCollecting()) throw new Error('采集已关闭或题库存储不可用,练习补齐已停止。');
const selected = doc.querySelector('.task-content-active');
if (selected && (!/练习/u.test(selected.textContent || '') || /考试/u.test(selected.textContent || ''))) throw new Error('当前培训任务不是明确的练习,不能启动。');
const forms = [...doc.querySelectorAll(FORM)].filter(visible);
if (forms.length !== 1) throw new Error('当前页面不是唯一的正常练习题,正式考试或不完整页面不能补齐。');
const form = forms[0], main = form.closest(MAIN);
if (!main || !visible(main)) throw new Error('练习题目区域尚未完整显示。');
const heading = trim(main.querySelector('.standard-size-14 span')?.textContent);
if (heading !== '判断题') throw new Error('本功能只支持判断题练习。');
const bank = getBank();
const taskId = trim(bank?.taskId || params.get('taskId'));
const total = bank?.questionCount;
if (!bank || bank.loading || !trim(bank.bankId) || !taskId || ![500, 232].includes(total)) throw new Error('当前题库身份或页面总题数未确认,请等待题库加载。');
const progress = [...doc.querySelectorAll('.yxtulcdsdk-answer-card__top')].filter(visible);
const counts = progress.map(node => trim(node.textContent).match(/(\d+)\s*\/\s*(\d+)(?!\d)/u)).filter(Boolean);
if (counts.length !== 1 || Number(counts[0][2]) !== total || Number(counts[0][1]) > total) throw new Error('页面答题卡总数与当前题库不一致,已停止。');
const route = `${url.pathname}${url.search}${url.hash}`;
if (binding && (bank.bankId !== binding.bankId || taskId !== binding.taskId || route !== binding.route || total !== binding.total)) {
throw new Error('题库、任务或地址已变化,练习补齐已停止。');
}
return { bank, taskId, total, route, form, main };
}
function readView(ctx) {
const question = getQuestion();
const number = trim(ctx.form.querySelector('.yxtulcdsdk-form__sub')?.textContent).match(/^(\d+)\s*[..、]?$/u);
if (!number || !question || question.type !== 'judge' || question.bankId !== ctx.bank.bankId || !trim(question.questionId) ||
!Number.isSafeInteger(question.questionIndex) || question.questionIndex !== Number(number[1]) - 1 ||
question.questionIndex < 0 || question.questionIndex >= ctx.total || question.options?.length !== 2 || !trim(question.stem)) return null;
const labels = [...ctx.form.querySelectorAll('.yxtulcdsdk-ques-radio')].filter(visible);
if (labels.length !== 2) return null;
const controls = labels.map(label => ({ label, input: label.querySelector('input[type="radio"]'),
text: trim(label.querySelector('.yxtf-radio__label')?.textContent || label.textContent) }));
if (controls.some(control => !control.input || !control.input.isConnected || !control.input.value) ||
controls.some((control, position) => control.text !== trim(question.options[position].text) || trim(control.input.value) !== trim(question.options[position].id)) ||
new Set(controls.map(control => control.text)).size !== 2 || !controls.some(control => control.text === '正确') || !controls.some(control => control.text === '错误')) return null;
const feedback = [...ctx.main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible).filter(row =>
['正确答案', '标准答案'].includes(trim(row.querySelector('.yxtulcdsdk-examing__result-line-left')?.textContent)));
if (feedback.length > 1) throw new Error('当前题出现多个标准答案展示,已停止核验。');
let standard = '';
if (feedback.length) {
const row = feedback[0];
const rowId = ['data-question-id', 'data-ques-id', 'question-id', 'ques-id'].map(name => trim(row.getAttribute(name))).find(Boolean);
if (rowId && rowId !== question.questionId) throw new Error('页面标准答案仍属于另一道题,已停止。');
standard = trim(row.querySelector('.yxtulcdsdk-examing__result-line-right')?.textContent);
if (!['正确', '错误'].includes(standard)) throw new Error('网站标准答案尚不明确,已停止。');
}
return { ...ctx, question, controls, standard, feedbackPresent: feedback.length === 1,
checked: controls.some(control => control.input.checked), mark: identity(question) };
}
function recordsAt(index) {
const records = getRecords();
if (!Array.isArray(records)) throw new Error('本地题库不可读取,已停止。');
if (indexSource !== records || indexLength !== records.length) {
indexSource = records; indexLength = records.length; recordIndex = new Map(); idIndex = new Map();
for (const record of records) {
if (!record || record.bankId !== binding.bankId || !Number.isSafeInteger(record.questionIndex)) continue;
if (!recordIndex.has(record.questionIndex)) recordIndex.set(record.questionIndex, []);
recordIndex.get(record.questionIndex).push(record);
for (const id of new Set([record.questionId, ...(record.questionIdAliases || [])].filter(Boolean))) {
if (!idIndex.has(id)) idIndex.set(id, []);
idIndex.get(id).push(record);
}
}
}
return recordIndex.get(index) || [];
}
function matchingRecord(question) {
const candidates = recordsAt(question.questionIndex).filter(record => record.type === 'judge' && flat(record.stem) === flat(question.stem) && optionsKey(record) === optionsKey(question) &&
[record.questionId, ...(record.questionIdAliases || [])].includes(question.questionId));
if (candidates.length > 1) throw new Error('同一题目位置存在多个匹配记录,已停止避免重复提交。');
const record = candidates[0];
if (!record) {
throw new Error('当前题不能与本地题库的位置、完整内容及题目ID唯一对应,已停止。');
}
if ((idIndex.get(question.questionId) || []).length !== 1) throw new Error('当前题目ID或别名对应多个原题位置,已停止。');
if (record.answerStatus === 'conflict' || record.conflicts?.length) throw new Error('本地当前题标准答案存在冲突,已停止。');
if (record.answerStatus === 'missing') return { kind: 'missing', record };
if (record.answerStatus !== 'confirmed' || !evidence(record)) throw new Error('当前题的已保存答案缺少可核验来源,已停止。');
const mapped = core.mapAnswers(record, question);
if (mapped.status !== 'matched' || mapped.answerTexts.length !== 1 || !['正确', '错误'].includes(mapped.answerTexts[0])) throw new Error('标准答案不能唯一对应当前题目,已停止。');
return { kind: 'confirmed', record, standard: mapped.answerTexts[0] };
}
function clickable(element) {
return visible(element) && typeof element.click === 'function' && !element.disabled && element.getAttribute?.('aria-disabled') !== 'true' &&
!element.closest?.('[aria-disabled="true"]');
}
function assertBeforeClick(view) {
if (!state.active || destroyed) return false;
const fresh = readView(context());
if (!fresh || fresh.mark !== view.mark || fresh.form !== view.form) throw new Error('点击前题目身份发生变化,已停止。');
return true;
}
function schedule() {
if (!state.active || destroyed || timer !== null) return;
const token = generation;
timer = win.setTimeout(() => { timer = null; if (state.active && !destroyed && token === generation) tick(); }, 300);
}
function beginNavigation(view, target) {
let button;
if (target === 0 && view.question.questionIndex !== 0) {
const matches = [...doc.querySelectorAll('.yxtulcdsdk-pc-card-list li')].filter(node => visible(node) && trim(node.textContent) === '1');
if (matches.length !== 1) throw new Error('答题卡第1题入口不唯一,不能安全从头核验。');
button = matches[0];
} else {
const matches = [...view.main.querySelectorAll('.yxtulcdsdk-examing__btns button')].filter(node => visible(node) && trim(node.textContent) === '下一题');
if (matches.length !== 1) throw new Error('没有唯一的下一题按钮;不会点击结束、提交或交卷。');
button = matches[0];
}
if (!clickable(button)) throw new Error('换题按钮暂不可用,已停止保留当前结果。');
navigation = { from: view.question.questionIndex, target, deadline: now() + 15000 };
update({ phase: 'navigating', message: `正在核对第 ${target + 1} 题。` });
if (!assertBeforeClick(view)) return;
button.click();
}
function finish() {
if (!state.active) return;
if (visited.size !== binding.total) throw new Error('本次尚未核对完整题库,不能标记完成。');
for (let index = 0; index < binding.total; index++) {
const question = visited.get(index);
if (!question || matchingRecord(question).kind !== 'confirmed') throw new Error(`第 ${index + 1} 题标准答案尚未完整核验,已停止。`);
}
generation++;
if (timer !== null) win.clearTimeout(timer);
timer = null; pending = null; navigation = null;
update({ active: false, phase: 'completed', reason: '', message: `已核验 ${binding.total} 道判断题。本次提交 ${state.answered} 题,跳过 ${state.skipped} 道已有标准答案的题目。` });
}
function advance(view, skipped) {
visited.set(view.question.questionIndex, { ...view.question, options: view.question.options.map(option => ({ ...option })) });
if (skipped) state = { ...state, skipped: state.skipped + 1 };
pending = null;
if (view.question.questionIndex === binding.total - 1) return finish();
beginNavigation(view, view.question.questionIndex + 1);
}
function decide(view) {
update({ phase: 'checking', current: view.question.questionIndex + 1, message: `正在核验第 ${view.question.questionIndex + 1} / ${binding.total} 题。` });
if (!state.active) return;
const local = matchingRecord(view.question);
if (local.kind === 'confirmed') {
if (view.feedbackPresent && view.standard !== local.standard) throw new Error('网页反馈与本地标准答案不一致,已停止保留冲突。');
return advance(view, true);
}
pending = { mark: view.mark, questionIndex: view.question.questionIndex, deadline: now() + 15000, clicked: false };
if (view.feedbackPresent || view.checked) {
update({ phase: 'waiting-answer', message: '当前题已有选择或反馈,正在等待同题标准答案入库,不会重复提交。' });
return;
}
const target = view.controls.find(control => control.text === '正确');
if (!target || !clickable(target.label) || target.input.disabled) throw new Error('当前题“正确”选项不可正常操作,已停止。');
update({ phase: 'selecting', message: `正在通过正常练习提交第 ${view.question.questionIndex + 1} 题,以取得网站标准反馈。` });
if (!assertBeforeClick(view)) return;
target.label.click();
if (!state.active) return;
pending.clicked = true;
update({ phase: 'waiting-answer', answered: state.answered + 1, message: '已提交一次,等待网站标准反馈及同题可信记录;不会重复点击。' });
}
function tick() {
try {
const ctx = context(), view = readView(ctx);
if (state.phase === 'navigating') {
if (now() >= navigation.deadline) throw new Error('换题等待超过15秒,已停止。');
if (!view || view.question.questionIndex === navigation.from) return;
if (view.question.questionIndex !== navigation.target) throw new Error('页面跳到了其他题目,已停止。');
navigation = null;
decide(view);
} else if (state.phase === 'waiting-answer') {
if (!view || view.mark !== pending.mark) throw new Error('等待反馈时题目或选项已变化,已停止。');
if (now() >= pending.deadline) throw new Error('标准反馈或可信入库等待超过15秒,已停止;请人工核对当前题。');
const local = matchingRecord(view.question);
if (!view.feedbackPresent || local.kind !== 'confirmed') return;
if (local.standard !== view.standard) throw new Error('网站标准反馈与本地入库答案不一致,已停止。');
advance(view, !pending.clicked);
} else {
if (!view) throw new Error('当前题目或选项尚不完整,已停止。');
if (view.question.questionIndex !== 0 && visited.size === 0) beginNavigation(view, 0);
else decide(view);
}
} catch (error) { stop(error.message || '练习补齐遇到异常,已停止。'); }
finally { schedule(); }
}
function start() {
if (destroyed || state.active) return getState();
try {
if (!core?.mapAnswers) throw new Error('题库核验模块不可用。');
binding = null;
const ctx = context(), view = readView(ctx);
if (!view) throw new Error('当前判断练习题目未完整加载,不能启动。');
binding = { bankId: ctx.bank.bankId, bankName: ctx.bank.bankName, taskId: ctx.taskId, total: ctx.total, route: ctx.route };
visited.clear(); pending = null; navigation = null; indexSource = null; indexLength = -1; recordIndex = null; idIndex = null;
generation++;
update({ active: true, phase: 'checking', reason: '', answered: 0, skipped: 0, current: view.question.questionIndex + 1,
total: ctx.total, bankId: binding.bankId, bankName: binding.bankName, taskId: binding.taskId,
message: '练习补齐已手动启动,将从第1题核验本题库;仅使用正常练习控件。' });
schedule();
} catch (error) { stop(error.message || '不能启动练习补齐。'); }
return getState();
}
const routeChanged = () => { if (state.active) stop('页面地址已变化,练习补齐已停止。'); };
win.addEventListener?.('hashchange', routeChanged);
win.addEventListener?.('popstate', routeChanged);
win.addEventListener?.('pagehide', routeChanged);
function destroy() {
stop('练习补齐已关闭。'); destroyed = true;
win.removeEventListener?.('hashchange', routeChanged);
win.removeEventListener?.('popstate', routeChanged);
win.removeEventListener?.('pagehide', routeChanged);
}
return { start, stop, getState, destroy };
}
return { create };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBCollector;
// ---- src/app.js ----
// UI and persistence stay inside the userscript sandbox. Page data is rendered as text.
(() => {
const FRAME_SHORTCUT = 'huayi-qb.frame-shortcut.v1';
const isToggleShortcut = event => event.altKey && !event.ctrlKey && !event.metaKey &&
(event.code === 'KeyQ' || (!event.code && String(event.key).toLowerCase() === 'q')) && !event.repeat;
// Frames only forward the explicit shortcut. Keep one panel and one set of
// storage/network handlers in the top page; never relay frame text or answers.
if (window.top !== window.self) {
window.addEventListener('keydown', event => {
if (!isToggleShortcut(event)) return;
event.preventDefault();
window.top.postMessage({ type: FRAME_SHORTCUT }, '*');
}, true);
return;
}
const CONFIG_KEY = 'huayi-qb.config.v1';
const RECORDS_KEY = 'huayi-qb.records.v1';
const BACKUP_KEY = 'huayi-qb.records.before-dedup.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 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 rawStorage = {
get(key, fallback) {
if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
},
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 : {}) };
if (!['open', 'collapsed', 'hidden'].includes(config.mode)) config.mode = 'open';
if (!['search', 'library', 'auto', 'settings'].includes(config.tab)) config.tab = 'search';
['keepLauncher', 'copyEnabled', 'collecting'].forEach(key => { if (typeof config[key] !== 'boolean') config[key] = DEFAULTS[key]; });
// 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 = generationError;
let cleanupNotice = '';
let storedRecordsText = '';
let saveIdle = null;
const SAVE_DELAY = 800;
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('去重备份结构异常,已保留原题库并停止覆盖');
}
function repairAnswerConflicts(original) {
if (!original.some(record => record?.answerStatus === 'conflict' && Array.isArray(record.conflicts) && record.conflicts.some(item =>
item?.reason === '正确选项ID与正确答案内容不一致'))) return { records: original, repaired: [] };
const result = QBCore.repairDuplicateOptionConflicts(original);
if (!result.repaired.length) return result;
const existing = storage.get(OPTION_REPAIR_BACKUP_KEY, null);
if (existing !== null && (!existing || existing.schemaVersion !== 1 || !Array.isArray(existing.records))) {
throw new Error('答案误报修复备份结构异常,已停止修复');
}
// Preserve every original version, including later imports and other tabs.
// Back up before returning repaired data to any caller that may persist it.
const originals = existing ? [...existing.records] : [];
const signatures = new Set(originals.map(record => JSON.stringify(record)));
for (const item of result.repaired) {
const signature = JSON.stringify(item.original);
if (!signatures.has(signature)) { signatures.add(signature); originals.push(item.original); }
}
storage.set(OPTION_REPAIR_BACKUP_KEY, { schemaVersion: 1, savedAt: existing?.savedAt || new Date().toISOString(), records: originals });
return result;
}
function repairJudgeEvidence(original) {
const result = QBCore.repairLegacyJudgeAnswers(original);
if (!result.changed.length) return result;
const existing = storage.get(JUDGE_EVIDENCE_BACKUP_KEY, null);
if (existing !== null && (!existing || existing.schemaVersion !== 1 || !Array.isArray(existing.records))) {
throw new Error('判断题答案核验备份结构异常,已停止覆盖');
}
const originals = existing ? [...existing.records] : [];
const signatures = new Set(originals.map(record => JSON.stringify(record)));
for (const item of result.changed) {
const signature = JSON.stringify(item.original);
if (!signatures.has(signature)) { signatures.add(signature); originals.push(item.original); }
}
// Retain every previous value before cleared or restored answers reach persistence.
const backup = { schemaVersion: 1, savedAt: existing?.savedAt || new Date().toISOString(), records: originals };
storage.set(JUDGE_EVIDENCE_BACKUP_KEY, backup);
if (JSON.stringify(storage.get(JUDGE_EVIDENCE_BACKUP_KEY, null)) !== JSON.stringify(backup)) {
throw new Error('判断题答案核验备份未能完整保存,已停止覆盖');
}
return result;
}
function imageReplacementSignature(value, includeQuestionIdentity = true) {
const record = QBCore.normalizeRecord(value);
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('内容补全备份结构异常,已停止覆盖');
}
return backup;
}
function ensureImageUpgradeBackup(upgraded) {
if (!upgraded.length) return;
const previous = imageUpgradeBackup();
const stored = storage.get(RECORDS_KEY, []);
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) {
const canonical = QBCore.normalizeRecord(item.original);
// Compaction may just have assigned occurrence=0 in memory, without a
// previous storage write. Capture that exact stored predecessor as well;
// only this generated metadata may be absent, never content or answers.
const versions = [item.original, ...stored.filter(value => {
const record = QBCore.normalizeRecord(value);
if (record.key !== canonical.key) return false;
if (record.contentOccurrence == null && canonical.contentOccurrence != null) record.contentOccurrence = canonical.contentOccurrence;
return JSON.stringify(QBCore.normalizeRecord(record)) === JSON.stringify(canonical);
})];
for (const value of versions) {
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), 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);
}
}
storage.set(IMAGE_UPGRADE_BACKUP_KEY, { schemaVersion: 1, savedAt: previous?.savedAt || new Date().toISOString(), records: originals, migrations });
}
function removeBackedUpImagePredecessors(original, replacements) {
const backup = imageUpgradeBackup();
if (!backup) return original;
// A stale tab can reintroduce the exact pre-upgrade record while saving.
// Remove only a durably backed-up version with its unchanged, confirmed
// replacement present. Different answers or newly changed old data survive.
const backups = new Set(backup.records.map(record => JSON.stringify(QBCore.normalizeRecord(record))));
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.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))));
});
}
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, 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 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: 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}`; }
function stopCollector(message) {
if (!collector) return;
try { if (collector.getState().active) collector.stop(message); }
catch (error) { tell(`停止练习补齐失败:${error.message}`, true); }
}
function collectorBlockReason() {
if (!collector) return collectorInitError || '练习补齐模块未加载,请更新完整脚本。';
if (!networkEnabled || !['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(window.location.hostname)) return '仅支持华谊网站当前判断题练习。';
if (!config.collecting) return '请先继续题库采集,再启动练习补齐。';
if (loadError || storageError || importing) return '题库读取、保存或导入尚未就绪,练习补齐已停用。';
const bank = currentBank();
if (!bank?.bankId || bank.loading || currentQuestion()?.type !== 'judge' ||
!document.querySelector('.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form[practice]')) return '请先打开当前判断题的正常练习页面。';
return '';
}
function startCollector() {
const blocked = collectorBlockReason();
if (blocked) { tell(blocked, true); renderAuto(); return; }
try {
autoAnswer.setEnabled(false);
collector.start();
renderAuto();
} catch (error) {
stopCollector(`练习补齐失败:${error.message}`);
tell(`练习补齐失败:${error.message}`, true);
renderAuto();
}
}
function activateKnownPage() {
if (!networkEnabled && document.querySelector('.yxtulcdsdk-pc-marking-main')) {
adapter.stop();
adapter.setCopyEnabled(false);
networkEnabled = true;
scope = '__current__'; searchScope = '__current__';
adapter = createAdapter();
if (config.collecting) adapter.start();
adapter.setCopyEnabled(Boolean(config.copyEnabled));
}
}
function bankChanged(bank) {
if (!bank || (!bank.bankName && !bank.bankId)) {
stopCollector('当前练习题库已离开,练习补齐已停止。');
if (activeBank) {
activeBank = null; scope = ''; searchScope = '';
renderVisible();
}
return;
}
const previous = activeBank;
const sameName = previous && bankNameKey(previous.bankName) === bankNameKey(bank.bankName);
const identityChanged = previous && (!sameName || (previous.taskId && bank.taskId && previous.taskId !== bank.taskId) || (previous.bankId && bank.bankId && previous.bankId !== bank.bankId));
const changed = JSON.stringify(previous) !== JSON.stringify(bank);
activeBank = { ...bank };
if (identityChanged || bank.loading) stopCollector('题库正在切换,练习补齐已停止。');
if (identityChanged) { scope = '__current__'; searchScope = '__current__'; }
if (changed) renderVisible();
}
function adapterStatus(value) {
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() {
if (!synchronizeReset()) return;
cancelPendingSave();
if (loadError || !recordsDirty) return;
try {
// 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('现有题库结构异常,已停止覆盖');
const currentStoredText = JSON.stringify(stored);
// Local records have already been validated when received/imported. Only
// reconcile the whole library when another tab actually changed storage.
if (currentStoredText !== storedRecordsText) {
const retained = removeBackedUpImagePredecessors(stored, records);
const compacted = QBCore.compactRecords(QBCore.upsert(repairAnswerConflicts(repairJudgeEvidence(retained).records).records, records));
if (compacted.removed > 0) ensureCleanupBackup(stored);
records = repairAnswerConflicts(repairJudgeEvidence(compacted.records).records).records;
}
const nextStoredText = JSON.stringify(records);
if (nextStoredText !== currentStoredText) storage.set(RECORDS_KEY, records);
storedRecordsText = nextStoredText;
recordsDirty = false;
storageError = false;
} catch (error) {
storageError = true;
stopCollector('题库保存失败,练习补齐已停止,请先备份数据。');
tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true);
}
}
function schedulePersist() {
if (saveTimer != null || saveIdle !== null) return;
saveTimer = setTimeout(() => {
saveTimer = null;
if (typeof window.requestIdleCallback === 'function') {
saveIdle = window.requestIdleCallback(() => { saveIdle = null; persistRecords(); }, { timeout: 1000 });
} else persistRecords();
}, SAVE_DELAY);
}
let observationIndexSource = null, observationIndex;
function redundantDOMObservation(record) {
if (record.source?.kind !== 'dom' || record.answerStatus !== 'missing' || record.explanation || !record.bankId || !record.questionId) return false;
if (observationIndexSource !== records) {
observationIndexSource = records;
observationIndex = new Map();
for (const saved of records) for (const id of new Set([saved.questionId, ...(saved.questionIdAliases || [])].filter(Boolean))) {
const key = JSON.stringify([saved.bankId, id]);
observationIndex.set(key, observationIndex.has(key) && observationIndex.get(key) !== saved ? null : saved);
}
}
const saved = observationIndex.get(JSON.stringify([record.bankId, record.questionId]));
const flat = value => String(value || '').replace(/\s+/gu, '');
if (!saved || saved.answerStatus !== 'confirmed' || saved.type !== record.type || flat(saved.stem) !== flat(record.stem) ||
(record.questionIndex != null && record.questionIndex !== saved.questionIndex) ||
(record.contentOccurrence != null && record.contentOccurrence !== saved.contentOccurrence)) return false;
return JSON.stringify(saved.options.map(option => flat(option.text)).sort()) ===
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));
if (!incoming.length) return;
incoming = repairAnswerConflicts(repairJudgeEvidence(incoming).records).records;
const bank = currentBank();
const upgradeContext = {
bankId: !bank?.loading && (context.currentBankId || context.bankId) === bank?.bankId ? bank.bankId : '',
expectedCount: context.pageQuestionCount, fullSnapshot: context.fullSnapshot === true
};
if (upgradeContext.fullSnapshot && Number.isSafeInteger(upgradeContext.expectedCount)) {
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;
if (!namesById.has(record.bankId)) namesById.set(record.bankId, new Set());
namesById.get(record.bankId).add(record.bankName);
});
// A newly observed, explicit bank ID/name pair can repair an old stale
// title for that same ID. Never infer a rename from question type or text.
if (namesById.size) records = records.map(record => {
const names = record.bankId && namesById.get(record.bankId);
return names?.size === 1 && record.bankName !== [...names][0] ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record;
});
const expanded = QBCore.upsert(records, incoming);
const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded);
records = repairAnswerConflicts(repairJudgeEvidence(compacted.records).records).records;
recordsDirty = true;
schedulePersist();
renderVisible();
} catch (error) { stopCollector('题目未能保存,练习补齐已停止。'); tell(`这批题目未保存:${error.message}`, true); }
}
function tell(message, error = false) {
status = message;
if (statusBox) statusBox.dataset.error = String(error);
renderStatus();
}
function renderStatus() {
if (statusBox) statusBox.textContent = status;
}
function node(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function setMode(mode) {
config.mode = mode;
persistConfig();
renderMode();
if (mode === 'open' && config.tab === 'search') shadow?.getElementById('qb-search').focus();
}
function renderMode() {
if (!panel) return;
panel.hidden = config.mode === 'hidden';
panel.classList.toggle('collapsed', config.mode === 'collapsed');
launcher.hidden = true;
const collapse = shadow.querySelector('[data-action="collapse"]');
collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起';
collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed'));
if (config.mode === 'open') renderVisible();
}
function selectTab(tab) {
config.tab = tab;
persistConfig();
shadow.querySelectorAll('[data-tab]').forEach(button => {
const selected = button.dataset.tab === tab;
button.setAttribute('aria-selected', String(selected));
button.classList.toggle('active', selected);
});
shadow.querySelectorAll('[data-view]').forEach(view => { view.hidden = view.dataset.view !== tab; });
renderVisible();
}
function renderVisible() {
if (!mounted || config.mode !== 'open') return;
refreshScope(); renderStats(); renderCurrent();
if (config.tab === 'search') renderResults();
if (config.tab === 'auto') renderAuto();
renderStatus();
}
function bankKey(record) { return String(record.bankId || record.bankName || ''); }
function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); }
let catalogSource = null, catalogCache;
function bankCatalog() {
if (catalogSource === records) return catalogCache;
const namedIds = new Map();
records.forEach(record => {
if (!record.bankId) return;
const name = bankNameKey(record.bankName);
if (!namedIds.has(name)) namedIds.set(name, new Set());
namedIds.get(name).add(record.bankId);
});
const catalog = new Map();
records.forEach(record => {
const ids = namedIds.get(bankNameKey(record.bankName));
const key = !record.bankId && ids?.size === 1 ? [...ids][0] : bankKey(record);
if (!catalog.has(key)) catalog.set(key, { key, name: record.bankName || record.bankId || '未命名题库', count: 0 });
catalog.get(key).count++;
});
catalogSource = records;
catalogCache = [...catalog.values()];
return catalogCache;
}
function currentQuestion() {
try { return adapter.getCurrentQuestion(); } catch (_) { return null; }
}
function currentBank() {
try { return adapter.getCurrentBank?.() || activeBank || currentQuestion(); }
catch (_) { return activeBank; }
}
function sameBankScope(record, anchor, knownIds) {
if (record.bankId && anchor.bankId) return record.bankId === anchor.bankId;
const name = String(anchor.bankName || '').replace(/\s+/g, '');
if (!name || name === '未命名题库' || String(record.bankName || '').replace(/\s+/g, '') !== name) return false;
const namedIds = knownIds || new Set(records.filter(item => item.bankId && bankNameKey(item.bankName) === name).map(item => item.bankId));
if (anchor.bankId) namedIds.add(anchor.bankId);
// A human CSV often has a bank name only. Include it only when that name
// identifies one known bank; identical names across bank IDs stay separate.
return namedIds.size <= 1;
}
function scopedRecords(selectedScope = scope) {
if (!selectedScope) return records;
const isCurrent = selectedScope === '__current__';
const anchor = isCurrent ? currentBank() : records.find(record => bankKey(record) === selectedScope);
if (!anchor) return [];
const namedIds = new Set(records.filter(item => item.bankId && bankNameKey(item.bankName) === bankNameKey(anchor.bankName)).map(item => item.bankId));
if (anchor.bankId) namedIds.add(anchor.bankId);
return records.filter(record => (!isCurrent && bankKey(record) === selectedScope) || sameBankScope(record, anchor, namedIds));
}
function scopeName(selectedScope = scope) {
if (selectedScope === '') return '全部题库';
if (selectedScope === '__current__') return currentBank()?.bankName || '当前网页未识别题库';
const anchor = records.find(record => bankKey(record) === selectedScope);
return anchor?.bankName || '未命名题库';
}
function refreshScope() {
if (!shadow || config.mode !== 'open') return;
const banks = new Map(bankCatalog().map(bank => [bank.key, bank.name]));
if (scope && scope !== '__current__' && !banks.has(scope)) scope = '__current__';
if (searchScope && searchScope !== '__current__' && !banks.has(searchScope)) searchScope = '__current__';
const names = [...banks.values()];
const options = [['__current__', `当前题库:${scopeName('__current__')}`], ['', `全部题库(${records.length}题)`], ...[...banks].map(([key, name]) => [key, names.filter(value => value === name).length > 1 ? `${name}(${key === name ? '无ID' : key})` : name])];
['qb-scope', 'qb-search-scope'].forEach(id => {
const select = shadow.getElementById(id);
const signature = JSON.stringify(options);
if (select.dataset.signature !== signature) {
select.dataset.signature = signature;
select.replaceChildren();
options.forEach(([value, label]) => {
const option = node('option', '', label); option.value = value; option.title = label; select.append(option);
});
}
select.value = id === 'qb-scope' ? scope : searchScope;
});
}
function renderStats() {
if (!shadow || config.mode !== 'open') return;
const selected = scopedRecords();
const confirmed = selected.filter(record => record.answerStatus === 'confirmed').length;
const conflicts = selected.filter(record => record.answerStatus === 'conflict').length;
[['total', selected.length], ['confirmed', confirmed], ['missing', selected.length - confirmed - conflicts], ['conflicts', conflicts]].forEach(([name, count]) => {
shadow.getElementById(`qb-stat-${name}`).textContent = count.toLocaleString('zh-CN');
});
const exportName = scopeName();
shadow.getElementById('qb-export-name').textContent = exportName;
shadow.getElementById('qb-export-name').title = exportName;
const catalog = bankCatalog();
shadow.getElementById('qb-library-summary').textContent = `本地共保存 ${catalog.length} 个题库、${records.length} 道题。上方统计与导出均按所选范围。`;
const bankList = shadow.getElementById('qb-bank-list');
bankList.replaceChildren();
catalog.forEach(bank => {
const item = node('div', 'bank-row');
item.append(node('span', '', bank.name), node('strong', '', `${bank.count} 题`));
bankList.append(item);
});
shadow.getElementById('qb-export-preview').textContent = selected.length ? `文件名:${exportFilename(exportName, selected.length, 'csv')}` : '该范围暂无题目;不会导出其他题库。';
const button = shadow.querySelector('[data-action="toggle-capture"]');
button.textContent = config.collecting ? '暂停采集' : '继续采集';
const indicator = shadow.getElementById('qb-capture-state');
indicator.textContent = config.collecting ? '采集中' : '已暂停';
indicator.classList.toggle('paused', !config.collecting);
}
function renderCurrent() {
if (!shadow || config.mode !== 'open') return;
const bank = currentBank();
const current = currentQuestion();
const known = Boolean(bank?.bankName || bank?.bankId);
const name = bank?.bankName || '通用网页 · 本地题库';
shadow.getElementById('qb-context-name').textContent = name;
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;
}
function renderAuto() {
if (!mounted || !shadow || config.mode !== 'open' || config.tab !== 'auto') return;
const state = autoAnswer.getState();
shadow.getElementById('qb-auto-enabled').checked = state.enabled;
shadow.getElementById('qb-auto-status').textContent = state.message || '自动答题尚未开启。可先点击“答当前题”。';
shadow.getElementById('qb-auto-count').textContent = `本页已选择 ${state.answeredCount || 0} 题 · 已跳过 ${state.skippedCount || 0} 题`;
shadow.querySelector('[data-action="auto-answer-current"]').disabled = Boolean(state.busy);
const collection = collector?.getState() || { active: false, answered: 0, skipped: 0, current: 0, total: 0 };
const blocked = collectorBlockReason();
shadow.getElementById('qb-collector-status').textContent = collection.message || blocked || '练习补齐默认关闭;仅在点击启动后运行。';
shadow.getElementById('qb-collector-count').textContent = `本次已作答 ${collection.answered || 0} 题 · 已跳过 ${collection.skipped || 0} 题 · 当前 ${collection.current || 0}/${collection.total || 0}`;
shadow.querySelector('[data-action="start-collector"]').disabled = Boolean(collection.active || blocked);
shadow.querySelector('[data-action="stop-collector"]').disabled = !collection.active;
}
function matchesCurrentQuestion(record, current) {
if (!current || record.type !== current.type) return false;
const flat = value => String(value || '').trim().replace(/\s+/gu, '');
const stem = value => flat(String(value || '').replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, ''));
if (record.bankId && current.bankId && record.bankId !== current.bankId) return false;
if (!(record.bankId && record.bankId === current.bankId) && record.bankName && current.bankName &&
record.bankName !== '未命名题库' && current.bankName !== '未命名题库' && bankNameKey(record.bankName) !== bankNameKey(current.bankName)) return false;
if (Number.isSafeInteger(record.questionIndex) && Number.isSafeInteger(current.questionIndex) && record.questionIndex !== current.questionIndex) return false;
return stem(record.stem) === stem(current.stem) && JSON.stringify((record.options || []).map(option => flat(option.text)).sort()) ===
JSON.stringify((current.options || []).map(option => flat(option.text)).sort());
}
function renderResults() {
if (!resultBox || config.mode !== 'open' || config.tab !== 'search') return;
resultBox.replaceChildren();
const pool = scopedRecords(searchScope);
const current = currentQuestion();
const currentRecords = current ? pool.filter(record => matchesCurrentQuestion(record, current)) : [];
const matches = [];
if (query.trim()) {
const seen = new Set();
// A complete match to the displayed question precedes other keyword candidates.
for (const item of [...QBCore.search(currentRecords, query, { limit: 50, field: searchField }), ...QBCore.search(pool, query, { limit: 50, field: searchField })]) {
if (seen.has(item.record.key)) continue;
seen.add(item.record.key); matches.push(item);
if (matches.length === 50) break;
}
}
const confirmedCurrent = currentRecords.filter(record => record.answerStatus === 'confirmed')
.map(record => QBCore.mapAnswers(record, current));
const currentAnswerAgrees = confirmedCurrent.length > 0 && !currentRecords.some(record => record.answerStatus === 'conflict') &&
confirmedCurrent.every(mapping => mapping.status === 'matched') &&
new Set(confirmedCurrent.map(mapping => JSON.stringify([...mapping.labels].sort()))).size === 1;
const searchName = scopeName(searchScope);
shadow.getElementById('qb-query-state').textContent = query.trim() ? `${searchName} · ${pool.length} 题 · 显示 ${matches.length} 个结果${matches.length === 50 ? '(最多50条,请细化关键词)' : ''}` : `${searchName} · 可输入题干、选项或标准答案关键词`;
if (!matches.length) {
const empty = node('div', 'empty');
empty.append(node('strong', '', query.trim() ? '没有找到匹配题目' : '输入文字,查找正确答案'), node('p', '', query.trim() ? '可切换到全部题库、更换关键词,或导入其他题库。' : '支持输入、粘贴或划选文字;按回车或点搜索查询。多个关键词可用空格分隔。'));
resultBox.append(empty);
return;
}
matches.forEach(({ record, match, matchedField }) => {
const card = node('article', 'result');
const meta = node('div', 'meta');
const statusText = record.answerStatus === 'confirmed' ? '已确认' : record.answerStatus === 'conflict' ? '答案冲突' : '待补答案';
const matchLabel = matchedField === 'options' ? '选项匹配' : matchedField === 'answer' ? '答案匹配' : ({ exact: '题干一致', partial: '文字匹配', similar: '相似候选' })[match] || '候选';
const currentMatches = matchesCurrentQuestion(record, current);
const mapped = currentMatches && record.answerStatus === 'confirmed' ? QBCore.mapAnswers(record, current) : null;
const verifiedCurrent = currentMatches && currentAnswerAgrees && mapped?.status === 'matched';
meta.append(node('span', `badge ${record.answerStatus}`, statusText), node('span', '', `${({ single: '单选', multiple: '多选', judge: '判断' })[record.type] || '题目'} · ${currentMatches ? '当前题完整匹配' : matchLabel}`));
card.append(meta, node('p', 'stem', record.stem));
const answer = node('div', `answer ${record.answerStatus !== 'confirmed' ? 'pending' : ''}`);
answer.append(node('span', 'answer-label', verifiedCurrent ? '本题正确答案' : record.answerStatus === 'missing' ? '标准答案' : '候选题答案'));
if (record.answerStatus === 'confirmed') {
const texts = record.answerTexts || [];
answer.append(node('strong', '', texts.length ? texts.join(';') : '标准答案缺少文字,需核验选项'));
if (verifiedCurrent && mapped.labels?.length) answer.append(node('span', 'mapping', `当前页面对应:${mapped.labels.join('、')}`));
else answer.append(node('span', 'mapping', currentMatches ? '答案尚不能唯一对应当前题,请核对题干和来源。' : current ? '与当前题的题干或完整选项不同,请核对题干。' : '尚未识别当前完整题目,请核对题干。'));
} else {
answer.append(node('strong', '', record.answerStatus === 'conflict' ? '存在不同标准答案,需要核验' : record.type === 'judge' ? '标准答案未确认' : '尚未取得标准答案'));
if (record.answerStatus === 'conflict' && record.conflicts?.length) answer.append(node('span', 'mapping', '冲突记录已保留在 JSON / CSV 备份中。'));
}
card.append(answer);
if (matchedField === 'options') {
const details = node('details', 'explanation');
details.open = true;
details.append(node('summary', '', '题目选项'));
record.options.forEach(option => details.append(node('p', '', `${option.label}. ${option.text}`)));
card.append(details);
}
if (record.explanation) {
const details = node('details', 'explanation');
details.append(node('summary', '', '查看解析'), node('p', '', record.explanation));
card.append(details);
}
card.append(node('div', 'source', `${record.bankName || '未命名题库'}${record.questionId ? ` · ID ${record.questionId}` : ''}`));
resultBox.append(card);
});
}
function runSearch(text) {
if (!synchronizeReset()) return;
query = String(text || '').trim().slice(0, 4000);
if (shadow) shadow.getElementById('qb-search').value = query;
renderResults();
}
function download(contents, filename, mime) {
const blob = new Blob([contents], { type: mime });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url; anchor.download = filename;
(document.body || document.documentElement).append(anchor);
anchor.click(); anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
function safeFilename(value) {
let name = String(value || '未命名题库').replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '');
name = Array.from(name).slice(0, 110).join('');
if (!name || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)) name = `题库_${name || '未命名'}`;
return name;
}
function exportFilename(name, count, format, date = new Date()) {
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date).map(part => [part.type, part.value]));
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();
const bankName = scopeName();
const filename = exportFilename(bankName, selected.length, format, date);
if (format === 'csv') download(QBCore.toCSV(selected), filename, 'text/csv;charset=utf-8');
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() {
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');
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/, '');
let incoming;
if (/\.json$/i.test(file.name) || /^[\s]*[\[{]/.test(text)) {
const payload = JSON.parse(text);
if (!Array.isArray(payload) && payload?.schemaVersion !== 1) throw new Error('不支持这个 JSON 备份版本');
const source = Array.isArray(payload) ? payload : payload.records;
if (!Array.isArray(source) || source.length > 50000) throw new Error('JSON 应包含不超过 50000 道题的 records 数组');
incoming = source.map(record => {
if (!record || typeof record !== 'object' || typeof record.stem !== 'string' || !record.stem.trim()) throw new Error('JSON 题目必须包含非空 stem 题干');
if (!['single', 'multiple', 'judge', 'unknown'].includes(record.type)) throw new Error('JSON 中存在不支持的题型');
if (record.answerStatus && !['confirmed', 'missing', 'conflict'].includes(record.answerStatus)) throw new Error('JSON 中存在不支持的答案状态');
return QBCore.normalizeRecord(record);
});
} else incoming = QBCore.fromCSV(text);
if (!incoming.length) throw new Error('文件中没有可导入的题目');
const fallbackName = String(file.name).replace(/\.(csv|json)$/i, '').trim() || '导入题库';
return incoming.map(record => QBCore.normalizeRecord({ ...record, bankName: !record.bankName || record.bankName === '未命名题库' ? fallbackName : record.bankName }));
}
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 = [];
// Parse every file before changing the library. A bad file cancels this batch.
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;
const expanded = QBCore.upsert(records, repairAnswerConflicts(repairJudgeEvidence(removeBackedUpImagePredecessors(incoming, records)).records).records);
const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded);
const merged = repairAnswerConflicts(repairJudgeEvidence(compacted.records).records).records;
records = merged;
recordsDirty = true;
persistRecords();
renderVisible();
const bankCount = new Set(incoming.map(bankKey)).size;
if (!storageError) tell(`已导入 ${files.length} 个文件、${bankCount} 个题库,共 ${incoming.length} 条记录,新增 ${Math.max(0, records.length - before)} 道题。各题库名称已分别保留。`);
}
const css = `
: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() {
if (mounted || document.getElementById('huayi-qb-root')) return;
activateKnownPage();
root = node('div'); root.id = 'huayi-qb-root';
shadow = root.attachShadow({ mode: 'open' });
const style = node('style'); style.textContent = css;
shadow.append(style);
const ui = node('div');
ui.innerHTML = `
<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 + Q 恢复">隐藏</button></div></header>
<div class="body">
<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" 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><button type="button" class="btn small" data-action="reset-library">重置全部题库</button></div>
</div>
<div class="view" id="qb-view-auto" data-view="auto" role="tabpanel" hidden>
<div class="section"><h3>自动选择正确答案</h3><p>根据本地已确认答案核对题干、题型和完整选项,按当前顺序选择。没有答案、答案冲突或无法唯一匹配的题目会跳过。</p><button type="button" class="btn primary" data-action="auto-answer-current">答当前题</button><label class="check"><input id="qb-auto-enabled" type="checkbox"><span>连续自动选择<small>开启后随题目变化自动选择,仅本页有效;刷新或打开新页面后关闭。</small></span></label><p id="qb-auto-status" role="status"></p><p id="qb-auto-count"></p><p>选择后按页面流程检查、提交或换题;部分网站会自动保存作答。</p></div>
<div class="section"><h3>补齐判断题标准答案</h3><p>会逐题进行正常练习作答并改变练习记录;以网站标准反馈保存。只限当前判断练习,刷新默认关闭。隐藏面板后仍会继续;需要停止时请点击“停止补齐”。</p><div class="row"><button type="button" class="btn primary" data-action="start-collector">补齐当前判断题库</button><button type="button" class="btn" data-action="stop-collector" disabled>停止补齐</button></div><p id="qb-collector-status" role="status">练习补齐默认关闭。</p><p id="qb-collector-count"></p></div>
<div class="section"><h3>适配范围</h3><p>支持华谊练习页面,以及使用标准单选框/复选框和明确题干容器的网页。网页未识别时可继续使用文字搜索。</p></div>
</div>
<div class="view" id="qb-view-settings" data-view="settings" role="tabpanel" hidden>
<div class="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">Alt + Q</kbd></p><p>原快捷键 Shift + Alt + Q 仍可使用。</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 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>缺少网页标准答案依据的旧判断题改为待补;重新取得标准答案后补全,核验前原值单独保留。</p><button type="button" class="btn small" data-action="export-judge-evidence-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>
</section><button type="button" id="qb-launcher" data-action="restore" title="显示题库助手 · Alt + Q" aria-label="显示题库助手" hidden>题库</button>`;
shadow.append(...ui.childNodes);
(document.body || document.documentElement).append(root);
panel = shadow.getElementById('qb-panel'); launcher = shadow.getElementById('qb-launcher');
resultBox = shadow.getElementById('qb-results'); statusBox = shadow.getElementById('qb-status');
mounted = true;
shadow.getElementById('qb-copy-enabled').checked = Boolean(config.copyEnabled);
shadow.addEventListener('click', event => {
const button = event.target.closest('button');
if (!button) return;
if (button.dataset.tab) return selectTab(button.dataset.tab);
try {
switch (button.dataset.action) {
case 'hide': setMode('hidden'); break;
case 'restore': setMode('open'); break;
case 'collapse': setMode(config.mode === 'collapsed' ? 'open' : 'collapsed'); break;
case 'search': runSearch(shadow.getElementById('qb-search').value); break;
case 'clear-search': clearTimeout(searchTimer); runSearch(''); shadow.getElementById('qb-search').focus(); break;
case 'auto-answer-current': stopCollector('已切换为答当前题,练习补齐已停止。'); Promise.resolve(autoAnswer.answerCurrent()).catch(error => tell(`自动选择失败:${error.message}`, true)); break;
case 'start-collector': startCollector(); break;
case 'stop-collector': stopCollector('已手动停止练习补齐。'); renderAuto(); break;
case 'capture-current': {
const captured = adapter.captureCurrent();
renderCurrent();
if (captured) tell(`已检查「${captured.bankName || '当前题库'}」的当前题目;尚未取得的标准答案会标为待补。`);
break;
}
case 'toggle-capture':
config.collecting = !config.collecting;
if (!config.collecting) stopCollector('题库采集已暂停,练习补齐已停止。');
config.collecting ? adapter.start() : adapter.stop();
adapter.setCopyEnabled(Boolean(config.copyEnabled));
persistConfig(); renderStats();
tell(config.collecting ? '已继续采集。此前未观察到的响应,可在重新进入练习后采集。' : '已暂停自动采集,已保存的题库仍可检索和导出。');
break;
case 'export-csv': exportData('csv'); break;
case 'export-json': exportData('json'); break;
case 'export-dedup-backup': exportCleanupBackup(); break;
case 'export-option-repair-backup': exportOptionRepairBackup(); break;
case 'export-image-upgrade-backup': exportImageUpgradeBackup(); break;
case 'export-judge-evidence-backup': exportJudgeEvidenceBackup(); break;
case 'reset-library': resetLibrary(); break;
case 'import': shadow.getElementById('qb-import-file').click(); break;
}
} catch (error) { tell(`操作失败:${error.message}`, true); }
});
shadow.getElementById('qb-search').addEventListener('input', event => {
clearTimeout(searchTimer); const value = event.target.value;
searchTimer = setTimeout(() => runSearch(value), 180);
});
shadow.getElementById('qb-search').addEventListener('keydown', event => {
if (event.key === 'Enter') { clearTimeout(searchTimer); runSearch(event.target.value); }
});
shadow.getElementById('qb-scope').addEventListener('change', event => { scope = event.target.value; renderStats(); });
shadow.getElementById('qb-search-scope').addEventListener('change', event => { searchScope = event.target.value; renderResults(); });
shadow.getElementById('qb-search-field').addEventListener('change', event => { searchField = event.target.value; renderResults(); });
shadow.getElementById('qb-auto-enabled').addEventListener('change', event => {
const enabled = event.target.checked;
if (enabled) stopCollector('已启用连续自动选择,练习补齐已停止。');
autoAnswer.setEnabled(enabled); renderAuto();
});
shadow.getElementById('qb-copy-enabled').addEventListener('change', event => { config.copyEnabled = event.target.checked; adapter.setCopyEnabled(config.copyEnabled); persistConfig(); });
shadow.getElementById('qb-import-file').addEventListener('change', async event => {
const input = event.currentTarget;
if (importing) return;
const files = Array.from(input.files || []);
const attempt = ++importAttempt;
importing = true;
stopCollector('开始导入题库,练习补齐已停止。');
const button = shadow.querySelector('[data-action="import"]');
button.disabled = true;
try { await importFiles(files); }
catch (error) { if (attempt === importAttempt) tell(`导入失败,题库未替换:${error.message}`, true); }
finally { if (attempt === importAttempt) { input.value = ''; importing = false; button.disabled = false; } }
});
if (cleanupNotice) status = cleanupNotice;
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode();
// Navigation/collection is driven by the adapter. A hidden panel has no
// polling/render loop; only an open, foreground panel refreshes its header.
setInterval(() => { if (config.mode === 'open' && !document.hidden) renderCurrent(); }, 2000);
}
document.addEventListener('mouseup', event => {
if (event.composedPath().includes(root)) return;
const active = document.activeElement;
if (active && (active.matches('input,textarea,[contenteditable="true"]'))) return;
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
const selected = window.getSelection()?.toString().trim();
if (selected && selected.length >= 2 && selected.length <= 4000) {
runSearch(selected);
if (shadow && config.tab !== 'search') selectTab('search');
}
}, 120);
});
window.addEventListener('keydown', event => {
if (isToggleShortcut(event)) {
event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden');
}
}, true);
window.addEventListener('message', event => {
if (event.data?.type !== FRAME_SHORTCUT || !event.source || event.source === window) return;
try {
if (event.source.top === window || event.source.top === pageWindow) setMode(config.mode === 'hidden' ? 'open' : 'hidden');
} catch (_) { /* Ignore messages from unrelated or inaccessible windows. */ }
});
window.addEventListener('pagehide', () => { stopCollector('页面已离开,练习补齐已停止。'); autoAnswer.setEnabled(false); persistRecords(); });
window.addEventListener('pageshow', event => {
synchronizeReset();
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') {
GM_registerMenuCommand('显示题库助手(Alt+Q)', () => setMode('open'));
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
GM_registerMenuCommand('重置全部本地题库', resetLibrary);
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup);
GM_registerMenuCommand('导出内容补全前原始备份', exportImageUpgradeBackup);
GM_registerMenuCommand('导出判断题答案核验前原始备份', exportJudgeEvidenceBackup);
}
function mergeRemoteRecords(value) {
if (!Array.isArray(value)) return;
try {
const valueText = JSON.stringify(value);
if (valueText === storedRecordsText) return;
const retained = removeBackedUpImagePredecessors(value, records);
const judgeRepair = repairJudgeEvidence(retained);
const incoming = repairAnswerConflicts(judgeRepair.records);
records = repairAnswerConflicts(repairJudgeEvidence(QBCore.compactRecords(QBCore.upsert(records, incoming.records)).records).records).records;
storedRecordsText = valueText;
if (judgeRepair.changed.length || incoming.repaired.length || retained.length < value.length) { recordsDirty = true; schedulePersist(); }
renderVisible();
}
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();
adapter.setCopyEnabled(Boolean(config.copyEnabled));
if (recordsDirty && !loadError) schedulePersist();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
else mount();
})();
})();