Files
huayi-question-bank/dist/huayi-question-bank.user.js
T
2026-10-29 18:08:28 +08:00

1461 lines
92 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.1.0
// @description 按题库采集与同名CSV导出,支持多题库批量导入、文字搜索、划词查答案、选择复制与隐藏面板。
// @match https://www.shhuayi-elearning.cn/*
// @match https://shhuayi-elearning.cn/*
// @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 : {};
return { kind: clean(source.kind) || 'unknown', url: clean(source.url), observedAt: clean(source.observedAt) || new Date().toISOString() };
}
function recordKey(record) {
const bank = record.bankId || record.bankName;
if (record.questionId) return `id:${JSON.stringify([bank, record.questionId])}`;
// Keep the complete signature, rather than a short hash whose collisions could merge answers.
return `fp:${JSON.stringify([bank, record.type, normalizedStem(record.stem), sorted(optionContents(record))])}`;
}
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,
};
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 base = { ...newRecord, 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, 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);
}
// 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);
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 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, 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];
const id = idString(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;
// 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);
return {
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() }
};
}
function extractQuestions(payload, context = {}) {
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 candidate = recordFromNode(node, next);
if (candidate) output.push(candidate);
for (const key of childKeys) if (has(node, key)) visit(node[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);
}
return [...unique.values()];
}
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 create({ window: win, document: doc, onQuestions = () => {}, onStatus = () => {}, onBankChange = () => {} }) {
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;
const known = new Map();
const xhrContext = new WeakMap();
const pendingXHR = new Set();
const banksById = new Map();
const banksByTask = 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 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 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() {
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 next = { bankId, bankName: state.bankName, taskId: state.taskId, loading: state.mismatch || Boolean(blockedForm) };
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) 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;
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 name = record.bankName || context.bankName;
const taskId = flat(name) === flat(context.bankName) ? context.taskId || '' : '';
const value = { bankId: record.bankId, bankName: name, taskId };
banksById.set(record.bankId, value);
if (value.taskId && distinct.size === 1) banksByTask.set(value.taskId, value);
}
}
function emit(records) {
if (!records.length) return;
for (const q of records) {
if (q.questionId) 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() {
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 matches = [...known.values()].filter(item => signature(item) === signature(q) &&
(q.bankId ? item.bankId === q.bankId : flat(item.bankName) === flat(q.bankName)));
if (blockedForm && (!bank.bankId || matches.length !== 1)) return null;
if (matches.length === 1) {
q.questionId = matches[0].questionId;
q.bankId = matches[0].bankId;
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() {
const current = getCurrentQuestion();
if (current) emit([current]);
else status('当前页面未发现可采集的练习题,请进入题目页面。');
return current;
}
function observePayload(payload, context) {
if (!active) return;
try {
const records = extractQuestions(payload, { ...context, knownQuestions: known });
rememberBanks(context, records);
emit(records);
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() {
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) 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) 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) {
copyEnabled = Boolean(value);
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;
ensureBankListeners();
ensureObserver();
installNetwork();
captureCurrent();
status('采集已开启;切换练习或正常作答时,自动保存收到的题目与标准答案。');
}
function stop() {
active = false;
win.clearTimeout(timer);
uninstallNetwork();
status('采集已暂停,已保存题库和选择复制功能仍可使用。');
}
return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled };
}
return { create, extractQuestions };
})();
if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter;
// ---- 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 DEFAULTS = { mode: 'open', keepLauncher: true, 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', 'settings'].includes(config.tab)) config.tab = 'search';
['keepLauncher', 'copyEnabled', 'collecting'].forEach(key => { if (typeof config[key] !== 'boolean') config[key] = DEFAULTS[key]; });
let records = [];
let loadError = '';
try {
const saved = storage.get(RECORDS_KEY, []);
if (!Array.isArray(saved)) throw new Error('本地题库格式不正确');
records = QBCore.upsert([], saved);
} catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; }
let root, shadow, panel, launcher, resultBox, statusBox;
let saveTimer, searchTimer, query = '', scope = '__current__', searchScope = '__current__', searchField = 'all';
let activeBank = null, importing = false;
let status = loadError || '准备就绪,可输入文字搜索,也可选中题干检索。';
let storageError = false;
let mounted = false;
const adapter = QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged });
function bankChanged(bank) {
if (!bank || (!bank.bankName && !bank.bankId)) 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) 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('现有题库结构异常,已停止覆盖');
records = QBCore.upsert(stored, records);
storage.set(RECORDS_KEY, records);
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;
});
records = QBCore.upsert(records, incoming);
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 = config.mode !== 'hidden' || !config.keepLauncher;
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 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 = `${bank?.loading ? '切换中 · ' : ''}本库已采集 ${pool.length} 题 · 已确认 ${pool.filter(record => record.answerStatus === 'confirmed').length} 题`;
shadow.getElementById('qb-current-title').textContent = bank?.loading ? '题库切换中,等待当前题目加载。' : current?.stem || '打开练习题后,这里显示当前题目。';
shadow.getElementById('qb-current-bank').textContent = name;
}
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} 道有已确认答案。`);
}
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 merged = QBCore.upsert(records, incoming);
records = merged;
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:18px;bottom:18px;z-index:2147483646;color:#17283d;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;pointer-events:none;color-scheme:light}
*,*:before,*:after{box-sizing:border-box} [hidden]{display:none!important} button,input,select{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid #63d3c7;outline-offset:2px}
#qb-panel{pointer-events:auto;width:390px;max-width:calc(100vw - 24px);max-height:calc(100dvh - 36px);display:flex;flex-direction:column;border:1px solid #dbe4ed;border-radius:18px;background:#f7f9fc;box-shadow:0 16px 60px #0d24362c;overflow:hidden}
.header{background:#152a40;color:#fff;padding:15px 16px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-shrink:0}.brand{font-size:16px;font-weight:700;letter-spacing:.5px}.subtitle{font-size:11px;color:#bed0df;margin-top:2px}.tools{display:flex;gap:5px}.tools button{border:1px solid #526578;color:#e8f4ff;background:transparent;padding:5px 8px;border-radius:7px;font-size:12px;white-space:nowrap}.tools button:hover{background:#294359}
.body{overflow:auto;overscroll-behavior:contain;min-height:0}.collapsed .body{display:none}.tabs{display:flex;gap:4px;padding:10px 12px 0;background:#fff;border-bottom:1px solid #e7edf3}.tabs button{flex:1;padding:9px 4px;background:none;border:0;border-bottom:3px solid transparent;color:#6b7b8f;font-weight:600}.tabs button.active{border-bottom-color:#008d82;color:#00786e}
.view{padding:14px}.search-wrap{display:flex;gap:7px}.search-wrap input{width:100%;min-width:0;border:1px solid #cdd9e4;border-radius:9px;padding:10px 11px;background:#fff;color:#17283d}.btn{border:1px solid #ccd8e4;border-radius:8px;padding:8px 11px;background:#fff;color:#284059;white-space:nowrap}.btn:hover{background:#edf5f8}.btn.primary{background:#00877d;border-color:#00877d;color:white}.btn.primary:hover{background:#006f66}.btn.small{padding:5px 9px;font-size:12px}
.context{padding:10px 15px;background:#eaf2f6;border-bottom:1px solid #dce6ee}.context-label{color:#718599;font-size:10px}.context strong{display:block;color:#25445b;font-size:12px;line-height:1.6;overflow-wrap:anywhere}.context-count{font-size:10px;color:#587487;margin-top:3px}.search-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:#3e5d74;margin-bottom:8px}.text-button{border:0;background:none;color:#6a7f92;font-size:11px;padding:3px}.text-button:hover{color:#007b70}.search-filters{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:8px;margin-top:10px}.search-filters label{min-width:0;color:#72869a;font-size:10px}.search-filters select{display:block;width:100%;min-width:0;border:1px solid #d1dce6;background:#fff;padding:7px 4px;margin-top:3px;border-radius:7px;color:#37556c;font-size:11px}.export-name{font-size:13px!important;color:#244e65!important;font-weight:600;overflow-wrap:anywhere;margin:4px 0 10px!important}.filename{font-size:10px!important;word-break:break-all;color:#758c9d!important}.bank-list{max-height:130px;overflow:auto;margin-top:8px}.bank-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;border-top:1px solid #edf1f5;padding:7px 0;font-size:11px;color:#657f94}.bank-row span{overflow-wrap:anywhere}.bank-row strong{flex-shrink:0;color:#486b80;font-weight:500}
.hint{font-size:11px;color:#738298;margin:8px 0 12px}.empty{padding:24px 14px;text-align:center;color:#597087;border:1px dashed #cedce5;border-radius:12px;background:#fff}.empty strong{color:#29475f;font-size:15px}.empty p{font-size:12px;line-height:1.8;margin:8px 0 0}.result{border:1px solid #e0e7ee;border-radius:12px;background:#fff;padding:13px;margin-bottom:10px}.meta{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:#8090a2}.badge{padding:2px 7px;border-radius:5px;background:#edf1f6;color:#536b82}.badge.confirmed{background:#e3f6ef;color:#137e55}.badge.missing{background:#fff4d8;color:#966819}.badge.conflict{background:#ffe9e7;color:#b74740}.stem{font-size:13px;line-height:1.75;margin:10px 0;white-space:pre-wrap;overflow-wrap:anywhere}.answer{border-radius:9px;background:#eaf7f3;padding:10px 11px;display:flex;flex-direction:column;gap:4px;overflow-wrap:anywhere}.answer.pending{background:#fff6e7}.answer-label{font-size:10px;letter-spacing:.5px;color:#517467}.answer strong{color:#086550;font-size:16px;line-height:1.6;white-space:pre-wrap}.answer.pending strong{color:#936426;font-size:14px}.mapping{font-size:11px;color:#567367}.source{font-size:10px;color:#8291a1;margin-top:10px;overflow-wrap:anywhere}.explanation{margin-top:9px;color:#617387;font-size:12px}.explanation summary{cursor:pointer}.explanation p{white-space:pre-wrap;overflow-wrap:anywhere}
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-bottom:12px}.stat{background:#fff;border:1px solid #e3eaf1;border-radius:9px;padding:9px 3px;text-align:center}.stat strong{display:block;font-size:21px;color:#234257}.stat span{font-size:10px;color:#7a8ca1}.section{padding:13px;border:1px solid #e0e7ee;background:#fff;border-radius:12px;margin-bottom:12px}.section h3{font-size:13px;margin:0 0 9px}.section p{font-size:12px;color:#718297;margin:5px 0;line-height:1.8;overflow-wrap:anywhere}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.spread{justify-content:space-between}.state{font-size:11px;color:#088574;background:#e7f6ee;border-radius:9px;padding:2px 8px}.state.paused{color:#8c672e;background:#fff3dd}#qb-current-title{font-size:12px;color:#344f66;margin:8px 0;max-height:80px;overflow:auto;white-space:pre-wrap}.scope{display:block;font-size:12px;color:#62778b;margin-bottom:10px}.scope select{margin-top:5px;width:100%;padding:8px;border:1px solid #cfdae5;border-radius:8px;background:#fff;color:#263f57}.check{display:flex;gap:9px;align-items:flex-start;margin:12px 0;color:#304e67;font-size:13px}.check input{accent-color:#00877d;margin-top:4px}.check small{display:block;font-size:11px;color:#7b8da0;margin-top:3px}.kbd{background:#edf1f5;border:1px solid #dce3eb;border-radius:4px;padding:2px 6px;font-family:monospace;font-size:12px;color:#374e62}.footer{padding:9px 14px 12px;font-size:11px;color:#718297;background:#eff4f8;overflow-wrap:anywhere;line-height:1.6;border-top:1px solid #e1e8ef}#qb-status[data-error="true"]{color:#b0473c}.footline{display:flex;gap:6px;align-items:center;margin-bottom:5px}.dot{width:5px;height:5px;background:#079a87;border-radius:50%}#qb-launcher{pointer-events:auto;border:1px solid #1b776f;background:#153f42;color:#fff;box-shadow:0 6px 22px #173b3c33;border-radius:50%;width:54px;height:54px;font-size:13px;font-weight:600}#qb-launcher:hover{background:#086f65}
@media(max-width:480px){:host{right:12px;bottom:12px}#qb-panel{max-height:calc(100dvh - 24px)}.view{padding:12px}.header{padding:12px}.brand{font-size:15px}}
`;
function mount() {
if (mounted || document.getElementById('huayi-qb-root')) return;
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"><span class="context-label">当前题库</span><strong id="qb-context-name">正在识别题库…</strong><div class="context-count" id="qb-context-count"></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="settings" role="tab" aria-controls="qb-view-settings">设置</button></nav>
<div class="view" id="qb-view-search" data-view="search" role="tabpanel"><div class="search-label"><label for="qb-search">输入或粘贴文字查答案</label><button type="button" class="text-button" data-action="clear-search">清空</button></div><div class="search-wrap"><input id="qb-search" type="search" maxlength="4000" placeholder="题干、选项或答案关键词…" aria-label="文字搜索"><button type="button" class="btn primary" data-action="search">搜索</button></div><div class="search-filters"><label>搜索题库<select id="qb-search-scope" aria-label="搜索题库"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><label>搜索内容<select id="qb-search-field" aria-label="搜索内容"><option value="all">全部文字</option><option value="stem">仅题干</option><option value="options">仅选项</option><option value="answer">仅标准答案</option></select></label></div><p class="hint" id="qb-query-state"></p><div id="qb-results" aria-live="polite"></div></div>
<div class="view" id="qb-view-library" data-view="library" role="tabpanel" hidden>
<div class="section"><h3>统计与导出范围</h3><label class="scope">选择题库<select id="qb-scope" aria-label="题库范围"><option value="__current__">当前题库</option><option value="">全部题库</option></select></label><p class="export-name" id="qb-export-name"></p><div class="stats"><div class="stat"><strong id="qb-stat-total">0</strong><span>范围内题数</span></div><div class="stat"><strong id="qb-stat-confirmed">0</strong><span>答案确认</span></div><div class="stat"><strong id="qb-stat-missing">0</strong><span>待补答案</span></div><div class="stat"><strong id="qb-stat-conflicts">0</strong><span>答案冲突</span></div></div><div class="row"><button type="button" class="btn primary" data-action="export-csv">导出 CSV</button><button type="button" class="btn" data-action="export-json">备份 JSON</button></div><p class="filename" id="qb-export-preview"></p></div>
<div class="section"><div class="row spread"><h3>当前练习</h3><span class="state" id="qb-capture-state">采集中</span></div><p id="qb-current-bank"></p><div id="qb-current-title"></div><div class="row"><button type="button" class="btn small primary" data-action="capture-current">采集当前题</button><button type="button" class="btn small" data-action="toggle-capture">暂停采集</button></div><p>记录正常加载的练习题与标准答案。答案尚未返回的题目会标为待补,可在后续练习中继续补齐。</p></div>
<div class="section"><div class="row spread"><h3>已保存题库</h3><button type="button" class="btn small" data-action="import">导入多个题库</button></div><input id="qb-import-file" type="file" accept=".csv,.json,text/csv,application/json" multiple hidden><p>可一次选择多个 CSV / JSON,分别保留题库名称。无名称的文件使用文件名作为题库名。</p><p id="qb-library-summary"></p><div class="bank-list" id="qb-bank-list"></div></div>
</div>
<div class="view" id="qb-view-settings" data-view="settings" role="tabpanel" hidden>
<div class="section"><h3>面板显示</h3><p>右上角可收起或隐藏,刷新后保持原状态。</p><label class="check"><input id="qb-keep-launcher" type="checkbox"><span>隐藏时保留悬浮按钮<small>取消后可完全隐藏,用快捷键或油猴菜单恢复。</small></span></label><p>显示 / 隐藏快捷键:<kbd class="kbd">Alt + Shift + 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>
<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-keep-launcher').checked = Boolean(config.keepLauncher);
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 '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 '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-keep-launcher').addEventListener('change', event => { config.keepLauncher = event.target.checked; persistConfig(); renderMode(); });
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; }
});
refreshScope(); renderStats(); renderCurrent(); renderResults(); 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', persistRecords);
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open'));
GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden'));
GM_registerMenuCommand('完全隐藏题库助手(快捷键或菜单恢复)', () => { config.keepLauncher = false; if (shadow) shadow.getElementById('qb-keep-launcher').checked = false; setMode('hidden'); });
}
if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => {
if (!remote || !Array.isArray(value)) return;
try { records = QBCore.upsert(records, value); if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } }
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 (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
else mount();
})();
})();