3259 lines
213 KiB
JavaScript
3259 lines
213 KiB
JavaScript
// ==UserScript==
|
||
// @name 华谊题库助手 · CSV导出与划词检索
|
||
// @namespace local.huayi.question-bank
|
||
// @version 1.2.7
|
||
// @description 全网页快捷键唤出、本地题库文字检索与自动选择答案,按库导入导出;每页默认完全隐藏。
|
||
// @match *://*/*
|
||
// @run-at document-start
|
||
// @noframes
|
||
// @grant unsafeWindow
|
||
// @grant GM_getValue
|
||
// @grant GM_setValue
|
||
// @grant GM_registerMenuCommand
|
||
// @grant GM_addValueChangeListener
|
||
// ==/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 = { '×': '×', '±': '±' };
|
||
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('采集已暂停,已保存题库和选择复制功能仍可使用。');
|
||
}
|
||
|
||
return { start, stop, 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 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 DEFAULTS = { mode: 'hidden', keepLauncher: false, copyEnabled: true, collecting: true, tab: 'search' };
|
||
const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
|
||
const storage = {
|
||
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));
|
||
}
|
||
};
|
||
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;
|
||
let records = [];
|
||
let recordsDirty = false;
|
||
let loadError = '';
|
||
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))));
|
||
});
|
||
}
|
||
try {
|
||
const saved = storage.get(RECORDS_KEY, []);
|
||
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
|
||
storedRecordsText = JSON.stringify(saved);
|
||
const retained = removeBackedUpImagePredecessors(saved, saved);
|
||
const judgeRepair = repairJudgeEvidence(retained);
|
||
const repaired = repairAnswerConflicts(judgeRepair.records);
|
||
const compacted = QBCore.compactRecords(QBCore.upsert([], repaired.records));
|
||
records = compacted.records;
|
||
const removed = retained.length - records.length;
|
||
if (retained.length < saved.length) recordsDirty = true;
|
||
if (removed > 0) {
|
||
ensureCleanupBackup(saved);
|
||
recordsDirty = true;
|
||
cleanupNotice = `已合并 ${removed} 条重复加载记录,原始数据已备份。`;
|
||
}
|
||
if (repaired.repaired.length) {
|
||
recordsDirty = true;
|
||
cleanupNotice += `${cleanupNotice ? ' ' : ''}已恢复 ${repaired.repaired.length} 道重复选项文字造成的答案误报,修复前记录已备份。`;
|
||
}
|
||
if (judgeRepair.changed.length) {
|
||
recordsDirty = true;
|
||
cleanupNotice += `${cleanupNotice ? ' ' : ''}已核验 ${judgeRepair.changed.length} 条判断题答案,缺少标准答案依据的记录已改为待补,原值已独立备份。`;
|
||
}
|
||
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
|
||
let root, shadow, panel, launcher, resultBox, statusBox;
|
||
let networkEnabled = ['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(window.location.hostname);
|
||
let saveTimer, searchTimer, query = '', scope = networkEnabled ? '__current__' : '', searchScope = scope, searchField = 'all';
|
||
let activeBank = null, importing = false;
|
||
let status = loadError || cleanupNotice || '可输入文字或划选题干检索。';
|
||
let storageError = false;
|
||
let mounted = false;
|
||
let collector = null, collectorInitError = '';
|
||
const createAdapter = () => QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged, observeNetwork: networkEnabled });
|
||
let adapter = createAdapter();
|
||
const autoAnswer = QBAuto.create({ window: pageWindow, document, core: QBCore, getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank,
|
||
onStatus: message => { tell(message); }, onChange: () => { renderAuto(); } });
|
||
try {
|
||
if (typeof QBCollector !== 'undefined') collector = QBCollector.create({ window: pageWindow, document, core: QBCore,
|
||
getRecords: () => records, getQuestion: currentQuestion, getBank: currentBank,
|
||
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 persistConfig() {
|
||
try { storage.set(CONFIG_KEY, { ...config }); }
|
||
catch (error) { tell(`设置保存失败:${error.message}`, true); }
|
||
}
|
||
function persistRecords() {
|
||
clearTimeout(saveTimer);
|
||
saveTimer = null;
|
||
if (saveIdle !== null) { window.cancelIdleCallback?.(saveIdle); saveIdle = null; }
|
||
if (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 (!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) {
|
||
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) {
|
||
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() {
|
||
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() {
|
||
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() {
|
||
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() {
|
||
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 (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 (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 + Shift + 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></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>每次打开或刷新网页都默认完全隐藏,不显示悬浮按钮。</p><p>显示 / 隐藏快捷键:<kbd class="kbd">Shift + Alt + Q</kbd></p><p>面板隐藏时,划选文字仍可更新检索内容,面板保持隐藏。</p><button type="button" class="btn small" data-action="hide">立即隐藏</button></div>
|
||
<div class="section"><h3>选择与复制</h3><label class="check"><input id="qb-copy-enabled" type="checkbox"><span>恢复题目区域的选择复制<small>换题后自动适配,关闭后恢复原有处理。</small></span></label></div>
|
||
<div class="section"><h3>答案规则</h3><p>按标准答案内容保存,再对应当前页面的选项位置。没有标准答案就显示待补;不同答案会保留冲突。所有资料保存在当前浏览器,建议定期导出备份。</p></div>
|
||
<div class="section"><h3>重复记录整理</h3><p>重复加载的题目按批次对应合并,保留原题库中的同文题和全部来源 ID。首次整理前会保留原始备份。</p><button type="button" class="btn small" data-action="export-dedup-backup">导出整理前备份</button></div>
|
||
<div 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 + Shift + 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 '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 || []);
|
||
importing = true;
|
||
stopCollector('开始导入题库,练习补齐已停止。');
|
||
const button = shadow.querySelector('[data-action="import"]');
|
||
button.disabled = true;
|
||
try { await importFiles(files); }
|
||
catch (error) { tell(`导入失败,题库未替换:${error.message}`, true); }
|
||
finally { input.value = ''; importing = false; button.disabled = false; }
|
||
});
|
||
if (cleanupNotice) status = cleanupNotice;
|
||
refreshScope(); renderStats(); renderCurrent(); renderResults(); renderAuto(); renderStatus(); selectTab(config.tab); renderMode();
|
||
// 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);
|
||
});
|
||
document.addEventListener('keydown', event => {
|
||
if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyQ' && !event.repeat) {
|
||
event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden');
|
||
}
|
||
}, true);
|
||
window.addEventListener('pagehide', () => { stopCollector('页面已离开,练习补齐已停止。'); autoAnswer.setEnabled(false); persistRecords(); });
|
||
window.addEventListener('pageshow', event => {
|
||
if (event.persisted) { stopCollector('页面已恢复,练习补齐保持关闭。'); autoAnswer.setEnabled(false); config.mode = 'hidden'; renderMode(); }
|
||
});
|
||
if (typeof GM_registerMenuCommand === 'function') {
|
||
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
|
||
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
|
||
GM_registerMenuCommand('导出整理前原始题库备份', exportCleanupBackup);
|
||
GM_registerMenuCommand('导出答案误报修复前原始备份', exportOptionRepairBackup);
|
||
GM_registerMenuCommand('导出内容补全前原始备份', exportImageUpgradeBackup);
|
||
GM_registerMenuCommand('导出判断题答案核验前原始备份', exportJudgeEvidenceBackup);
|
||
}
|
||
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
|
||
if (!remote || !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); }
|
||
});
|
||
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();
|
||
})();
|
||
|
||
})();
|