Files
huayi-question-bank/dist/huayi-question-bank.user.js
T

2079 lines
131 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name 华谊题库助手 · CSV导出与划词检索
// @namespace local.huayi.question-bank
// @version 1.2.1
// @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 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.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(answerTexts.map((value) => comparableAnswer(value, type)), 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.answerSource || answerStatus === 'confirmed') record.answerSource = sourceInfo(input.answerSource || source);
record.key = recordKey(record);
return record;
}
function answerSignature(record) {
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}`)));
}
function conflictSnapshot(record, reason) {
return { reason, stem: record.stem, options: record.options.map((option) => ({ ...option })), answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds], source: record.answerSource || record.source };
}
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]);
if (!byContent.has(key)) byContent.set(key, item);
}
return [...byContent.values()].slice(-40);
}
function mergeRecord(existing, incoming) {
const oldRecord = normalizeRecord(existing);
const newRecord = normalizeRecord(incoming);
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 answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' && answerSignature(oldRecord) !== answerSignature(newRecord);
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, 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 });
}
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) ? mergeRecord(records.get(record.key), record) : record);
lastSeen.set(record.key, index);
}
// Explicit aliases are produced only by content/batch repair. Ordinary
// records with different IDs are still kept separate until that repair.
const bankIdentity = bankResolver([...records.values()]);
for (const target of [...records.values()].filter(record => record.questionIdAliases?.length > 1)) {
if (!records.has(target.key)) continue;
for (const other of [...records.values()]) {
if (other.key === target.key || !records.has(other.key) || !other.questionId) continue;
const current = records.get(target.key);
const aliases = new Set(current.questionIdAliases || []);
if (!aliases.has(other.questionId) || bankIdentity(current) !== bankIdentity(other) || !sameContent(current, other) || current.type !== other.type) continue;
if (current.contentOccurrence != null && other.contentOccurrence != null && current.contentOccurrence !== other.contentOccurrence) continue;
records.set(target.key, foldEquivalent([current, other], { questionId: current.questionId, bankId: current.bankId, bankName: current.bankName, contentOccurrence: current.contentOccurrence }));
records.delete(other.key);
}
}
for (const record of [...records.values()]) {
if (record.questionId || record.contentOccurrence != null || record.answerStatus !== 'missing') continue;
if ([...records.values()].some(other => other.questionIdAliases?.length > 1 && bankIdentity(other) === bankIdentity(record) && other.type === record.type && sameContent(other, record))) records.delete(record.key);
}
// The saved DOM has no stable question ID. Upgrade it only when the complete
// content and bank identify one unique server record in both directions.
const anonymous = [...records.values()].filter((record) => !record.questionId);
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 && a.type === b.type && sameBank(a, b) && sameContent(a, b) &&
(a.contentOccurrence == null || b.contentOccurrence == null || a.contentOccurrence === b.contentOccurrence);
const candidates = new Map(anonymous.map((record) => [record.key, identified.filter((other) => possiblePair(record, other))]));
for (const record of anonymous) {
const matches = candidates.get(record.key);
if (matches.length !== 1) continue;
const target = matches[0];
if (anonymous.filter((other) => candidates.get(other.key).some((candidate) => candidate.key === target.key)).length !== 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) ? mergeRecord(promoted, currentTarget) : mergeRecord(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'));
let result;
for (const record of ordered) {
const rebased = normalizeRecord({ ...record, ...meta });
result = result ? mergeRecord(result, rebased) : rebased;
}
return result;
}
function compactRecords(input) {
if (!Array.isArray(input)) throw new Error('题库必须是数组');
const entries = input.map((value, index) => ({ record: normalizeRecord(value), index, batch: '', occurrence: null }));
const bankIdentity = bankResolver(entries.map(entry => entry.record));
const listSource = record => [record.answerSource, record.source].find(source => source?.kind === 'network' &&
/\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/u.test(source.url || '') && Number.isFinite(Date.parse(source.observedAt)));
const legacySources = new Map();
for (const entry of entries) {
const record = entry.record;
if (record.contentOccurrence != null) entry.occurrence = record.contentOccurrence;
const explicit = record.source.batchId || record.answerSource?.batchId;
if (explicit) entry.batch = `${bankIdentity(record)}:${explicit}`;
else if (entry.occurrence == null) {
const source = listSource(record);
if (!source) continue;
const key = JSON.stringify([bankIdentity(record), source.url]);
if (!legacySources.has(key)) legacySources.set(key, []);
legacySources.get(key).push({ entry, time: Date.parse(source.observedAt) });
}
}
// Older versions timestamped individual rows inside each list response.
// Infer only network list-response clusters; DOM/answer observations are
// never assumed to be full snapshots. Their original order gives duplicate
// occurrence numbers and keeps legitimate repeated questions in each list.
for (const [key, items] of legacySources) {
items.sort((a, b) => a.time - b.time || a.entry.index - b.entry.index);
let previous = -Infinity, batch = '';
for (const item of items) {
if (item.time - previous > 1000) batch = `legacy:${key}:${item.time}`;
item.entry.batch = batch; previous = item.time;
}
}
const counters = new Map();
for (const entry of entries) {
if (!entry.batch || entry.occurrence != null) continue;
const key = JSON.stringify([entry.batch, contentSignature(entry.record)]);
entry.occurrence = counters.get(key) || 0;
counters.set(key, entry.occurrence + 1);
}
const groups = new Map();
const byContent = new Map();
const add = (key, entry) => {
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(entry);
};
for (const entry of entries) {
const content = JSON.stringify([bankIdentity(entry.record), contentSignature(entry.record)]);
entry.content = content;
if (entry.occurrence == null || entry.record.options.length < 2 || entry.record.type === 'unknown') continue;
const key = JSON.stringify([content, entry.occurrence]);
add(key, entry);
if (!byContent.has(content)) byContent.set(content, new Set());
byContent.get(content).add(key);
}
for (const entry of entries) {
if (entry.occurrence != null && entry.record.options.length >= 2 && entry.record.type !== 'unknown') continue;
const matches = [...(byContent.get(entry.content) || [])];
let key;
const matchingAlias = matches.filter(candidate => groups.get(candidate).some(item => item.record.questionId === entry.record.questionId || item.record.questionIdAliases?.includes(entry.record.questionId)));
if (entry.record.questionId && matchingAlias.length === 1) key = matchingAlias[0];
else if (matches.length === 1) key = matches[0];
else if (matches.length > 1 && !entry.record.questionId && entry.record.answerStatus === 'missing') continue;
else key = `unresolved:${entry.record.key}`;
add(key, entry);
}
const records = [];
let duplicateGroups = 0;
for (const group of groups.values()) {
if (group.length > 1) duplicateGroups++;
const identity = {};
const occurrence = group.find(entry => entry.occurrence != null)?.occurrence;
if (occurrence != null) identity.contentOccurrence = occurrence;
records.push(foldEquivalent(group.map(entry => entry.record), identity));
}
return { records, removed: input.length - records.length, groups: duplicateGroups, conflictCount: records.filter(record => record.answerStatus === 'conflict').length };
}
function bigrams(value) {
const chars = Array.from(value);
if (chars.length < 2) return new Set(chars);
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 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 > 1) return result('ambiguous');
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');
}
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, 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 text = value => String(value == null ? '' : value)
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '')
.replace(/<br\s*\/?\s*>/gi, '\n').replace(/<\/?[a-z][^>]*>/gi, '')
.replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => {
const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n);
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '';
}).replace(/&(nbsp|lt|gt|amp|quot|apos);/g, (_, name) =>
({ nbsp: ' ', lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" })[name])
.replace(/\u00a0/g, ' ').trim();
const flat = value => text(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 (answerNode.allowViewSingleQuesAnswer === false || answerNode.allowViewSingleQuesAnswer === 0) {
return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' };
}
if (type === 'judge') {
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' };
}
} 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' };
}
}
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.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);
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 next = { ...inherited, 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,
bankId: url.searchParams.get('praId') || (answerMatch && answerMatch[1]) || (praMatch && praMatch[1]) || '',
answerResponse: Boolean(answerMatch)
};
} 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();
const xhrContext = new WeakMap();
const pendingXHR = new Set();
const banksById = new Map();
const banksByTask = new Map();
const knownSnapshotOrder = new Map();
let currentBank = null;
let pendingSelection = '';
let blockedForm = null;
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(element.textContent) } : 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 = text(progress?.textContent).match(/(\d+)\s*\/\s*(\d+)(?!\d)/);
if (!match) return null;
const done = Number(match[1]), total = Number(match[2]);
return total >= 0 && total <= 100000 && done <= total ? total : null;
}
function bankDOM() {
const selected = doc.querySelector('.task-content-active .group-title, .task-content-active .task-content-color-active');
const selectedName = text(selected?.textContent);
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. */ }
}
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 emit(records, context = {}) {
if (!records.length) return;
const staleBanks = new Set();
if (context.fullSnapshot) {
for (const bankId of new Set(records.map(record => record.bankId))) {
if ((knownSnapshotOrder.get(bankId) || 0) > (context.requestSequence || 0)) { staleBanks.add(bankId); continue; }
for (const [key, old] of known) if (old.bankId === bankId) known.delete(key);
knownSnapshotOrder.set(bankId, context.requestSequence || 0);
}
}
for (const q of records) {
if (q.questionId && !staleBanks.has(q.bankId)) known.set(`${q.bankId}\u001f${q.questionId}`, q);
}
try { onQuestions(records); } catch (error) { status(`保存题目失败:${error.message || error}`); }
}
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 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.textContent);
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).textContent);
return { id: input ? idString(input.value) : '', text: text(rich ? rich.textContent : full.replace(/^[A-Z][.、.]\s*/, '')), label: labelFor(index) };
});
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 = [...known.values()].filter(item => signature(item) === signature(q) &&
(q.bankId ? item.bankId === q.bankId : flat(item.bankName) === flat(q.bankName)));
if (questionIndex !== null) {
const indexed = matches.filter(item => item.questionIndex === questionIndex);
if (indexed.length) matches = indexed;
else if (matches.length && matches.every(item => Number.isSafeInteger(item.questionIndex))) return null;
}
if (blockedForm && (!bank.bankId || matches.length !== 1)) return null;
if (matches.length === 1) {
q.questionId = matches[0].questionId;
q.bankId = matches[0].bankId;
if (Number.isSafeInteger(matches[0].questionIndex)) q.questionIndex = matches[0].questionIndex;
if (Number.isSafeInteger(matches[0].contentOccurrence)) q.contentOccurrence = matches[0].contentOccurrence;
if (matches[0].source?.batchId) q.source.batchId = matches[0].source.batchId;
if (blockedForm) { blockedForm = null; syncBank(); }
}
// Only a row explicitly labelled standard answer can establish correctness.
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')?.textContent);
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)) {
q.correctOptionIds = selected.map(o => o.id).filter(Boolean);
q.answerTexts = selected.map(o => o.text);
q.answerStatus = 'confirmed';
}
} else if (heading === '解析' || heading === '试题解析' || heading === '答案解析') 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;
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;
observer = new win.MutationObserver(mutations => {
const relevant = mutations.some(m => {
const node = m.target.nodeType === 1 ? m.target : m.target.parentElement;
return node && !node.closest('#huayi-qb-root') && node.id !== 'huayi-qb-copy-style';
});
if (!relevant) return;
syncBank();
if (copyEnabled) ensureCopyStyle();
if (!active) return;
win.clearTimeout(timer);
timer = win.setTimeout(() => {
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]); }
}, 180);
});
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;
win.clearTimeout(timer);
uninstallNetwork();
status('采集已暂停,已保存题库和选择复制功能仍可使用。');
}
return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled, isSupportedPage };
}
return { create, extractQuestions };
})();
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);
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', '当前题型暂不支持自动选择。');
const contents = question.options.map(option => optionKey(option.text, question.type));
if (new Set(contents).size !== contents.length || new Set(question.options.map(option => option.label)).size !== question.options.length) return result('ambiguous', '当前选项文字或标记重复,无法唯一对应,已跳过。');
const relevant = [];
for (const input of records) {
let record;
try { record = core.normalizeRecord(input); } catch (_) { continue; }
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 tidy(rich.textContent);
const clone = (label.querySelector('.yxtf-radio__label, .yxtf-checkbox__label') || label).cloneNode(true);
clone.querySelectorAll('input,button,script,style').forEach(node => node.remove());
return tidy(clone.textContent).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 => node.textContent).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 };
}
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 = tidy(titles[0].textContent);
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: '', 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 || '', question.type, stemKey(question.stem), question.options.map(option => [option.label, optionKey(option.text, question.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]); }
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 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();
await Promise.resolve();
if (destroyed || generation !== runGeneration || (continuous && !state.enabled)) {
emit({ busy: false, status: 'idle', message: '自动选择已暂停。' });
return { status: 'cancelled', message: '自动选择已暂停。' };
}
if (!stillSame(candidate, mark)) 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'] });
}
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/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 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 = '';
function ensureCleanupBackup(original) {
const existing = storage.get(BACKUP_KEY, null);
if (existing === null) storage.set(BACKUP_KEY, { schemaVersion: 1, savedAt: new Date().toISOString(), records: original });
else if (!existing || !Array.isArray(existing.records)) throw new Error('去重备份结构异常,已保留原题库并停止覆盖');
}
try {
const saved = storage.get(RECORDS_KEY, []);
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
const compacted = QBCore.compactRecords(QBCore.upsert([], saved));
records = compacted.records;
const removed = saved.length - records.length;
if (removed > 0) {
ensureCleanupBackup(saved);
recordsDirty = true;
cleanupNotice = `已合并 ${removed} 条重复加载记录,原始数据已备份。`;
}
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
let root, shadow, panel, launcher, resultBox, statusBox;
let networkEnabled = ['www.shhuayi-elearning.cn', 'shhuayi-elearning.cn'].includes(window.location.hostname);
let saveTimer, searchTimer, query = '', scope = networkEnabled ? '__current__' : '', searchScope = scope, searchField = 'all';
let activeBank = null, importing = false;
let status = loadError || cleanupNotice || '可输入文字或划选题干检索。';
let storageError = false;
let mounted = false;
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(); } });
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)) {
if (activeBank) {
activeBank = null; scope = ''; searchScope = '';
if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); }
}
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) { scope = '__current__'; searchScope = '__current__'; }
if (mounted && changed) { refreshScope(); renderStats(); renderCurrent(); renderResults(); }
}
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);
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 compacted = QBCore.compactRecords(QBCore.upsert(stored, records));
if (compacted.removed > 0) ensureCleanupBackup(stored);
records = compacted.records;
storage.set(RECORDS_KEY, records);
recordsDirty = false;
storageError = false;
} catch (error) {
storageError = true;
tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true);
}
}
function receive(incoming) {
if (!Array.isArray(incoming) || !incoming.length || loadError) return;
try {
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.
records = records.map(record => {
const names = record.bankId && namesById.get(record.bankId);
return names?.size === 1 ? 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 = compacted.records;
recordsDirty = true;
clearTimeout(saveTimer);
saveTimer = setTimeout(persistRecords, 250);
if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); }
} catch (error) { 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'));
}
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; });
}
function bankKey(record) { return String(record.bankId || record.bankName || ''); }
function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); }
function bankCatalog() {
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++;
});
return [...catalog.values()];
}
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) 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) 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) 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 (!shadow) 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);
}
function renderResults() {
if (!resultBox) return;
resultBox.replaceChildren();
const pool = scopedRecords(searchScope);
const matches = query.trim() ? QBCore.search(pool, query, { limit: 50, field: searchField }) : [];
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;
}
const current = currentQuestion();
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] || '候选';
meta.append(node('span', `badge ${record.answerStatus}`, statusText), node('span', '', `${({ single: '单选', multiple: '多选', judge: '判断' })[record.type] || '题目'} · ${matchLabel}`));
card.append(meta, node('p', 'stem', record.stem));
const answer = node('div', `answer ${record.answerStatus !== 'confirmed' ? 'pending' : ''}`);
answer.append(node('span', 'answer-label', '正确答案'));
if (record.answerStatus === 'confirmed') {
const texts = record.answerTexts || [];
answer.append(node('strong', '', texts.length ? texts.join(';') : '标准答案缺少文字,需核验选项'));
const mapped = current ? QBCore.mapAnswers(record, current) : null;
if (mapped?.status === 'matched' && mapped.labels?.length) answer.append(node('span', 'mapping', `当前页面对应:${mapped.labels.join('、')}`));
else answer.append(node('span', 'mapping', '当前页面选项未确认,请按答案内容核对。'));
} else {
answer.append(node('strong', '', record.answerStatus === 'conflict' ? '存在不同标准答案,需要核验' : '尚未取得标准答案'));
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} 条原始记录。`);
}
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, incoming);
const compacted = QBCore.compactRecords(expanded);
if (compacted.removed > 0) ensureCleanupBackup(expanded);
const merged = compacted.records;
records = merged;
recordsDirty = true;
persistRecords();
refreshScope(); renderStats(); renderCurrent(); renderResults();
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>
</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>
<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': Promise.resolve(autoAnswer.answerCurrent()).catch(error => tell(`自动选择失败:${error.message}`, true)); break;
case 'capture-current': {
const captured = adapter.captureCurrent();
renderCurrent();
if (captured) tell(`已检查「${captured.bankName || '当前题库'}」的当前题目;尚未取得的标准答案会标为待补。`);
break;
}
case 'toggle-capture':
config.collecting = !config.collecting;
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 '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 => { autoAnswer.setEnabled(event.target.checked); 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;
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();
setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000);
}
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', () => { autoAnswer.setEnabled(false); persistRecords(); });
window.addEventListener('pageshow', event => {
if (event.persisted) { 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);
}
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
if (!remote || !Array.isArray(value)) return;
try { records = QBCore.compactRecords(QBCore.upsert(records, value)).records; if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } }
catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ }
});
if (config.collecting) adapter.start();
adapter.setCopyEnabled(Boolean(config.copyEnabled));
if (recordsDirty && !loadError) saveTimer = setTimeout(persistRecords, 250);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
else mount();
})();
})();