Files

152 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Exact answer matching and unified local search. Roles remain source metadata. */
(function (root) {
'use strict';
const cache = new WeakMap();
function normalize(value) {
// Strict answer identity: MPa/mPa, m²/m2 and Ⅰ/I are distinct.
return String(value == null ? '' : value)
.replace(/[\u200b-\u200d\u2060\uFEFF]/g, '')
.replace(/\s+/g, ' ').trim();
}
// Search is intentionally more tolerant; these keys never authorize clicks.
function searchNormalize(value) {
return normalize(value).normalize('NFKC').replace(/[−﹣]/g, '-').toLowerCase();
}
function courseNormalize(value) { return searchNormalize(value); }
function normalizeType(value) {
const type = String(value || '').trim().replace(/^[\[【]|[\]】]$/g, '').replace(/题$/, '');
return ({ 单选: '单选', 单项选择: '单选', single: '单选', radio: '单选', 多选: '多选', 多项选择: '多选', multiple: '多选', checkbox: '多选', 判断: '判断', 判断正误: '判断', boolean: '判断' })[type.toLowerCase()] || type;
}
function optionKey(options) { return JSON.stringify((options || []).map(option => normalize(option.text)).sort()); }
function questionKey(record) { return JSON.stringify([normalizeType(record.type), normalize(record.question)]); }
function correctKey(record) { return JSON.stringify((record.correctTexts || []).map(normalize).sort()); }
function groupKey(record) { return questionKey(record) + '\n' + optionKey(record.options); }
function normalizedLabel(value) { return String(value || '').normalize('NFKC').trim().toUpperCase().replace(/[.、::)]$/, ''); }
function getIndex(snapshot) {
if (!snapshot || !Array.isArray(snapshot.records)) return new Map();
const saved = cache.get(snapshot);
if (saved && saved.revision === snapshot.revision && saved.records === snapshot.records) return saved.index;
const index = new Map();
snapshot.records.forEach(record => {
const key = groupKey(record);
if (!index.has(key)) index.set(key, []);
index.get(key).push(record);
});
cache.set(snapshot, { revision: snapshot.revision, records: snapshot.records, index });
return index;
}
function sourceIdentity(value) {
if (!value) return '';
try {
const url = new URL(value, 'https://local.invalid');
const route = url.hash.startsWith('#/') ? new URL(url.hash.slice(1), url.origin) : url;
for (const params of [route.searchParams, url.searchParams]) {
for (const [key, id] of params) {
if (/^(course_?id|exam_?id|paper_?id|training_?id|lesson_?id)$/i.test(key) && id) return key.toLowerCase().replace(/_/g, '') + ':' + id;
}
}
const exam = route.pathname.match(/\/exam\/([^/]+)\/(?:questions|exercise)\/?$/i);
if (exam) return 'examid:' + decodeURIComponent(exam[1]);
// A source path works after a domain/IP change, while title parameters do not identify a course.
const params = new URLSearchParams(route.search);
['title', 'flag', 'name'].forEach(key => params.delete(key));
params.sort();
return route.pathname + (params.size ? '?' + params.toString() : '');
} catch (_) { return String(value); }
}
function result(status, reason, records, labels, texts) {
return { status, reason, records: records || [], correctLabels: labels || [], correctTexts: texts || [] };
}
function match(question, context, snapshot) {
context = context || {};
if (!question || !['单选', '多选', '判断'].includes(normalizeType(question.type))) return result('unsupported', '当前题型暂不支持自动选择');
if (!normalize(question.question) || !Array.isArray(question.options) || question.options.length < 2) return result('unsupported', '题干或选项尚未完整加载');
if ((question.questionImages && question.questionImages.length) || question.options.some(option => !normalize(option.text) || (option.images && option.images.length))) return result('unsupported', '含图片的题目需要人工核对');
const currentTexts = question.options.map(option => normalize(option.text));
const currentLabels = question.options.map(option => normalizedLabel(option.label));
if (new Set(currentTexts).size !== currentTexts.length || new Set(currentLabels).size !== currentLabels.length || currentLabels.some(label => !/^[A-H]$/.test(label))) return result('unsupported', '当前选项文字或字母重复,无法唯一映射');
let records = getIndex(snapshot).get(groupKey(question)) || [];
if (!records.length) return result('missing', '统一题库没有题型、题干和全部选项均一致的记录');
if (Array.isArray(context.courses)) {
const allowed = new Set(context.courses.map(courseNormalize).filter(Boolean));
records = records.filter(record => allowed.has(courseNormalize(record.course)));
if (!records.length) return result('missing', '已确认的题库主题中没有完全一致的题目记录');
}
let sourceMatched = false;
const identity = sourceIdentity(context.sourceUrl);
if (identity) {
const exactSource = records.filter(record => record.sourceUrl && sourceIdentity(record.sourceUrl) === identity);
if (exactSource.length) { records = exactSource; sourceMatched = true; }
}
const course = courseNormalize(context.course);
if (course) {
const exactCourse = records.filter(record => courseNormalize(record.course) === course);
if (exactCourse.length) { records = exactCourse; sourceMatched = true; }
}
const answers = new Set(records.map(correctKey));
if (answers.size > 1) return result('conflict', '相同题干与选项存在不同答案;当前课程来源未能唯一确定', records);
const wanted = records[0].correctTexts || [];
const wantedKeys = wanted.map(normalize);
if (!wantedKeys.length || new Set(wantedKeys).size !== wantedKeys.length || wantedKeys.some(text => !currentTexts.includes(text))) return result('unsupported', '题库参考答案无法唯一对应当前选项', records);
const selected = question.options.filter(option => wantedKeys.includes(normalize(option.text)));
if (normalizeType(question.type) !== '多选' && selected.length !== 1) return result('unsupported', '单选/判断题的参考答案数量不正确', records);
// Keep label/text pairs in current-page order, but obtain each text from bank evidence.
const evidenceTexts = selected.map(option => wanted.find(text => normalize(text) === normalize(option.text)));
return result('matched', sourceMatched ? '课程来源、题干及选项完全匹配' : '题干及选项完全匹配,所有来源答案一致', records, selected.map(option => normalizedLabel(option.label)), evidenceTexts);
}
function search(query, settings, snapshot) {
settings = settings || {};
const empty = reason => ({ items: [], total: 0, reason, role: '', query: String(query || ''), hasMore: false });
const fullQuery = searchNormalize(query);
if (!fullQuery) return empty('请输入题干、关键词或选项文字');
const tokens = fullQuery.split(/\s+/).filter(Boolean);
const course = courseNormalize(settings.course);
const records = snapshot && Array.isArray(snapshot.records) ? snapshot.records : [];
const groups = new Map(), conflicts = new Map();
records.forEach(record => {
if (course && courseNormalize(record.course) !== course) return;
if (settings.sourceUrl && sourceIdentity(record.sourceUrl) !== sourceIdentity(settings.sourceUrl)) return;
const baseKey = groupKey(record), answerKey = correctKey(record), key = baseKey + '\n' + answerKey;
if (!conflicts.has(baseKey)) conflicts.set(baseKey, new Set());
conflicts.get(baseKey).add(answerKey);
if (!groups.has(key)) groups.set(key, { ...record, sources: [], conflict: false, sourceCount: 0, _baseKey: baseKey, _sources: new Set() });
const group = groups.get(key);
const source = { role: record.role || '', course: record.course, sourceUrl: record.sourceUrl || '', sourceFile: record.sourceFile || '', number: record.number };
const sourceKey = JSON.stringify(source);
if (!group._sources.has(sourceKey)) { group._sources.add(sourceKey); group.sources.push(source); group.sourceCount++; }
});
const items = [];
groups.forEach(group => {
const stem = searchNormalize(group.question);
const optionText = (group.options || []).map(option => searchNormalize(option.text)).join('\n');
const answerText = (group.correctTexts || []).map(searchNormalize).join('\n');
const haystack = stem + '\n' + optionText + '\n' + answerText;
if (!tokens.every(token => haystack.includes(token))) return;
let score = stem === fullQuery ? 10000 : stem.includes(fullQuery) ? 5000 : 0;
tokens.forEach(token => { score += stem.includes(token) ? 100 : optionText.includes(token) ? 30 : 10; });
group.conflict = conflicts.get(group._baseKey).size > 1;
delete group._baseKey; delete group._sources;
items.push({ ...group, score });
});
items.sort((left, right) => right.score - left.score || left.question.localeCompare(right.question, 'zh-CN') || left.course.localeCompare(right.course, 'zh-CN') || left.number - right.number);
const total = items.length;
const limit = Math.max(1, Math.min(100, Number(settings.limit) || 20));
const offset = Math.max(0, Math.floor(Number(settings.offset) || 0));
return { items: items.slice(offset, offset + limit), total, role: '', query: String(query), reason: total ? '' : '统一题库未找到符合条件的题目', hasMore: offset + limit < total };
}
const api = Object.freeze({ version: '3.0.0', normalize, searchNormalize, normalizeType, optionKey, questionKey, sourceIdentity, match, search });
root.YianAnswerMatcher = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);