// ==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(//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 = `
题库助手
HUAYI · 本地练习资料
正在识别题库…
本地题库 · 标准答案优先
`; 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(); })(); })();