feat: add portable role-based question bank assistant
This commit is contained in:
@@ -1,59 +1,78 @@
|
||||
# 谊安学练习题库导出
|
||||
# 谊安学助手 2.0
|
||||
|
||||
进入谊安学“练习”页面后自动下载 CSV,包含题干、全部选项、参考答案、答案对应选项、题目解析及图片链接。也可手动导出 JSON、TXT。
|
||||
一个可直接安装的油猴脚本,集成岗位题库搜索、自动选择答案、允许复制文字,以及原有 CSV / JSON / TXT 导出功能。题库在浏览器本地解析和保存,内置 JSZip,无 CDN 依赖。
|
||||
|
||||
脚本读取网页中已加载的参考答案,包括隐藏答案,不点击选项或“查看答案”,不提交练习。样本中的“查看答案”会记录错题,因此采用直接读取的方式。
|
||||
## 安装和更新
|
||||
|
||||
## 在线自动导出
|
||||
1. 在 Chrome / Edge 中安装并启用油猴(Tampermonkey)。
|
||||
2. 新建脚本,将 `谊安学题库导出.user.js` 的全部内容粘贴进去,保存。升级时替换原脚本全文,不要同时启用新旧两份脚本。
|
||||
3. 刷新网页,点击右下角“题库助手”入口展开面板。
|
||||
4. 选择“主操 / 班长 / 副操”,点击“导入题库”,选择 ZIP、CSV 或 JSON 文件。
|
||||
|
||||
1. 在 Chrome / Edge 浏览器的油猴(Tampermonkey)中新建脚本。
|
||||
2. 将 `谊安学题库导出.user.js` 的全部内容粘贴进去,保存并启用。
|
||||
3. 刷新谊安学网页,正常登录,点击进入“练习”。
|
||||
4. 等待题目和答案加载完毕,CSV 会自动下载至浏览器下载目录,右下角显示导出状态。也可点击 CSV / JSON / TXT 按钮重新导出。
|
||||
脚本使用 `@match *://*/*` 和 `@match file:///*`,不固定域名、IP、端口或网址路径,换地址后无需修改脚本。普通网页均显示可折叠入口;`chrome://` 等浏览器限制页面无法注入。访问本地 HTML 时,需在扩展设置中开启“允许访问文件网址”。
|
||||
|
||||
脚本匹配 `http://10.119.67.7/clientcn/*` 和对应 HTTPS 地址,仅在 `/exam/<id>/questions?flag=exercise` 页面自动导出;考试页面不会触发。站点地址变化时,修改脚本顶部的 `@match` 地址。
|
||||
**网址通用不等于所有平台都能自动答题。** 自动选择适配提供的谊安学桌面练习页结构,还需明确识别为“练习”;考试/测试页面及无法识别的页面不会自动选择。`file:` 保存网页只用于搜索、复制和导出,不执行自动选择。
|
||||
|
||||
如果脚本未运行,请检查油猴是否启用、浏览器是否允许扩展运行用户脚本。如果提示已发起下载但没有文件,点击右下角导出按钮;浏览器若提示下载权限,允许本页面下载。
|
||||
## 导入题库与搜索答案
|
||||
|
||||
## 从已下载网页导出
|
||||
- ZIP 内按“主操 / 班长 / 副操”目录自动归类;成功导入后全量替换已有题库。提供的 `题库.zip` 已核对为 28 个 CSV、5,520 条记录:主操 1,620、班长 2,340、副操 1,560。
|
||||
- 单个或多个 CSV / JSON 建议先选择岗位;文件缺少岗位信息时,使用当前选择的岗位。导入会更新相同岗位、课程和来源的数据,保留其他岗位及其他来源。
|
||||
- 所有文件先解析和验证,再保存。题干、选项或参考答案无效时,会提示文件及题目位置,原题库保留。
|
||||
- 油猴存储在同一浏览器中跨网址共享题库及岗位设置,刷新后无需重新导入。无油猴存储 API 时,回退为当前网站的本地存储,面板会明确提示;此模式不能跨网址共享。
|
||||
|
||||
1. 解压原始网页压缩包。
|
||||
2. 双击本项目的 `离线导出题库.html`,保持它与 `question-bank-core.js` 放在同一目录。
|
||||
3. 选择解压得到的 `谊安学.html`,查看题目数和答案完整性,点击所需导出格式。
|
||||
搜索时先选择岗位,可再按课程筛选。输入题干、选项文字或关键词后点击“搜索”;多个关键词用空格分开,结果须同时包含所有关键词。也可选中网页题目文字后点击“用选中文字搜索”。切换岗位会保留当前搜索词并重新查询。
|
||||
|
||||
本地工具只解析 HTML 文本,不运行原网站脚本、不连接原网站。不需要登录或安装 Node.js。输入的是解压后的 HTML 文件。
|
||||
结果显示完整题干、选项、正确答案及课程来源,支持“复制题目与答案”和分页。相同题目、选项及答案合并来源;答案冲突分别展示并标记。遇到冲突,选择对应课程核对。
|
||||
|
||||
油猴脚本也支持原始保存网页;需要在浏览器扩展设置中开启“允许访问文件网址”。通常优先使用上面的离线工具,避免原网页脚本重新启动后清空已保存内容。
|
||||
## 两个功能开关
|
||||
|
||||
## 导出内容和限制
|
||||
**自动选择答案:默认关闭,每次刷新后需手动开启。** 先导入题库、选择岗位,并进入已加载完成的兼容练习页,再开启。脚本按岗位、题型、题干和全部选项精确匹配,优先结合课程来源消歧;按选项文字映射当前字母,支持选项换序。无匹配、冲突未消除、图片题或选项无法唯一对应时,跳过并显示原因。搜索结果不会直接触发自动选择。
|
||||
|
||||
脚本点击网页真实选项,网站会按正常逻辑保存练习记录。单选、判断可能点击后立即判分并锁定;关闭开关只能停止后续操作,不能撤回已生效的选择。多选只调整选中集合,显示“待确认”,由用户在网页中确认。不自动点击“查看答案”或交卷。切换岗位、题库或页面时停止旧任务,随后按新上下文处理尚未作答的题目。
|
||||
|
||||
**允许复制文字:首次使用默认开启,后续保留设置。** 开启后解除已适配的文字选择、复制和右键限制;关闭后恢复网页原有 `body` 限制属性和相关样式。搜索结果的复制按钮独立可用。
|
||||
|
||||
## 网页题目导出
|
||||
|
||||
进入兼容练习页后,默认等待题目和参考答案加载完整,再自动下载 CSV;可在面板关闭自动导出,或点击 CSV / JSON / TXT 按钮手动导出。导出直接读取网页中的参考答案,包括隐藏答案,不用导入题库补全缺失答案。
|
||||
|
||||
- CSV 使用 UTF-8 BOM,可用 Excel 打开;JSON 保留结构化选项和图片地址;TXT 便于阅读。
|
||||
- 参考答案与用户已选择的答案分开处理,导出的是网页提供的参考答案。缺失答案保留为空并提示,不推测答案。
|
||||
- 自动导出等待题目内容稳定,并核对答题卡数量;存在缺题或缺答案时不会自动下载,手动导出会标记不完整。没有答题卡数量时,只能核对当前 DOM 中的题目,不能保证包含网站未加载的题目。
|
||||
- 本版本针对提供的桌面练习页。分页或滚动后才加载的题目,需要先加载全部内容;未加载的内容无法从 DOM 读取。
|
||||
- 图片以链接导出,不会自动打包图片文件。保存网页中的相对图片地址按原始网页地址解析,离线时这些地址可能不可访问。
|
||||
- 提供的样本为“安全领导力-1”,有 120 题,单选、多选、判断各 40 题;全部参考答案存在,其中 112 题答案被隐藏。样本的 120 处解析文本均为空,导出时也为空。
|
||||
- 同一次页面运行中,相同练习内容只自动下载一次;重新刷新或手动点击导出仍可下载。
|
||||
- 缺题或缺答案时不自动下载;手动导出会标记不完整。没有答题卡总数时,只能核对当前页面内容。
|
||||
- 分页或滚动后才加载的题目,需要先加载或逐页导出。同一次页面运行中,相同练习内容只自动下载一次。
|
||||
- 图片以链接导出,不打包图片文件。保存网页的相对图片地址按原始网址解析,离线时可能无法访问。
|
||||
|
||||
## 开发和验证
|
||||
从下载的网页导出,也可使用原有独立工具:
|
||||
|
||||
`question-bank-core.js` 是 DOM 提取与文件导出核心;`practice-exporter.js` 负责练习页面识别、加载等待、状态面板和自动下载。发布用油猴脚本已构建,可直接安装。
|
||||
1. 解压网页压缩包。
|
||||
2. 双击 `离线导出题库.html`,保持它与 `question-bank-core.js` 在同一目录。
|
||||
3. 选择解压后的 HTML 文件,查看完整性提示,再点击导出格式。
|
||||
|
||||
修改源文件后运行:
|
||||
离线工具只解析 HTML 文本,不运行原网站脚本、不连接原网站,不需要登录或 Node.js。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
`question-bank-core.js` 负责网页提取和导出;`bank-store.js`、`answer-matcher.js` 负责本地题库和匹配搜索;`assistant-panel.js`、`copy-controller.js`、`answer-runner.js` 分别负责面板、复制开关和真实选项点击;`practice-exporter.js` 负责页面识别及功能协调。发布脚本已构建,安装时只需一个 `.user.js` 文件。
|
||||
|
||||
修改源码后重新构建:
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
```
|
||||
|
||||
浏览器验证依赖 Playwright 和本机 Chrome,可执行:
|
||||
安装开发依赖并运行验证,需要本机 Chrome:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm test
|
||||
```
|
||||
|
||||
测试使用合成题目模拟隐藏答案、错误选项、延迟加载、页面切换、缺题、缺答案和下载,且不会连接谊安学服务器。若本地存在 `网页样本/考试答案/谊安学.html`,还会核对该样本的 120 道题。
|
||||
使用本机 Edge 时可先设置:
|
||||
|
||||
原始压缩包、解压网页和实际题库文件已在 `.gitignore` 中排除,不会随源代码提交。
|
||||
```powershell
|
||||
$env:PLAYWRIGHT_CHANNEL = 'edge'
|
||||
npm test
|
||||
```
|
||||
|
||||
验证使用本地模拟页面,不连接谊安学服务器;真实登录后的线上使用仍需在实际环境验收。本地存在题库 ZIP 或保存网页样本时,还会核对对应内容。原始 ZIP、解压网页及实际导出题库均由 `.gitignore` 排除,不提交到源码仓库。
|
||||
|
||||
远程仓库:`ssh://git@192.168.10.31:2222/qishizhe/yianxue-shauti.git`。
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/* Exact answer matching and role-scoped local search. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const ROLES = ['主操', '班长', '副操'];
|
||||
const cache = new WeakMap();
|
||||
|
||||
function normalize(value) {
|
||||
return String(value == null ? '' : value).normalize('NFKC')
|
||||
.replace(/[−﹣]/g, '-').replace(/\u00a0/g, ' ')
|
||||
.replace(/[\s\u200b\uFEFF]+/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (!ROLES.includes(record.role)) return;
|
||||
const key = record.role + '\n' + 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 (!ROLES.includes(context.role)) return result('unsupported', '请先选择主操、班长或副操');
|
||||
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(context.role + '\n' + groupKey(question)) || [];
|
||||
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 = normalize(context.course);
|
||||
if (course) {
|
||||
const exactCourse = records.filter(record => normalize(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);
|
||||
return result('matched', sourceMatched ? '岗位、课程、题干及选项完全匹配' : '岗位、题干及选项完全匹配,所有来源答案一致', records, selected.map(option => normalizedLabel(option.label)), selected.map(option => option.text));
|
||||
}
|
||||
|
||||
function search(query, settings, snapshot) {
|
||||
settings = settings || {};
|
||||
const role = settings.role;
|
||||
const empty = reason => ({ items: [], total: 0, reason, role: role || '', query: String(query || ''), hasMore: false });
|
||||
if (!ROLES.includes(role)) return empty('请先选择主操、班长或副操');
|
||||
const fullQuery = normalize(query);
|
||||
if (!fullQuery) return empty('请输入题干、关键词或选项文字');
|
||||
const tokens = fullQuery.split(/\s+/).filter(Boolean);
|
||||
const course = normalize(settings.course);
|
||||
const records = snapshot && Array.isArray(snapshot.records) ? snapshot.records : [];
|
||||
const groups = new Map(), conflicts = new Map();
|
||||
records.forEach(record => {
|
||||
if (record.role !== role || (course && normalize(record.course) !== course)) 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 || '', 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 = normalize(group.question);
|
||||
const optionText = (group.options || []).map(option => normalize(option.text)).join('\n');
|
||||
const answerText = (group.correctTexts || []).map(normalize).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: '2.0.0', normalize, normalizeType, optionKey, questionKey, sourceIdentity, match, search });
|
||||
root.YianAnswerMatcher = api;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,379 @@
|
||||
/* YianAnswerRunner: 只在已识别的练习页面串行点击真实选项。 */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
const CHECKED = 'examtest_content_list_des_checked';
|
||||
const WAIT_STEP = 100;
|
||||
const WAIT_LIMIT = 1200;
|
||||
const CLICK_GAP = 150;
|
||||
const COUNT_KEYS = ['selected', 'pending', 'missing', 'conflict', 'locked', 'failed', 'unsupported'];
|
||||
const CONTEXT_KEYS = ['role', 'course', 'sourceUrl', 'routeKey', 'pageSignature', 'revision'];
|
||||
|
||||
function normalize(value) {
|
||||
return String(value == null ? '' : value).replace(/\u00a0/g, ' ').replace(/[\t\n\r ]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function questionSignature(question) {
|
||||
// 答案、checked class 和展开解析不属于题目身份,判分后它们会正常变化。
|
||||
return JSON.stringify([question.type, question.question, (question.questionImages || []), question.options.map(function (option) {
|
||||
return [option.label, option.text, option.images || []];
|
||||
})]);
|
||||
}
|
||||
|
||||
function bankSignature(bank) {
|
||||
return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(questionSignature)]);
|
||||
}
|
||||
|
||||
function isLocked(box, selectors) {
|
||||
if (box.querySelector('.pc_examtest_content_form_disabled')) return true;
|
||||
const component = box.closest('[app-exam-question],app-exam-question');
|
||||
const flags = [box, component].filter(Boolean);
|
||||
if (flags.some(function (element) {
|
||||
return element._showanswer === true || element.getAttribute('_showanswer') === 'true' || element.getAttribute('data-showanswer') === 'true';
|
||||
})) return true;
|
||||
if (!box.querySelector('.pc_examtest_content_question_showanswer')) return true;
|
||||
// 原页面已判分时 option 的直接父容器由 child1 变为 child2;隐藏参考答案不表示锁定。
|
||||
return Array.from(box.querySelectorAll(selectors.option)).some(function (node) {
|
||||
const parent = node.parentElement;
|
||||
return parent && parent.matches('.pc_examtest_content_list_child2,.pc_examtest_content_list_parent_child2,.parent_child2');
|
||||
});
|
||||
}
|
||||
|
||||
function optionState(box, selectors) {
|
||||
const nodes = Array.from(box.querySelectorAll(selectors.option));
|
||||
return {
|
||||
nodes: nodes,
|
||||
selected: nodes.map(function (node) { return node.classList.contains(CHECKED); }),
|
||||
locked: isLocked(box, selectors)
|
||||
};
|
||||
}
|
||||
|
||||
function sameSelection(actual, wanted) {
|
||||
return actual.length === wanted.length && actual.every(function (selected, index) { return selected === wanted[index]; });
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
options = options || {};
|
||||
if (typeof options.getContext !== 'function' || typeof options.match !== 'function') throw new TypeError('自动选择需要 getContext 和 match。');
|
||||
const doc = root.document;
|
||||
const core = root.QuestionBankCore;
|
||||
if (!doc || !core || !core.selectors || typeof core.extract !== 'function') throw new Error('自动选择需要 QuestionBankCore 和当前网页。');
|
||||
const selectors = core.selectors;
|
||||
let active = null;
|
||||
let scope = null;
|
||||
let records = new Map();
|
||||
let total = 0;
|
||||
let destroyed = false;
|
||||
let generation = 0;
|
||||
|
||||
function notifyState(state, reason) {
|
||||
if (typeof options.onState === 'function') options.onState(state, reason || '');
|
||||
}
|
||||
|
||||
function progress() {
|
||||
const counts = { selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: total };
|
||||
const details = [];
|
||||
records.forEach(function (record) {
|
||||
if (COUNT_KEYS.includes(record.status)) counts[record.status]++;
|
||||
if (record.pending) counts.pending++;
|
||||
details.push({ number: record.number, status: record.status, reason: record.reason || '' });
|
||||
});
|
||||
counts.details = details;
|
||||
return counts;
|
||||
}
|
||||
|
||||
function notifyProgress() {
|
||||
const value = progress();
|
||||
if (typeof options.onProgress === 'function') options.onProgress(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function setRecord(item, status, reason, pending) {
|
||||
records.set(item.key, { number: item.question.number, status: status, reason: reason || '', pending: Boolean(pending) });
|
||||
notifyProgress();
|
||||
}
|
||||
|
||||
function captureContext(context) {
|
||||
const captured = { root: context.root, pageURL: String(root.location && root.location.href || '') };
|
||||
CONTEXT_KEYS.forEach(function (key) { captured[key] = context[key]; });
|
||||
return captured;
|
||||
}
|
||||
|
||||
function sameContext(left, right) {
|
||||
return left && right && left.root === right.root && left.pageURL === right.pageURL && CONTEXT_KEYS.every(function (key) { return left[key] === right[key]; });
|
||||
}
|
||||
|
||||
function initialReason(context) {
|
||||
if (!context || context.ready !== true) return '等待练习页面就绪。';
|
||||
if (context.local === true || root.location && root.location.protocol === 'file:') return '保存的静态网页可搜索和导出,不能记录自动选择。';
|
||||
if (context.practice !== true) return '当前页面未识别为练习,自动选择已停止。';
|
||||
if (!context.role) return '请先选择主操、班长或副操。';
|
||||
if (!context.root || !context.root.isConnected || context.root.ownerDocument !== doc) return '练习内容已切换,等待新页面。';
|
||||
return '';
|
||||
}
|
||||
|
||||
function cancel(run) {
|
||||
if (!run || run.cancelled) return;
|
||||
run.cancelled = true;
|
||||
run.waiters.forEach(function (finish) { finish(false); });
|
||||
run.waiters.clear();
|
||||
}
|
||||
|
||||
function endActive(state, reason) {
|
||||
const old = active;
|
||||
generation++;
|
||||
active = null;
|
||||
cancel(old);
|
||||
if (!destroyed) notifyState(state, reason);
|
||||
return progress();
|
||||
}
|
||||
|
||||
function pause(reason) { return endActive('paused', reason || '已暂停自动选择。'); }
|
||||
|
||||
function stop(reason) { return endActive('stopped', reason || '自动选择已关闭。'); }
|
||||
|
||||
function wait(run, milliseconds) {
|
||||
if (run.cancelled || active !== run) return Promise.resolve(false);
|
||||
return new Promise(function (resolve) {
|
||||
let timer;
|
||||
let settled = false;
|
||||
const finish = function (ok) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
root.clearTimeout(timer);
|
||||
run.waiters.delete(finish);
|
||||
resolve(ok && !run.cancelled && active === run);
|
||||
};
|
||||
run.waiters.add(finish);
|
||||
timer = root.setTimeout(function () { finish(true); }, milliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
function contextReason(run) {
|
||||
if (destroyed || run.cancelled || active !== run || run.id !== generation) return '自动选择已取消。';
|
||||
const context = options.getContext();
|
||||
const reason = initialReason(context);
|
||||
if (reason) return reason;
|
||||
if (!sameContext(run.context, captureContext(context))) return '页面、岗位或题库已切换,已停止本轮自动选择。';
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractItems(context) {
|
||||
const boxes = Array.from(doc.querySelectorAll(selectors.question));
|
||||
const bank = core.extract(doc);
|
||||
if (bank.questions.length !== boxes.length) throw new Error('题目与选项尚未同步完成。');
|
||||
const items = boxes.map(function (box, index) {
|
||||
const question = bank.questions[index];
|
||||
const signature = questionSignature(question);
|
||||
return { box: box, index: index, question: question, signature: signature, key: index + '\u0000' + signature };
|
||||
}).filter(function (item) { return context.root === item.box || context.root.contains(item.box); });
|
||||
return { items: items, signature: bankSignature(bank) };
|
||||
}
|
||||
|
||||
function readCurrent(run, item) {
|
||||
const reason = contextReason(run);
|
||||
if (reason) return { reason: reason, cancelled: true };
|
||||
if (!item.box.isConnected || !run.context.root.contains(item.box) && run.context.root !== item.box) return { reason: '题目节点已更新,已停止本轮自动选择。', cancelled: true };
|
||||
const boxes = Array.from(doc.querySelectorAll(selectors.question));
|
||||
if (boxes[item.index] !== item.box) return { reason: '题目顺序已变化,已停止本轮自动选择。', cancelled: true };
|
||||
const bank = core.extract(doc);
|
||||
// 父面板的 MutationObserver 尚未运行时,也直接阻断标题或其他题目的变化。
|
||||
if (bankSignature(bank) !== run.bankSignature) return { reason: '练习题目或课程已更新,已停止本轮自动选择。', cancelled: true };
|
||||
const current = bank.questions[item.index];
|
||||
if (!current || questionSignature(current) !== item.signature) return { reason: '题干或选项已变化,已停止本轮自动选择。', cancelled: true };
|
||||
const state = optionState(item.box, selectors);
|
||||
if (state.nodes.length !== current.options.length) return { reason: '选项尚未完整加载。' };
|
||||
return { question: current, state: state };
|
||||
}
|
||||
|
||||
function clickable(node, item) {
|
||||
if (!node || !node.isConnected || !item.box.contains(node) || node.ownerDocument !== doc) return false;
|
||||
if (node.closest('[hidden],[inert]') || node.getAttribute('aria-disabled') === 'true' || node.hasAttribute('disabled')) return false;
|
||||
const style = root.getComputedStyle(node);
|
||||
return node.getClientRects().length > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.visibility !== 'collapse' && style.pointerEvents !== 'none';
|
||||
}
|
||||
|
||||
function abortForChange(run, reason) {
|
||||
if (active === run && !run.cancelled) stop(reason);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function clickOption(run, item, optionIndex, wantedChecked) {
|
||||
const before = readCurrent(run, item);
|
||||
if (before.cancelled) return { cancelled: true, reason: before.reason };
|
||||
if (before.reason) return { reason: before.reason };
|
||||
if (before.state.locked) return { locked: true, reason: '该题已判分或锁定,不能修改。' };
|
||||
if (before.state.selected[optionIndex] === wantedChecked) return { ok: true };
|
||||
const node = before.state.nodes[optionIndex];
|
||||
if (!clickable(node, item)) return { reason: '选项不可点击,已跳过。' };
|
||||
// 最后一次即时检查,避免异步匹配期间已经换路由或岗位。
|
||||
const reason = contextReason(run);
|
||||
if (reason) return { cancelled: true, reason: reason };
|
||||
node.click();
|
||||
for (let elapsed = 0; elapsed <= WAIT_LIMIT; elapsed += WAIT_STEP) {
|
||||
const current = readCurrent(run, item);
|
||||
if (current.cancelled) return { cancelled: true, reason: current.reason };
|
||||
if (current.reason) return { reason: current.reason };
|
||||
if (current.state.selected[optionIndex] === wantedChecked) {
|
||||
if (!await wait(run, CLICK_GAP)) return { cancelled: true, reason: '自动选择已取消。' };
|
||||
const settled = readCurrent(run, item);
|
||||
if (settled.cancelled) return { cancelled: true, reason: settled.reason };
|
||||
if (settled.reason) return { reason: settled.reason };
|
||||
if (settled.state.selected[optionIndex] !== wantedChecked) return { reason: '网页未保留选项状态,已停止修改该题。' };
|
||||
return { ok: true };
|
||||
}
|
||||
if (current.state.locked) return { locked: true, reason: '选项点击后题目锁定,但未确认目标选项,已停止修改该题。' };
|
||||
if (elapsed < WAIT_LIMIT && !await wait(run, WAIT_STEP)) return { cancelled: true, reason: '自动选择已取消。' };
|
||||
}
|
||||
return { reason: '网页在限定时间内未更新选项状态,已跳过。' };
|
||||
}
|
||||
|
||||
function resolveTargets(question, matched) {
|
||||
const labels = Array.isArray(matched.correctLabels) ? matched.correctLabels.map(normalize) : String(matched.correctLabels || '').split(/[\s,,、;;]+/).filter(Boolean);
|
||||
if (!labels.length || new Set(labels).size !== labels.length) return { reason: '正确选项为空或重复,已跳过。' };
|
||||
const actualLabels = question.options.map(function (option) { return normalize(option.label); });
|
||||
if (new Set(actualLabels).size !== actualLabels.length || labels.some(function (label) { return !actualLabels.includes(label); })) return { reason: '答案不能唯一对应网页选项,已跳过。' };
|
||||
if (question.type !== '多选' && labels.length !== 1) return { reason: '单选或判断题匹配到多个答案,已跳过。' };
|
||||
if (Array.isArray(matched.correctTexts) && matched.correctTexts.length) {
|
||||
const expectedTexts = matched.correctTexts.map(normalize).sort();
|
||||
const actualTexts = question.options.filter(function (option) { return labels.includes(normalize(option.label)); }).map(function (option) { return normalize(option.text); }).sort();
|
||||
if (JSON.stringify(expectedTexts) !== JSON.stringify(actualTexts)) return { reason: '匹配答案文字与网页选项不一致,已跳过。' };
|
||||
}
|
||||
return { wanted: actualLabels.map(function (label) { return labels.includes(label); }) };
|
||||
}
|
||||
|
||||
async function processQuestion(run, item) {
|
||||
run.currentBox = item.box;
|
||||
let current = readCurrent(run, item);
|
||||
if (current.cancelled) return abortForChange(run, current.reason);
|
||||
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
|
||||
if (records.has(item.key)) return true;
|
||||
if (!item.box.closest('[app-exam-question],app-exam-question')) { setRecord(item, 'unsupported', '题目缺少已适配的练习组件,已跳过。'); return true; }
|
||||
if (!['单选', '多选', '判断'].includes(current.question.type)) { setRecord(item, 'unsupported', '暂不支持该题型自动选择。'); return true; }
|
||||
if (current.state.locked) { setRecord(item, 'locked', '该题已判分或锁定,保留原作答。'); return true; }
|
||||
const matched = await options.match(current.question, options.getContext());
|
||||
current = readCurrent(run, item);
|
||||
if (current.cancelled) return abortForChange(run, current.reason);
|
||||
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
|
||||
if (current.state.locked) { setRecord(item, 'locked', '匹配期间该题已锁定,保留原作答。'); return true; }
|
||||
if (!matched || matched.status !== 'matched') {
|
||||
const status = matched && ['missing', 'conflict', 'unsupported'].includes(matched.status) ? matched.status : 'unsupported';
|
||||
setRecord(item, status, matched && matched.reason || '未获得可自动选择的唯一答案。');
|
||||
return true;
|
||||
}
|
||||
const target = resolveTargets(current.question, matched);
|
||||
if (target.reason) { setRecord(item, 'unsupported', target.reason); return true; }
|
||||
let changed = false;
|
||||
if (current.question.type === '多选') {
|
||||
// 每次按当前集合核对;先去掉错误选项,随后补选目标选项,不重复 toggle。
|
||||
const planned = [];
|
||||
target.wanted.forEach(function (wanted, index) { if (!wanted && current.state.selected[index]) planned.push({ index: index, checked: false }); });
|
||||
target.wanted.forEach(function (wanted, index) { if (wanted && !current.state.selected[index]) planned.push({ index: index, checked: true }); });
|
||||
for (const step of planned) {
|
||||
const result = await clickOption(run, item, step.index, step.checked);
|
||||
if (result.cancelled) return abortForChange(run, result.reason);
|
||||
if (!result.ok) { setRecord(item, result.locked && !changed ? 'locked' : 'failed', result.reason); return true; }
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
const wantedIndex = target.wanted.indexOf(true);
|
||||
if (!sameSelection(current.state.selected, target.wanted)) {
|
||||
if (current.state.selected[wantedIndex]) { setRecord(item, 'failed', '单选题有多个选中状态,已跳过。'); return true; }
|
||||
const result = await clickOption(run, item, wantedIndex, true);
|
||||
if (result.cancelled) return abortForChange(run, result.reason);
|
||||
if (!result.ok) { setRecord(item, result.locked ? 'locked' : 'failed', result.reason); return true; }
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
current = readCurrent(run, item);
|
||||
if (current.cancelled) return abortForChange(run, current.reason);
|
||||
if (current.reason) { setRecord(item, 'failed', current.reason); return true; }
|
||||
if (!sameSelection(current.state.selected, target.wanted)) { setRecord(item, 'failed', '网页当前选中集合与答案不一致,已停止修改该题。'); return true; }
|
||||
const pending = current.question.type === '多选' && !current.state.locked;
|
||||
setRecord(item, 'selected', pending ? '已选择,待网页确认。' : changed ? '已选择匹配答案。' : '当前已选中匹配答案。', pending);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function execute(run, items) {
|
||||
try {
|
||||
for (const item of items) {
|
||||
if (!await processQuestion(run, item)) return progress();
|
||||
if (run.cancelled || active !== run) return progress();
|
||||
}
|
||||
if (active === run && !run.cancelled) {
|
||||
active = null;
|
||||
notifyState('complete', '本页自动选择已完成。');
|
||||
}
|
||||
} catch (error) {
|
||||
if (active === run && !run.cancelled) {
|
||||
if (run.currentBox) {
|
||||
const item = items.find(function (candidate) { return candidate.box === run.currentBox; });
|
||||
if (item) setRecord(item, 'failed', error && error.message || '读取或点击选项失败。');
|
||||
}
|
||||
pause(error && error.message || '自动选择遇到错误,已暂停。');
|
||||
}
|
||||
} finally {
|
||||
cancel(run);
|
||||
}
|
||||
return progress();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (destroyed) return Promise.resolve(progress());
|
||||
if (active && !active.cancelled) return active.promise;
|
||||
const context = options.getContext();
|
||||
const reason = initialReason(context);
|
||||
if (reason) { notifyState('idle', reason); return Promise.resolve(progress()); }
|
||||
const captured = captureContext(context);
|
||||
if (!sameContext(scope, captured)) { scope = captured; records = new Map(); total = 0; }
|
||||
let extracted;
|
||||
try { extracted = extractItems(context); }
|
||||
catch (error) { notifyState('idle', error.message || '等待题目加载。'); return Promise.resolve(progress()); }
|
||||
const items = extracted.items;
|
||||
total = items.length;
|
||||
const currentKeys = new Set(items.map(function (item) { return item.key; }));
|
||||
records.forEach(function (_, key) { if (!currentKeys.has(key)) records.delete(key); });
|
||||
notifyProgress();
|
||||
if (!items.length) { notifyState('idle', '当前没有可处理的练习题目。'); return Promise.resolve(progress()); }
|
||||
const run = { id: ++generation, context: captured, bankSignature: extracted.signature, cancelled: false, waiters: new Set(), currentBox: null, promise: null };
|
||||
active = run;
|
||||
// 先写入 promise,再通知或执行,使同步 UI 回调中的 start 也不会并发运行。
|
||||
run.promise = Promise.resolve().then(function () {
|
||||
if (active !== run || run.cancelled) return progress();
|
||||
notifyState('running', '正在选择当前岗位题库中的匹配答案。');
|
||||
return execute(run, items);
|
||||
});
|
||||
return run.promise;
|
||||
}
|
||||
|
||||
function manualClick(event) {
|
||||
if (!event.isTrusted || !active || active.cancelled) return;
|
||||
const target = event.target && event.target.nodeType === 1 ? event.target : event.target && event.target.parentElement;
|
||||
if (!target) return;
|
||||
const box = target.closest(selectors.question);
|
||||
if (box && (active.context.root === box || active.context.root.contains(box))) pause('检测到手动作答,已暂停自动选择;可重新开启继续。');
|
||||
}
|
||||
|
||||
doc.addEventListener('click', manualClick, true);
|
||||
|
||||
function reset() {
|
||||
stop('自动选择状态已重置。');
|
||||
records = new Map();
|
||||
scope = null;
|
||||
total = 0;
|
||||
return notifyProgress();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
stop('自动选择已卸载。');
|
||||
destroyed = true;
|
||||
doc.removeEventListener('click', manualClick, true);
|
||||
records.clear();
|
||||
}
|
||||
|
||||
return { start: start, stop: stop, reset: reset, destroy: destroy, get running() { return Boolean(active && !active.cancelled); } };
|
||||
}
|
||||
|
||||
root.YianAnswerRunner = { version: '1.0.0', create: create };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
File diff suppressed because one or more lines are too long
+358
@@ -0,0 +1,358 @@
|
||||
/* Local question-bank import and storage. No network requests are made. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
const ROLES = Object.freeze(['主操', '班长', '副操']);
|
||||
const BANK_KEY = 'yianxue.assistant.bank.v2';
|
||||
const SETTING_PREFIX = 'yianxue.assistant.setting.v2.';
|
||||
const MAX_BYTES = 60 * 1024 * 1024;
|
||||
let current = null;
|
||||
let importQueue = Promise.resolve();
|
||||
|
||||
function normalize(value) {
|
||||
return String(value == null ? '' : value).normalize('NFKC')
|
||||
.replace(/[−﹣]/g, '-').replace(/\u00a0/g, ' ')
|
||||
.replace(/[\s\u200b\uFEFF]+/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
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 hash(value) {
|
||||
let result = 2166136261;
|
||||
for (let i = 0; i < value.length; i++) { result ^= value.charCodeAt(i); result = Math.imul(result, 16777619); }
|
||||
return (result >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function storage() {
|
||||
// Userscript grants can be sandbox globals without being properties of window.
|
||||
const legacyGet = typeof GM_getValue === 'function' ? GM_getValue : root.GM_getValue;
|
||||
const legacySet = typeof GM_setValue === 'function' ? GM_setValue : root.GM_setValue;
|
||||
const modern = typeof GM !== 'undefined' && GM ? GM : root.GM;
|
||||
if (typeof legacyGet === 'function' && typeof legacySet === 'function') {
|
||||
return { name: 'userscript', get: (key, fallback) => Promise.resolve(legacyGet(key, fallback)), set: (key, value) => Promise.resolve(legacySet(key, value)) };
|
||||
}
|
||||
if (modern && typeof modern.getValue === 'function' && typeof modern.setValue === 'function') {
|
||||
return { name: 'userscript', get: (key, fallback) => modern.getValue(key, fallback), set: (key, value) => modern.setValue(key, value) };
|
||||
}
|
||||
return {
|
||||
name: 'origin',
|
||||
get: async function (key, fallback) {
|
||||
try { const raw = root.localStorage.getItem(key); return raw === null ? fallback : JSON.parse(raw); }
|
||||
catch (error) { throw new Error('无法读取此网址的本地存储:' + error.message); }
|
||||
},
|
||||
set: async function (key, value) {
|
||||
try { root.localStorage.setItem(key, JSON.stringify(value)); }
|
||||
catch (error) { throw new Error('题库保存失败;请检查浏览器存储空间或油猴权限:' + error.message); }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(records, revision, storageName) {
|
||||
const counts = { 主操: 0, 班长: 0, 副操: 0 };
|
||||
records.forEach(record => { counts[record.role]++; });
|
||||
return { schemaVersion: 2, records, counts, total: records.length, revision: revision || 0, storage: storageName, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
// RFC 4180: quoted commas, escaped quotes, CRLF and embedded line breaks.
|
||||
function parseCSV(input) {
|
||||
const text = String(input).replace(/^\uFEFF/, '');
|
||||
const rows = [];
|
||||
let row = [], field = '', quoted = false, closed = false, line = 1;
|
||||
function pushField() { row.push(field); field = ''; closed = false; }
|
||||
function pushRow() { pushField(); if (row.some(value => value !== '')) rows.push(row); row = []; }
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (quoted) {
|
||||
if (ch === '"') {
|
||||
if (text[i + 1] === '"') { field += '"'; i++; }
|
||||
else { quoted = false; closed = true; }
|
||||
} else { field += ch; if (ch === '\n') line++; }
|
||||
} else if (ch === ',') { pushField(); }
|
||||
else if (ch === '\r' || ch === '\n') {
|
||||
if (ch === '\r' && text[i + 1] === '\n') i++;
|
||||
pushRow(); line++;
|
||||
} else if (closed) {
|
||||
if (ch !== ' ' && ch !== '\t') throw new Error('CSV 第 ' + line + ' 行的引号后存在非法字符');
|
||||
} else if (ch === '"') {
|
||||
if (field !== '') throw new Error('CSV 第 ' + line + ' 行有未转义的引号');
|
||||
quoted = true;
|
||||
} else { field += ch; }
|
||||
}
|
||||
if (quoted) throw new Error('CSV 引号未闭合(第 ' + line + ' 行附近)');
|
||||
if (field !== '' || row.length || closed) pushRow();
|
||||
return rows;
|
||||
}
|
||||
|
||||
function restoreCSVCell(value) {
|
||||
// toCSV prefixes one apostrophe only for a leading spreadsheet formula marker.
|
||||
// A real quote such as '引用, or two leading quotes, is retained.
|
||||
return String(value == null ? '' : value).replace(/^'(?=[\s\uFEFF]*[=+\-@])/, '');
|
||||
}
|
||||
|
||||
function roleFromPath(path) {
|
||||
return String(path || '').replace(/\\/g, '/').split('/').find(part => ROLES.includes(part)) || '';
|
||||
}
|
||||
|
||||
function courseFromFilename(name) {
|
||||
return String(name || '').replace(/\\/g, '/').split('/').pop().replace(/\.(csv|json)$/i, '').replace(/_题库_\d+题(?:_不完整)?$/, '');
|
||||
}
|
||||
|
||||
function boolValue(value) {
|
||||
const text = normalize(value);
|
||||
if (/^(正确|对|是|true|√|✓)$/.test(text)) return 'true';
|
||||
if (/^(错误|错|否|false|×|✗)$/.test(text)) return 'false';
|
||||
return '';
|
||||
}
|
||||
|
||||
function labelsForAnswer(answer, options, type) {
|
||||
const raw = String(answer == null ? '' : answer).trim().replace(/^(?:参考答案|正确答案|答案)\s*[::]\s*/, '');
|
||||
const compact = raw.toUpperCase().replace(/[\s,,、;;|/]+/g, '');
|
||||
if (/^[A-H]+$/.test(compact)) {
|
||||
if (new Set(compact).size !== compact.length) throw new Error('参考答案包含重复字母');
|
||||
return Array.from(compact);
|
||||
}
|
||||
const exact = options.filter(option => normalize(option.text) === normalize(raw));
|
||||
if (exact.length === 1) return [exact[0].label];
|
||||
if (type === '判断' && boolValue(raw)) {
|
||||
const found = options.filter(option => boolValue(option.text) === boolValue(raw));
|
||||
if (found.length === 1) return [found[0].label];
|
||||
}
|
||||
throw new Error('参考答案无法对应选项:' + (raw || '答案为空'));
|
||||
}
|
||||
|
||||
function validateRecord(raw, context) {
|
||||
context = context || {};
|
||||
if (!raw || typeof raw !== 'object') throw new Error('题目记录格式不正确');
|
||||
const role = String(raw.role || context.role || '').trim();
|
||||
if (!ROLES.includes(role)) throw new Error('题库未标明岗位;请先选择主操、班长或副操再导入');
|
||||
if (context.pathRole && raw.role && raw.role !== context.pathRole) throw new Error('题目岗位与 ZIP 目录岗位不一致');
|
||||
const type = normalizeType(raw.type);
|
||||
if (!['单选', '多选', '判断'].includes(type)) throw new Error('题型不支持或缺失:' + (type || '空'));
|
||||
const question = String(raw.question == null ? '' : raw.question).trim();
|
||||
if (!question) throw new Error('题干为空');
|
||||
if (!Array.isArray(raw.options) || raw.options.length < 2 || raw.options.length > 8) throw new Error('每道题需要 2 至 8 个有效选项');
|
||||
const options = raw.options.map((option, index) => {
|
||||
if (!option || typeof option !== 'object') throw new Error('选项格式不正确');
|
||||
const label = String(option.label || String.fromCharCode(65 + index)).trim().toUpperCase().replace(/[..、::))]$/, '');
|
||||
const text = String(option.text == null ? '' : option.text).trim();
|
||||
if (!/^[A-H]$/.test(label) || !text) throw new Error('选项字母或文字为空/无效');
|
||||
return { label, text, ...(Array.isArray(option.images) && option.images.length ? { images: option.images.map(String) } : {}) };
|
||||
});
|
||||
if (new Set(options.map(option => option.label)).size !== options.length) throw new Error('选项字母重复');
|
||||
let answer = String(raw.answer == null ? '' : raw.answer).trim();
|
||||
let labels;
|
||||
if (answer) labels = labelsForAnswer(answer, options, type);
|
||||
else if (Array.isArray(raw.correctTexts) && raw.correctTexts.length) {
|
||||
labels = raw.correctTexts.map(text => {
|
||||
const found = options.filter(option => normalize(option.text) === normalize(text));
|
||||
if (found.length !== 1) throw new Error('正确答案文字无法唯一对应选项');
|
||||
return found[0].label;
|
||||
});
|
||||
} else throw new Error('参考答案为空');
|
||||
if (labels.some(label => !options.some(option => option.label === label))) throw new Error('参考答案引用不存在的选项');
|
||||
if ((type === '单选' || type === '判断') && labels.length !== 1) throw new Error('单选/判断题必须只有一个参考答案');
|
||||
if (new Set(labels).size !== labels.length) throw new Error('参考答案重复');
|
||||
const correctOptions = options.filter(option => labels.includes(option.label));
|
||||
const correctTexts = correctOptions.map(option => option.text);
|
||||
if (Array.isArray(raw.correctTexts) && raw.correctTexts.length && JSON.stringify(raw.correctTexts.map(normalize).sort()) !== JSON.stringify(correctTexts.map(normalize).sort())) {
|
||||
throw new Error('正确答案文字与参考答案字母不一致');
|
||||
}
|
||||
answer = correctOptions.map(option => option.label).join('');
|
||||
const course = String(raw.course || context.course || '').trim();
|
||||
if (!course) throw new Error('题库缺少课程名称');
|
||||
const sourceUrl = String(raw.sourceUrl || context.sourceUrl || '').trim();
|
||||
const number = raw.number == null || raw.number === '' ? context.number || 0 : Number(raw.number);
|
||||
if (!Number.isInteger(number) || number < 0) throw new Error('题号格式不正确');
|
||||
const record = {
|
||||
role, course, sourceUrl, number, type, question, options, correctTexts, answer,
|
||||
answerText: correctOptions.map(option => option.label + '. ' + option.text).join(';'),
|
||||
explanation: String(raw.explanation == null ? '' : raw.explanation).trim(),
|
||||
sourceFile: String(raw.sourceFile || context.sourceFile || ''),
|
||||
...(Array.isArray(raw.questionImages) && raw.questionImages.length ? { questionImages: raw.questionImages.map(String) } : {})
|
||||
};
|
||||
record.questionKey = questionKey(record);
|
||||
record.optionKey = optionKey(options);
|
||||
record.id = 'qb-' + hash(JSON.stringify([role, course, sourceUrl, number, record.questionKey, record.optionKey, correctTexts.map(normalize).sort()]));
|
||||
return record;
|
||||
}
|
||||
|
||||
function readCSV(text, context) {
|
||||
const rows = parseCSV(text);
|
||||
if (rows.length < 2) throw new Error('CSV 没有题目记录');
|
||||
const headers = rows[0].map(value => value.replace(/^\uFEFF/, '').trim());
|
||||
if (new Set(headers).size !== headers.length) throw new Error('CSV 列名重复');
|
||||
const map = new Map(headers.map((name, index) => [name, index]));
|
||||
if (!map.has('题目') || !map.has('参考答案')) throw new Error('CSV 缺少“题目”或“参考答案”列');
|
||||
const read = (row, key) => map.has(key) ? restoreCSVCell(row[map.get(key)] || '') : '';
|
||||
const optionColumns = headers.map((name, index) => ({ name, index })).filter(item => /^选项[A-H]$/.test(item.name));
|
||||
return rows.slice(1).map((row, index) => {
|
||||
if (row.length !== headers.length) throw new Error('CSV 第 ' + (index + 2) + ' 条记录的列数与表头不一致');
|
||||
let options = optionColumns.map(item => ({ label: item.name.slice(-1), text: restoreCSVCell(row[item.index] || '') })).filter(option => option.text.trim());
|
||||
if (!options.length && map.has('全部选项')) {
|
||||
options = [];
|
||||
read(row, '全部选项').split(/\r?\n/).forEach(line => {
|
||||
const match = line.match(/^\s*([A-H])[..、::))]\s*(.*)$/);
|
||||
if (match) options.push({ label: match[1], text: match[2] });
|
||||
else if (options.length) options[options.length - 1].text += '\n' + line;
|
||||
});
|
||||
}
|
||||
try {
|
||||
return validateRecord({
|
||||
role: read(row, '岗位') || read(row, '角色') || context.role,
|
||||
course: read(row, '题库名称') || read(row, '课程') || context.course,
|
||||
sourceUrl: read(row, '来源网址') || context.sourceUrl,
|
||||
number: read(row, '题号') || index + 1,
|
||||
type: read(row, '题型'), question: read(row, '题目'), options,
|
||||
answer: read(row, '参考答案'), explanation: read(row, '题目解析')
|
||||
}, context);
|
||||
} catch (error) { throw new Error('第 ' + (index + 2) + ' 条记录:' + error.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function readJSON(text, context) {
|
||||
let data;
|
||||
try { data = JSON.parse(String(text).replace(/^\uFEFF/, '')); }
|
||||
catch (error) { throw new Error('JSON 格式错误:' + error.message); }
|
||||
let rows;
|
||||
if (Array.isArray(data)) rows = data;
|
||||
else if (data && Array.isArray(data.records)) rows = data.records;
|
||||
else if (data && Array.isArray(data.questions)) rows = data.questions;
|
||||
else throw new Error('JSON 应包含 questions 或 records 数组');
|
||||
if (!rows.length) throw new Error('JSON 没有题目记录');
|
||||
const base = { ...context, role: data.role || context.role, course: data.title || data.course || context.course, sourceUrl: data.sourceUrl || context.sourceUrl };
|
||||
return rows.map((raw, index) => {
|
||||
try { return validateRecord(raw, { ...base, number: index + 1 }); }
|
||||
catch (error) { throw new Error('第 ' + (index + 1) + ' 道题:' + error.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function isIgnored(name) {
|
||||
const parts = String(name).replace(/\\/g, '/').split('/');
|
||||
return parts.some(part => /^(?:__MACOSX|\.DS_Store|\.~lock\.|~\$|\._)/i.test(part));
|
||||
}
|
||||
|
||||
function sourceScope(record) {
|
||||
return JSON.stringify([record.role, record.course, record.sourceUrl]);
|
||||
}
|
||||
|
||||
function deduplicate(records) {
|
||||
const seen = new Set();
|
||||
return records.filter(record => {
|
||||
const key = JSON.stringify([sourceScope(record), record.number, record.questionKey, record.optionKey, record.correctTexts.map(normalize).sort()]);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key); return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (current) return current;
|
||||
const backend = storage();
|
||||
let saved = await backend.get(BANK_KEY, null);
|
||||
if (typeof saved === 'string') {
|
||||
try { saved = JSON.parse(saved); } catch (_) { throw new Error('已缓存题库格式损坏;请重新导入题库'); }
|
||||
}
|
||||
if (!saved) return (current = snapshot([], 0, backend.name));
|
||||
if (!Array.isArray(saved.records)) throw new Error('已缓存题库格式损坏;请重新导入题库');
|
||||
const records = saved.records.map((record, index) => {
|
||||
try { return validateRecord(record, { number: index + 1 }); }
|
||||
catch (error) { throw new Error('已缓存题库的第 ' + (index + 1) + ' 道题无效;请重新导入:' + error.message); }
|
||||
});
|
||||
current = snapshot(deduplicate(records), Number(saved.revision) || 0, backend.name);
|
||||
if (saved.updatedAt) current.updatedAt = saved.updatedAt;
|
||||
return current;
|
||||
}
|
||||
|
||||
async function fileBytes(file) {
|
||||
if (typeof file.arrayBuffer === 'function') return file.arrayBuffer();
|
||||
if (typeof root.FileReader === 'function') return new Promise((resolve, reject) => {
|
||||
const reader = new root.FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error('无法读取导入文件')); reader.readAsArrayBuffer(file);
|
||||
});
|
||||
throw new Error('浏览器不支持文件读取');
|
||||
}
|
||||
|
||||
function decodeCSV(bytes) {
|
||||
const decoder = root.TextDecoder || (typeof TextDecoder !== 'undefined' ? TextDecoder : null);
|
||||
if (!decoder) throw new Error('浏览器不支持 TextDecoder');
|
||||
try { return new decoder('utf-8', { fatal: true }).decode(bytes); }
|
||||
catch (_) { return new decoder('gb18030', { fatal: true }).decode(bytes); }
|
||||
}
|
||||
|
||||
async function performImport(files, options) {
|
||||
const selected = Array.from(files || []).filter(file => !isIgnored(file.webkitRelativePath || file.name));
|
||||
if (!selected.length) throw new Error('请选择 ZIP、CSV 或 JSON 题库文件');
|
||||
if (options.role && !ROLES.includes(options.role)) throw new Error('岗位只能选择主操、班长或副操');
|
||||
const zipFiles = selected.filter(file => /\.zip$/i.test(file.name || ''));
|
||||
if (zipFiles.length && (zipFiles.length !== 1 || selected.length !== 1)) throw new Error('ZIP 全量导入时请只选择一个 ZIP 文件');
|
||||
const incoming = [];
|
||||
let bytesRead = 0;
|
||||
function accept(text, name, pathRole) {
|
||||
bytesRead += text.length * 2;
|
||||
if (bytesRead > MAX_BYTES) throw new Error('题库解压后超过 60 MB,请拆分导入');
|
||||
const context = { role: pathRole || options.role || '', pathRole, course: courseFromFilename(name), sourceFile: name };
|
||||
try {
|
||||
incoming.push(...(/\.csv$/i.test(name) ? readCSV(text, context) : readJSON(text, context)));
|
||||
} catch (error) { throw new Error(name + ':' + error.message); }
|
||||
}
|
||||
if (zipFiles.length) {
|
||||
if (!root.JSZip || typeof root.JSZip.loadAsync !== 'function') throw new Error('ZIP 解析组件未加载,请重新安装完整脚本');
|
||||
const file = zipFiles[0];
|
||||
if (file.size > MAX_BYTES) throw new Error('ZIP 文件超过 60 MB');
|
||||
const zip = await root.JSZip.loadAsync(await fileBytes(file));
|
||||
const entries = Object.values(zip.files).filter(entry => !entry.dir && !isIgnored(entry.name) && /\.(csv|json)$/i.test(entry.name));
|
||||
if (!entries.length) throw new Error('ZIP 内没有 CSV 或 JSON 题库');
|
||||
if (entries.length > 2000) throw new Error('ZIP 内题库文件数量过多');
|
||||
let estimatedSize = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry._data && entry._data.uncompressedSize) estimatedSize += entry._data.uncompressedSize;
|
||||
if (estimatedSize > MAX_BYTES) throw new Error('题库解压后超过 60 MB,请拆分导入');
|
||||
const text = decodeCSV(await entry.async('uint8array'));
|
||||
accept(text, entry.name, roleFromPath(entry.name));
|
||||
}
|
||||
} else {
|
||||
for (const file of selected) {
|
||||
const name = file.webkitRelativePath || file.name || '';
|
||||
if (!/\.(csv|json)$/i.test(name)) throw new Error('仅支持 ZIP、CSV、JSON 文件:' + name);
|
||||
if (file.size > MAX_BYTES) throw new Error('文件超过 60 MB:' + name);
|
||||
accept(decodeCSV(await fileBytes(file)), name, roleFromPath(name));
|
||||
}
|
||||
}
|
||||
if (!incoming.length) throw new Error('题库没有有效题目');
|
||||
const backend = storage();
|
||||
let previous;
|
||||
try { previous = await load(); }
|
||||
catch (error) { if (!zipFiles.length) throw error; previous = snapshot([], 0, backend.name); }
|
||||
const scopes = new Set(incoming.map(sourceScope));
|
||||
const retained = zipFiles.length ? [] : previous.records.filter(record => !scopes.has(sourceScope(record)));
|
||||
const next = snapshot(deduplicate(retained.concat(incoming)), previous.revision + 1, backend.name);
|
||||
// Only publish after every record has passed validation and storage succeeded.
|
||||
await backend.set(BANK_KEY, next);
|
||||
current = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function importFiles(files, options) {
|
||||
const selection = Array.from(files || []);
|
||||
const settings = { ...(options || {}) };
|
||||
const job = importQueue.then(() => performImport(selection, settings));
|
||||
importQueue = job.catch(() => {});
|
||||
return job;
|
||||
}
|
||||
|
||||
async function getSetting(key, fallback) { return storage().get(SETTING_PREFIX + String(key), fallback); }
|
||||
async function setSetting(key, value) { await storage().set(SETTING_PREFIX + String(key), value); return value; }
|
||||
|
||||
const api = Object.freeze({ version: '2.0.0', roles: ROLES, load, importFiles, getSetting, setSetting, parseCSV, validateRecord, normalize, normalizeType, optionKey, questionKey });
|
||||
root.YianBankStore = api;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+18
-8
@@ -3,17 +3,27 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
||||
const metadata = `// ==UserScript==
|
||||
// @name 谊安学练习题库自动导出
|
||||
// @namespace yianxue-question-bank
|
||||
// @version 1.0.0
|
||||
// @description 进入练习后自动导出题干、选项、参考答案和解析;读取隐藏答案。
|
||||
// @match http://10.119.67.7/clientcn/*
|
||||
// @match https://10.119.67.7/clientcn/*
|
||||
// @version 2.0.0
|
||||
// @description 不绑定网址的题库助手:岗位题库搜索、允许复制、练习自动选择与导出。
|
||||
// @match *://*/*
|
||||
// @match file:///*
|
||||
// @run-at document-idle
|
||||
// @grant none
|
||||
// @grant GM_getValue
|
||||
// @grant GM_setValue
|
||||
// @grant GM_setClipboard
|
||||
// @noframes
|
||||
// ==/UserScript==
|
||||
`;
|
||||
const core = readFileSync(new URL('./question-bank-core.js', import.meta.url), 'utf8');
|
||||
const automation = readFileSync(new URL('./practice-exporter.js', import.meta.url), 'utf8');
|
||||
writeFileSync(new URL('./谊安学题库导出.user.js', import.meta.url), metadata + '\n' + core + '\n' + automation, 'utf8');
|
||||
const zip = readFileSync(new URL('./vendor/jszip.min.js', import.meta.url), 'utf8');
|
||||
const zipLicense = readFileSync(new URL('./vendor/LICENSE-JSZip.md', import.meta.url), 'utf8').split(/\r?\nGPL version 3/)[0];
|
||||
const pakoLicense = readFileSync(new URL('./vendor/LICENSE-pako.txt', import.meta.url), 'utf8');
|
||||
const notices = '/* Third-party notices: JSZip and pako distributed under their MIT licenses.\n'
|
||||
+ zipLicense + '\n' + pakoLicense + '\n*/\n';
|
||||
// 用局部 CommonJS 环境加载内置 ZIP 库,避免被网页的 AMD / require 接管。
|
||||
const zipWrapper = '(function () { const module = { exports: {} }; const exports = module.exports;\n'
|
||||
+ zip + '\nwindow.JSZip = module.exports;\n})();\n';
|
||||
const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js',
|
||||
'copy-controller.js', 'assistant-panel.js', 'answer-runner.js', 'practice-exporter.js'];
|
||||
const source = modules.map(name => readFileSync(new URL('./' + name, import.meta.url), 'utf8')).join('\n');
|
||||
writeFileSync(new URL('./谊安学题库导出.user.js', import.meta.url), metadata + '\n' + notices + zipWrapper + source, 'utf8');
|
||||
console.log('已生成 谊安学题库导出.user.js');
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/* 仅解除正文的选择 / 右键内联限制;保留并恢复网页自身的设置。 */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const EVENT_FIELDS = ['onselectstart', 'oncontextmenu'];
|
||||
|
||||
function inheritedDescriptor(node, name) {
|
||||
let prototype = Object.getPrototypeOf(node);
|
||||
while (prototype) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
|
||||
if (descriptor) return descriptor;
|
||||
prototype = Object.getPrototypeOf(prototype);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function create() {
|
||||
const doc = global.document;
|
||||
const records = new Map();
|
||||
let enabled = false;
|
||||
let destroyed = false;
|
||||
let style = null;
|
||||
let observer = null;
|
||||
|
||||
function readNative(record, field) {
|
||||
try {
|
||||
if (field.native && typeof field.native.get === 'function') return field.native.get.call(record.node);
|
||||
return null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
function writeNative(record, field, value) {
|
||||
try {
|
||||
if (field.native && typeof field.native.set === 'function') field.native.set.call(record.node, value);
|
||||
} catch (_) { /* 不覆盖不允许修改的网页字段。 */ }
|
||||
}
|
||||
function removeRestriction(record, field) {
|
||||
if (record.node.hasAttribute(field.name)) record.removeAttribute.call(record.node, field.name);
|
||||
writeNative(record, field, null);
|
||||
}
|
||||
function rememberAttribute(record, field) {
|
||||
field.hasAttribute = record.node.hasAttribute(field.name);
|
||||
field.attributeValue = record.node.getAttribute(field.name);
|
||||
field.intendedProperty = readNative(record, field);
|
||||
field.propertyAssigned = false;
|
||||
removeRestriction(record, field);
|
||||
}
|
||||
function installField(record, name) {
|
||||
const node = record.node;
|
||||
const own = Object.getOwnPropertyDescriptor(node, name);
|
||||
const native = inheritedDescriptor(node, name);
|
||||
const field = {
|
||||
name: name, own: own, native: native,
|
||||
hasAttribute: node.hasAttribute(name), attributeValue: node.getAttribute(name),
|
||||
intendedProperty: node[name], propertyAssigned: false, getter: null, setter: null
|
||||
};
|
||||
record.fields[name] = field;
|
||||
removeRestriction(record, field);
|
||||
// 实例级别拦截这两个属性的重新赋值,不改变事件的点击行为。
|
||||
if ((!own || own.configurable) && native && typeof native.set === 'function') {
|
||||
field.getter = function () { return null; };
|
||||
field.setter = function (value) {
|
||||
writeNative(record, field, value);
|
||||
field.intendedProperty = readNative(record, field);
|
||||
field.propertyAssigned = true;
|
||||
writeNative(record, field, null);
|
||||
};
|
||||
try { Object.defineProperty(node, name, { configurable: true, enumerable: own ? own.enumerable : true, get: field.getter, set: field.setter }); }
|
||||
catch (_) { field.getter = field.setter = null; }
|
||||
}
|
||||
}
|
||||
function installMethod(record, name) {
|
||||
const node = record.node;
|
||||
const own = Object.getOwnPropertyDescriptor(node, name);
|
||||
const original = node[name];
|
||||
const wrapper = function (attributeName) {
|
||||
const result = original.apply(this, arguments);
|
||||
if (enabled && this === node) {
|
||||
const field = record.fields[String(attributeName).toLowerCase()];
|
||||
if (field) rememberAttribute(record, field);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
if (!own || own.configurable) {
|
||||
try {
|
||||
Object.defineProperty(node, name, { configurable: true, writable: true, enumerable: own ? own.enumerable : false, value: wrapper });
|
||||
record.methods[name] = { own: own, wrapper: wrapper };
|
||||
} catch (_) { /* MutationObserver 仍会处理内联属性的重新设置。 */ }
|
||||
}
|
||||
}
|
||||
function watchBody() {
|
||||
if (!enabled || !doc.body || records.has(doc.body)) return;
|
||||
const node = doc.body;
|
||||
const record = { node: node, fields: {}, methods: {}, setAttribute: node.setAttribute, removeAttribute: node.removeAttribute };
|
||||
records.set(node, record);
|
||||
EVENT_FIELDS.forEach(function (name) { installField(record, name); });
|
||||
// 记录正文上对限制属性的最新修改意图。其他属性原样转交浏览器。
|
||||
installMethod(record, 'setAttribute');
|
||||
installMethod(record, 'removeAttribute');
|
||||
}
|
||||
function restoreRecord(record) {
|
||||
const node = record.node;
|
||||
Object.keys(record.methods).forEach(function (name) {
|
||||
const method = record.methods[name];
|
||||
if (node[name] !== method.wrapper) return;
|
||||
try {
|
||||
if (method.own) Object.defineProperty(node, name, method.own);
|
||||
else delete node[name];
|
||||
} catch (_) { /* 若网页已接管字段,保留网页的新设置。 */ }
|
||||
});
|
||||
EVENT_FIELDS.forEach(function (name) {
|
||||
const field = record.fields[name];
|
||||
const current = Object.getOwnPropertyDescriptor(node, name);
|
||||
const ownsProperty = !field.getter || current && current.get === field.getter && current.set === field.setter;
|
||||
if (field.getter && !ownsProperty) return;
|
||||
if (field.getter && ownsProperty) {
|
||||
try {
|
||||
if (field.own) Object.defineProperty(node, name, field.own);
|
||||
else delete node[name];
|
||||
} catch (_) { /* 保留不能恢复的字段。 */ }
|
||||
}
|
||||
// 当前仍有内联值时,说明网页刚改过它且观察回调尚未执行,保留该值。
|
||||
if (node.hasAttribute(name)) return;
|
||||
if (field.hasAttribute) record.setAttribute.call(node, name, field.attributeValue);
|
||||
else record.removeAttribute.call(node, name);
|
||||
if (ownsProperty) {
|
||||
// 属性和事件函数可被分别设置,恢复实际保存的最后一个处理器。
|
||||
writeNative(record, field, field.intendedProperty);
|
||||
// 先前实例上的自定义值不属于原生事件处理器,恢复其最新值。
|
||||
if (field.own && 'value' in field.own && field.propertyAssigned) {
|
||||
try { node[name] = field.intendedProperty; } catch (_) { /* 只读属性保持原样。 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function start() {
|
||||
style = doc.createElement('style');
|
||||
style.setAttribute('data-yian-copy-style', '');
|
||||
style.textContent = 'body,body *:not(input):not(button):not(select):not(option):not(textarea):not([data-yian-assistant]){-webkit-user-select:text!important;user-select:text!important;}';
|
||||
(doc.head || doc.documentElement).appendChild(style);
|
||||
watchBody();
|
||||
observer = new global.MutationObserver(function (mutations) {
|
||||
if (!enabled) return;
|
||||
watchBody();
|
||||
mutations.forEach(function (mutation) {
|
||||
if (mutation.type !== 'attributes') return;
|
||||
const record = records.get(mutation.target);
|
||||
if (!record) return;
|
||||
const field = record.fields[mutation.attributeName];
|
||||
// 自己移除的属性已是 null;有新的内联限制才记录并解除。
|
||||
if (field && record.node.hasAttribute(field.name)) rememberAttribute(record, field);
|
||||
});
|
||||
});
|
||||
observer.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: EVENT_FIELDS });
|
||||
}
|
||||
function stop() {
|
||||
if (observer) { observer.disconnect(); observer = null; }
|
||||
records.forEach(restoreRecord);
|
||||
records.clear();
|
||||
if (style) { style.remove(); style = null; }
|
||||
}
|
||||
function setEnabled(value) {
|
||||
if (destroyed || enabled === Boolean(value)) return;
|
||||
enabled = Boolean(value);
|
||||
if (enabled) start(); else stop();
|
||||
}
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
enabled = false;
|
||||
stop();
|
||||
destroyed = true;
|
||||
}
|
||||
return { setEnabled: setEnabled, destroy: destroy };
|
||||
}
|
||||
|
||||
global.YianCopyController = { create: create };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+4
-3
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "yianxue-question-bank-exporter",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "谊安学练习题库自动导出与保存网页离线解析",
|
||||
"description": "不绑定网址的谊安学题库助手:岗位搜索、允许复制和练习自动选择",
|
||||
"scripts": {
|
||||
"build": "node build-userscript.mjs",
|
||||
"pretest": "node build-userscript.mjs",
|
||||
"test": "node tests/verify.cjs"
|
||||
"test": "node tests/run.cjs",
|
||||
"test:assistant": "node tests/assistant.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "1.62.1"
|
||||
|
||||
+170
-141
@@ -1,173 +1,202 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// 源代码:构建后连同 question-bank-core.js 一起装入单个油猴脚本。
|
||||
if (window.YianQuestionBankExporter) return;
|
||||
const core = window.QuestionBankCore;
|
||||
if (!core) throw new Error('题库提取核心未加载');
|
||||
const config = { autoExport: true, format: 'csv', settleMs: 1800 };
|
||||
const completed = new Set();
|
||||
let state = null;
|
||||
let panel = null;
|
||||
let statusNode = null;
|
||||
let debounceTimer = null;
|
||||
let previousPractice = false;
|
||||
let previousRoot = null;
|
||||
let previousSignature = '';
|
||||
const store = window.YianBankStore;
|
||||
const matcher = window.YianAnswerMatcher;
|
||||
const config = { autoAnswer: false, allowCopy: true, autoExport: true, role: '', course: '' };
|
||||
let snapshot = { records: [], counts: {}, total: 0, revision: '', storage: 'origin' };
|
||||
let loading = true, disposed = false, page = null, previous = null;
|
||||
let searchQuery = '', runKey = '', debounceTimer;
|
||||
const exported = new Set();
|
||||
const settleMs = 1200;
|
||||
const copy = window.YianCopyController.create();
|
||||
const emptyMetrics = () => ({ selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: 0 });
|
||||
let metrics = emptyMetrics();
|
||||
|
||||
function routeInfo() {
|
||||
const url = new URL(location.href);
|
||||
const original = location.protocol === 'file:' ? core.savedSourceURL(document) : '';
|
||||
const url = new URL(original || location.href);
|
||||
let params = url.searchParams;
|
||||
let path = url.pathname;
|
||||
if (url.hash.startsWith('#/')) {
|
||||
const hashUrl = new URL(url.hash.slice(1), url.origin);
|
||||
params = hashUrl.searchParams;
|
||||
path = hashUrl.pathname;
|
||||
}
|
||||
if (url.hash.startsWith('#/')) params = new URL(url.hash.slice(1), url.origin).searchParams;
|
||||
const root = document.querySelector('app-examtest, .pc_examtest');
|
||||
const local = url.protocol === 'file:';
|
||||
const practice = !!root && (local
|
||||
? /[?&]flag=exercise(?:&|$)/.test(core.savedSourceURL(document))
|
||||
: params.get('flag') === 'exercise' && /\/exam\/[^/]+\/questions\/?$/.test(path));
|
||||
return { practice, root, title: params.get('title') || '', key: path + '|' + (params.get('title') || ''), local };
|
||||
const mode = (params.get('flag') || '').toLowerCase();
|
||||
const marker = root && Array.from(root.querySelectorAll('.pc_examtest_commit'))
|
||||
.some(node => node.textContent.trim() === '重新练习' && !node.classList.contains('hidden'));
|
||||
const identity = new URL(location.href);
|
||||
identity.searchParams.delete('t');
|
||||
return { root, practice: !!root && (mode ? mode === 'exercise' : !!marker),
|
||||
local: location.protocol === 'file:', sourceUrl: url.href, key: identity.href,
|
||||
expectedTitle: mode === 'exercise' ? params.get('title') || '' : '', mode };
|
||||
}
|
||||
|
||||
function fingerprint(bank) {
|
||||
function structureSignature(bank) {
|
||||
return JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map(q =>
|
||||
[q.number, q.type, q.question, q.options, q.answer, q.explanation, q.images])]);
|
||||
[q.number, q.type, q.question, q.options.map(option => [option.label, option.text, option.images])])]);
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
if (statusNode && statusNode.textContent !== message) statusNode.textContent = message;
|
||||
// 点击前现读上下文,阻断轮询尚未发现的路由、岗位、题目变化。
|
||||
function getContext() {
|
||||
const route = routeInfo();
|
||||
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
|
||||
const signature = route.root ? structureSignature(bank) : '';
|
||||
return { ready: !!page && page.ready && !loading && page.key === route.key
|
||||
&& page.root === route.root && page.signature === signature,
|
||||
practice: route.practice, local: route.local, role: config.role, course: bank.title,
|
||||
sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root,
|
||||
pageSignature: signature, revision: snapshot.revision, snapshot };
|
||||
}
|
||||
|
||||
function createPanel() {
|
||||
if (panel) return;
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'yian-question-bank-exporter';
|
||||
panel.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483600;max-width:min(330px,calc(100vw - 32px));padding:14px;border:1px solid #d5dde5;border-radius:12px;background:#fff;color:#233447;box-shadow:0 4px 20px #0002;font:13px/1.6 system-ui,sans-serif;text-align:left';
|
||||
const heading = document.createElement('strong');
|
||||
heading.textContent = '练习题库导出';
|
||||
statusNode = document.createElement('div');
|
||||
statusNode.setAttribute('role', 'status');
|
||||
statusNode.style.cssText = 'margin:6px 0;white-space:pre-line';
|
||||
const actions = document.createElement('div');
|
||||
actions.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap';
|
||||
for (const format of ['csv', 'json', 'txt']) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = '导出 ' + format.toUpperCase();
|
||||
button.style.cssText = 'cursor:pointer;border:1px solid #d5dde5;border-radius:6px;background:#f5f8fa;padding:5px 9px;color:#233447;font:inherit';
|
||||
button.addEventListener('click', () => {
|
||||
try { exportCurrent(format); }
|
||||
catch (error) { setStatus('导出失败:' + error.message); }
|
||||
const runner = window.YianAnswerRunner.create({ getContext,
|
||||
match: (question, context) => matcher.match(question, context, snapshot),
|
||||
onProgress: progress => { metrics = progress; panel.setState({ metrics }); },
|
||||
onState: (state, reason) => {
|
||||
if (disposed) return;
|
||||
if (state === 'paused') { config.autoAnswer = false; runKey = ''; panel.setState({ autoAnswer: false }); }
|
||||
if (reason && (state === 'paused' || state === 'complete')) panel.setNotice(reason, state === 'paused' ? 'warning' : 'success');
|
||||
}
|
||||
});
|
||||
actions.append(button);
|
||||
function courses() {
|
||||
return Array.from(new Set(snapshot.records.filter(record => record.role === config.role).map(record => record.course))).sort();
|
||||
}
|
||||
const label = document.createElement('label');
|
||||
label.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:8px;cursor:pointer';
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = config.autoExport;
|
||||
checkbox.addEventListener('change', () => {
|
||||
config.autoExport = checkbox.checked;
|
||||
tick();
|
||||
});
|
||||
label.append(checkbox, document.createTextNode('进入练习后自动导出 CSV'));
|
||||
panel.append(heading, statusNode, actions, label);
|
||||
document.body.append(panel);
|
||||
function refreshPanel(extra) {
|
||||
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
|
||||
autoExport: config.autoExport, role: config.role, course: config.course, courses: courses(),
|
||||
bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
|
||||
}
|
||||
|
||||
function exportCurrent(format = config.format) {
|
||||
if (!routeInfo().practice) throw new Error('请先进入练习页面');
|
||||
const bank = core.extract(document, location.href);
|
||||
if (!bank.total) throw new Error('页面还没有题目,请稍后重试');
|
||||
// 手动导出可保存不完整内容,文件及提示都会标记缺失。
|
||||
function performSearch(query = searchQuery, options = {}) {
|
||||
searchQuery = String(query || '');
|
||||
const offset = options.offset || 0;
|
||||
const result = searchQuery.trim() ? matcher.search(searchQuery,
|
||||
{ role: config.role, course: config.course, limit: 20, offset }, snapshot) : { items: [], total: 0 };
|
||||
panel.renderSearch(Object.assign({}, result, { offset }));
|
||||
if (!config.role && !options.quiet) panel.setNotice('请先选择主操、班长或副操。', 'warning');
|
||||
return result;
|
||||
}
|
||||
async function saveSetting(key, value) {
|
||||
try { await store.setSetting(key, value); }
|
||||
catch (error) { panel.setNotice('设置保存失败:' + error.message, 'error'); }
|
||||
}
|
||||
async function setRole(role) {
|
||||
if (!store.roles.includes(role)) throw new Error('请选择主操、班长或副操');
|
||||
runner.stop('岗位已切换'); config.role = role; config.course = ''; runKey = ''; metrics = emptyMetrics();
|
||||
refreshPanel(); performSearch(); tick(); await saveSetting('role', role); return role;
|
||||
}
|
||||
function setAutoAnswer(enabled) {
|
||||
const context = getContext();
|
||||
if (enabled && (!context.ready || !context.practice || context.local || !config.role || !snapshot.counts[config.role])) {
|
||||
panel.setNotice('请先导入所选岗位题库,并进入已加载完成的兼容练习页面。', 'warning'); refreshPanel(); return false;
|
||||
}
|
||||
config.autoAnswer = !!enabled; runKey = '';
|
||||
if (!enabled) runner.stop('自动选择已关闭,已生效的选择保留。');
|
||||
refreshPanel(); tick(); return config.autoAnswer;
|
||||
}
|
||||
function setCopy(enabled) {
|
||||
config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy); refreshPanel(); saveSetting('allowCopy', config.allowCopy);
|
||||
}
|
||||
async function importFiles(files) {
|
||||
runner.stop('正在更新题库'); loading = true; runKey = ''; refreshPanel(); panel.setNotice('正在解析题库,请稍候…');
|
||||
try {
|
||||
snapshot = await store.importFiles(Array.from(files || []), { role: config.role });
|
||||
metrics = emptyMetrics(); config.course = '';
|
||||
panel.setNotice('题库导入成功:' + snapshot.total + ' 条记录。请选择岗位后搜索或自动选择。', 'success');
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
panel.setNotice('导入失败:' + error.message + '。原有题库保留。', 'error'); throw error;
|
||||
} finally { loading = false; refreshPanel(); performSearch(searchQuery, { quiet: true }); tick(); }
|
||||
}
|
||||
function exportCurrent(format = 'csv') {
|
||||
const route = routeInfo();
|
||||
if (!route.practice) throw new Error('请先进入支持的练习页面');
|
||||
const bank = core.extract(document, route.sourceUrl);
|
||||
if (!bank.total) throw new Error('页面题目尚未加载');
|
||||
core.download(bank, format);
|
||||
setStatus((bank.complete ? '已发起下载:' : '已发起不完整题库下载:') + bank.title + '\n'
|
||||
+ bank.total + ' 题,' + bank.answered + ' 个答案'
|
||||
+ (bank.complete ? '。' : ',请检查缺失内容。')
|
||||
+ '\n若未出现文件,请点击上方导出按钮。');
|
||||
panel.setNotice((bank.complete ? '已发起下载:' : '已发起不完整题库下载:') + bank.title
|
||||
+ ',' + bank.total + ' 题、' + bank.answered + ' 个答案。若没有文件,请点击导出按钮。', bank.complete ? 'success' : 'warning');
|
||||
return bank;
|
||||
}
|
||||
const panel = window.YianAssistantPanel.create({
|
||||
onAutoChange: setAutoAnswer, onCopyChange: setCopy,
|
||||
onRoleChange: role => { setRole(role).catch(error => panel.setNotice(error.message, 'error')); },
|
||||
onCourseChange: course => { config.course = course; performSearch(); },
|
||||
onImport: files => { importFiles(files).catch(() => {}); }, onSearch: performSearch,
|
||||
onSelectionSearch: text => performSearch(text),
|
||||
onExport: format => { try { exportCurrent(format); } catch (error) { panel.setNotice(error.message, 'error'); } },
|
||||
onAutoExportChange: enabled => { config.autoExport = !!enabled; saveSetting('autoExport', config.autoExport); tick(); }
|
||||
});
|
||||
|
||||
function tick() {
|
||||
if (disposed) return;
|
||||
const route = routeInfo();
|
||||
if (!route.practice) {
|
||||
if (panel) panel.remove();
|
||||
panel = statusNode = null;
|
||||
state = null;
|
||||
previousPractice = false;
|
||||
previousRoot = route.root;
|
||||
return;
|
||||
if (!route.root) {
|
||||
if (runner.running) runner.stop('当前页面未识别到练习题目');
|
||||
if (page) previous = page;
|
||||
page = null;
|
||||
refreshPanel({ canAnswer: false, canExport: false, pageStatus: '当前网页可搜索题库和复制文字;未识别到支持的练习题目。' }); return;
|
||||
}
|
||||
createPanel();
|
||||
const bank = core.extract(document, location.href);
|
||||
const signature = fingerprint(bank);
|
||||
const now = Date.now();
|
||||
if (!state || state.key !== route.key) {
|
||||
// SPA 改 URL 后旧题目可能仍留在 DOM,等待题目或组件更新。
|
||||
const transitioning = !!previousRoot && !!(previousPractice || previousSignature)
|
||||
&& previousRoot === route.root && previousSignature === signature;
|
||||
state = { key: route.key, signature, since: now, initialRoot: route.root,
|
||||
initialSignature: signature, waitingForReplacement: transitioning };
|
||||
const bank = core.extract(document, route.sourceUrl), signature = structureSignature(bank), now = Date.now();
|
||||
if (!page || page.key !== route.key || page.root !== route.root) {
|
||||
if (runner.running) runner.stop('页面已切换,旧任务已停止');
|
||||
const last = page || previous;
|
||||
page = { key: route.key, root: route.root, signature, since: now, ready: false,
|
||||
waiting: !!last && last.key !== route.key && last.root === route.root && last.signature === signature };
|
||||
runKey = ''; metrics = emptyMetrics(); refreshPanel({ expanded: true });
|
||||
}
|
||||
if (state.waitingForReplacement && (route.root !== state.initialRoot || signature !== state.initialSignature)) {
|
||||
state.waitingForReplacement = false;
|
||||
state.since = now;
|
||||
if (page.signature !== signature) {
|
||||
if (runner.running) runner.stop('题目内容已更新');
|
||||
page.signature = signature; page.since = now; page.waiting = false; runKey = ''; metrics = emptyMetrics();
|
||||
}
|
||||
previousPractice = true;
|
||||
previousRoot = route.root;
|
||||
previousSignature = signature;
|
||||
if (state.signature !== signature) {
|
||||
state.signature = signature;
|
||||
state.since = now;
|
||||
}
|
||||
if (state.waitingForReplacement || (route.title && route.title.trim() !== bank.title.trim())) {
|
||||
setStatus('等待本次练习的题目加载…');
|
||||
return;
|
||||
}
|
||||
if (!bank.total || now - state.since < config.settleMs) {
|
||||
setStatus('正在读取题目与隐藏答案…');
|
||||
return;
|
||||
}
|
||||
if (bank.expectedTotal !== null && bank.expectedTotal !== bank.total) {
|
||||
setStatus('已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。\n若页面分页,请逐页导出;手动导出会标记不完整。');
|
||||
return;
|
||||
}
|
||||
if (!bank.complete) {
|
||||
setStatus('已读取 ' + bank.total + ' 题,缺少 ' + bank.missingAnswers.length + ' 个答案。\n自动导出等待答案加载;可手动保存现有内容。');
|
||||
return;
|
||||
}
|
||||
const exportKey = state.key + '|' + signature;
|
||||
if (completed.has(exportKey)) return;
|
||||
if (!config.autoExport) {
|
||||
setStatus(bank.title + '\n' + bank.total + ' 题,答案齐全,可点击导出。');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
exportCurrent(config.format);
|
||||
completed.add(exportKey);
|
||||
} catch (error) {
|
||||
config.autoExport = false;
|
||||
const checkbox = panel.querySelector('input');
|
||||
if (checkbox) checkbox.checked = false;
|
||||
setStatus('自动下载失败:' + error.message + '\n请点击导出按钮重试。');
|
||||
console.error('[谊安学题库导出]', error);
|
||||
const countComplete = bank.expectedTotal === null || bank.expectedTotal === bank.total;
|
||||
const titleReady = !route.expectedTitle || route.expectedTitle.trim() === bank.title.trim();
|
||||
page.ready = !!bank.total && countComplete && titleReady && !page.waiting && now - page.since >= settleMs;
|
||||
let status;
|
||||
if (!route.practice) status = '已识别题目,但未确认练习模式;自动选择和导出暂停。';
|
||||
else if (page.waiting || !titleReady) status = '等待本次练习的题目加载…';
|
||||
else if (!bank.total || now - page.since < settleMs) status = '正在读取题目与隐藏答案…';
|
||||
else if (!countComplete) status = '已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。';
|
||||
else status = bank.title + ':' + bank.total + ' 题,' + (bank.missingAnswers.length
|
||||
? '缺少 ' + bank.missingAnswers.length + ' 个网页答案;可用导入题库选择。' : '网页答案齐全。')
|
||||
+ (route.local ? ' 保存网页支持搜索与导出;自动选择需要在线练习页面。' : '');
|
||||
refreshPanel({ pageStatus: status, canExport: route.practice && !!bank.total,
|
||||
canAnswer: route.practice && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] });
|
||||
if (!route.practice || !page.ready || loading) { if (runner.running) runner.stop('等待兼容练习页面准备完成'); return; }
|
||||
if (config.autoExport && bank.complete) {
|
||||
const key = route.key + '|' + signature;
|
||||
if (!exported.has(key)) {
|
||||
try { exportCurrent('csv'); exported.add(key); }
|
||||
catch (error) { config.autoExport = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); }
|
||||
}
|
||||
}
|
||||
const nextRunKey = route.key + '|' + signature + '|' + config.role + '|' + snapshot.revision;
|
||||
if (config.autoAnswer && config.role && snapshot.counts[config.role] && runKey !== nextRunKey && !runner.running) {
|
||||
runKey = nextRunKey;
|
||||
runner.start().catch(error => { config.autoAnswer = false; refreshPanel(); panel.setNotice('自动选择失败:' + error.message, 'error'); });
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(records => {
|
||||
if (records.every(record => panel && (record.target === panel || panel.contains(record.target)))) return;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(tick, 300);
|
||||
if (records.every(record => record.target === panel.host || panel.host.contains(record.target))) return;
|
||||
clearTimeout(debounceTimer); debounceTimer = setTimeout(tick, 200);
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
const timer = setInterval(tick, 750); // 同时覆盖 Angular 路由和返回/前进。
|
||||
window.YianQuestionBankExporter = Object.freeze({
|
||||
exportCurrent,
|
||||
collectCurrent: () => core.extract(document, location.href),
|
||||
stop: () => { observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer); if (panel) panel.remove(); }
|
||||
});
|
||||
tick();
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
|
||||
const timer = setInterval(tick, 600);
|
||||
const onNavigation = () => { runner.stop('页面地址已变化'); runKey = ''; tick(); };
|
||||
window.addEventListener('popstate', onNavigation); window.addEventListener('hashchange', onNavigation);
|
||||
function dispose() {
|
||||
disposed = true; observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer);
|
||||
window.removeEventListener('popstate', onNavigation); window.removeEventListener('hashchange', onNavigation);
|
||||
runner.destroy(); copy.destroy(); panel.destroy();
|
||||
}
|
||||
const ready = (async () => {
|
||||
try {
|
||||
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('autoExport', true)]);
|
||||
snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : '';
|
||||
config.allowCopy = values[2] !== false; config.autoExport = values[3] !== false;
|
||||
} catch (error) { panel.setNotice('题库读取失败:' + error.message + '。可重新导入题库。', 'error'); }
|
||||
loading = false; copy.setEnabled(config.allowCopy); refreshPanel(); tick(); return snapshot;
|
||||
})();
|
||||
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, search: performSearch, getContext,
|
||||
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }),
|
||||
exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose });
|
||||
window.YianAssistant = api; window.YianQuestionBankExporter = api;
|
||||
refreshPanel(); tick();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/* 完整面板与跨网址存储验证;GM 桥接仅用于本机模拟,不请求业务服务器。 */
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
const { chromium } = require('playwright');
|
||||
const workspace = path.resolve(__dirname, '..');
|
||||
const script = fs.readFileSync(path.join(workspace, '谊安学题库导出.user.js'), 'utf8');
|
||||
assert(script.includes('// @match *://*/*'));
|
||||
assert(!/\/\/ @match\s+https?:\/\/10\./.test(script));
|
||||
const qaDir = process.env.YIAN_QA_DIR || path.join(os.tmpdir(), 'yianxue-assistant-qa');
|
||||
fs.mkdirSync(qaDir, { recursive: true });
|
||||
|
||||
const questions = [
|
||||
{ type: '单选', stem: '广西华谊新材料有限公司涉及几种高危工艺?', options: ['5种', '4种', '3种', '2种'] },
|
||||
{ type: '多选', stem: '以下哪些属于广西华谊新材料有限公司涉及的高危工艺?', options: ['聚合', '蒸馏', '氧化', '裂解'] },
|
||||
{ type: '判断', stem: '安全不仅是企业的底线,更是华谊对员工、对社会的庄严承诺。', options: ['错', '对'] }
|
||||
];
|
||||
async function bankInput() {
|
||||
const zipPath = path.join(workspace, '题库.zip');
|
||||
if (fs.existsSync(zipPath) && process.env.YIAN_TEST_SYNTHETIC_BANK !== '1') return { file: zipPath, total: 5520, counts: { 主操: 1620, 班长: 2340, 副操: 1560 }, real: true };
|
||||
const JSZip = require(path.join(workspace, 'vendor', 'jszip.min.js'));
|
||||
const zip = new JSZip();
|
||||
for (const role of ['主操', '班长', '副操']) {
|
||||
const records = questions.map((q, index) => ({ role, course: '安全领导力-1', number: index + 1,
|
||||
type: q.type, question: q.stem, options: [['2种', '3种', '4种', '5种'], ['裂解', '氧化', '聚合', '蒸馏'], ['对', '错']][index]
|
||||
.map((text, i) => ({ label: String.fromCharCode(65 + i), text })), answer: ['C', 'AB', 'A'][index] }));
|
||||
for (const [index, course] of ['风险管理', '重大危险源安全管理'].entries()) records.push({ role, course, number: 1,
|
||||
type: '多选', question: '常压罐组收料作业中,以下哪些属于工程控制措施?',
|
||||
options: ['装置甲', '装置乙', '装置丙', '防火堤'].map((text, i) => ({ label: String.fromCharCode(65 + i), text })), answer: index ? 'ABCD' : 'ABC' });
|
||||
for (let i = 0; i < 25; i++) records.push({ role, course: '作业课程', number: i + 1, type: '单选',
|
||||
question: '作业检索测试题' + i, options: [{ label: 'A', text: '正确项' }, { label: 'B', text: '错误项' }], answer: 'A' });
|
||||
zip.file(role + '/测试题库.json', JSON.stringify(records));
|
||||
}
|
||||
return { file: { name: '测试题库.zip', mimeType: 'application/zip', buffer: await zip.generateAsync({ type: 'nodebuffer' }) },
|
||||
total: 90, counts: { 主操: 30, 班长: 30, 副操: 30 }, real: false };
|
||||
}
|
||||
function practiceHTML() {
|
||||
return `<app-examtest><div class="pc_examtest_title">安全领导力-1</div>${questions.map((q, index) =>
|
||||
`<div app-exam-question class="pc_exam_list"><a id="examtest${index}"></a><div class="pc_examtest_content" data-kind="${q.type}" data-number="${index + 1}"><div class="pc_examtest_content_question"><p>[${q.type}] ${index + 1}. ${q.stem}<span class="pc_examtest_content_question_showanswer">查看答案</span></p></div><div class="pc_examtest_content_list">${q.options.map((text, i) => `<div class="pc_examtest_content_list_child1"><div class="pc_examtest_content_list_des${index === 1 && i === 0 ? ' examtest_content_list_des_checked' : ''}"><span class="examtest_content_list_des_optionsort">${String.fromCharCode(65 + i)}</span>${text}</div></div>`).join('')}</div><div class="hidden"><div class="examtest_content_answer_left">参考答案:<span class="answer"></span></div><div class="pc_examtest_content_resolve">题目解析:</div></div></div></div>`).join('')}<div class="pc_examtest_card">${questions.map((_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div><div class="pc_examtest_commit">重新练习</div><button id="submit">交卷</button></app-examtest>`;
|
||||
}
|
||||
function documentHTML(practice) {
|
||||
return `<!doctype html><meta charset="utf-8"><title>${practice ? '助手练习验证' : '普通网页验证'}</title><style>body{font:16px system-ui;margin:32px;background:#f4f7fa;user-select:none}.pc_examtest_content{padding:16px;background:white;border-bottom:1px solid #ddd;max-width:750px}.pc_examtest_content_list_des{padding:8px;cursor:pointer}.examtest_content_list_des_checked{background:#dff1e5}.hidden{display:none}.pc_examtest_title{font-size:24px;margin:18px 0}.pc_examtest_content_question_showanswer{margin-left:12px;color:#3268ac}</style><body onselectstart="return false" oncontextmenu="return false"><h1>${practice ? '练习页面' : '任意网址的题库搜索'}</h1><p id="copy-target">广西华谊新材料有限公司涉及几种高危工艺</p>${practice ? practiceHTML() : '<p>网页没有练习题目,仍可使用岗位题库搜索与复制开关。</p>'}<script>
|
||||
window.optionClicks=[];window.parseClicks=0;window.submitClicks=0;
|
||||
document.addEventListener('click',function(event){const n=event.target.closest('.pc_examtest_content_list_des');if(n){const box=n.closest('.pc_examtest_content');if(box.dataset.showanswer==='true')return;const label=n.querySelector('.examtest_content_list_des_optionsort').textContent;window.optionClicks.push(box.dataset.number+':'+label);if(box.dataset.kind==='多选')n.classList.toggle('examtest_content_list_des_checked');else{box.querySelectorAll('.pc_examtest_content_list_des').forEach(x=>x.classList.remove('examtest_content_list_des_checked'));n.classList.add('examtest_content_list_des_checked');box.dataset.showanswer='true';box.querySelector('.pc_examtest_content_question_showanswer').remove();box.querySelectorAll('.pc_examtest_content_list_child1').forEach(x=>x.className='pc_examtest_content_list_child2');}}if(event.target.closest('.pc_examtest_content_question_showanswer'))window.parseClicks++;if(event.target.id==='submit')window.submitClicks++;});
|
||||
</script></body>`;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const memory = new Map([['yianxue.assistant.setting.v2.autoExport', false]]);
|
||||
let clipboard = '';
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
|
||||
res.end(documentHTML(req.url.includes('exercise') || req.url.includes('flag=test')));
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, resolve));
|
||||
const port = server.address().port;
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1366, height: 900 }, acceptDownloads: true });
|
||||
await context.exposeBinding('__gmRead', (source, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
|
||||
await context.exposeBinding('__gmWrite', (source, key, value) => { memory.set(key, structuredClone(value)); });
|
||||
await context.exposeBinding('__gmCopy', (source, text) => { clipboard = text; });
|
||||
// API 仅作为沙箱全局变量存在,刻意不放到 window 上。
|
||||
const sandboxScript = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);'
|
||||
+ 'const GM_setValue=(key,value)=>window.__gmWrite(key,value);const GM_setClipboard=text=>window.__gmCopy(text);\n'
|
||||
+ script + '\n})();';
|
||||
const errors = [];
|
||||
function capture(page) { page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); }); }
|
||||
const page = await context.newPage(); capture(page);
|
||||
const startURL = `http://127.0.0.1:${port}/completely-unrelated-path`;
|
||||
await page.goto(startURL); await page.addScriptTag({ content: sandboxScript });
|
||||
await page.evaluate(() => YianAssistant.ready);
|
||||
assert.equal(page.url(), startURL); assert.equal(await page.title(), '普通网页验证');
|
||||
assert(await page.getByRole('heading', { name: '任意网址的题库搜索' }).isVisible());
|
||||
await page.getByRole('button', { name: '展开题库助手' }).click();
|
||||
assert(await page.getByRole('switch', { name: '自动选择答案' }).isDisabled());
|
||||
assert.equal(await page.evaluate(() => typeof window.GM_getValue), 'undefined');
|
||||
console.log('PASS 任意路径显示搜索面板,非练习页禁止自动选择,沙箱API兼容');
|
||||
|
||||
const input = await bankInput();
|
||||
await page.locator('#yian-question-assistant input[type=file]').setInputFiles(input.file);
|
||||
await page.waitForFunction(total => YianAssistant.getSnapshot().total === total, input.total);
|
||||
assert.deepEqual(await page.evaluate(() => YianAssistant.getSnapshot().counts), input.counts);
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().storage), 'userscript');
|
||||
await page.getByRole('radio', { name: '主操', exact: true }).check();
|
||||
await page.getByRole('textbox', { name: '搜索题目' }).fill('工程控制措施');
|
||||
await page.getByRole('button', { name: '搜索', exact: true }).click();
|
||||
assert(await page.locator('#yian-question-assistant .result .conflict').count() > 0);
|
||||
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('主操'));
|
||||
console.log('PASS ' + (input.real ? '实际ZIP导入5520条' : '合成ZIP导入90条') + '、岗位数量与冲突来源显示');
|
||||
|
||||
// 未点击搜索按钮的新输入,也应在换岗位时立即检索。
|
||||
const query = '广西华谊新材料有限公司涉及几种高危工艺';
|
||||
await page.getByRole('textbox', { name: '搜索题目' }).fill(query);
|
||||
await page.getByRole('radio', { name: '副操', exact: true }).check();
|
||||
assert.equal(await page.getByRole('textbox', { name: '搜索题目' }).inputValue(), query);
|
||||
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('副操'));
|
||||
assert(await page.locator('#yian-question-assistant .result').count() > 0);
|
||||
assert((await page.locator('#yian-question-assistant .sources').first().innerText()).includes('副操'));
|
||||
await page.getByRole('button', { name: '复制题目与答案' }).first().click();
|
||||
assert(clipboard.includes('正确答案:')); assert(clipboard.includes('高危工艺'));
|
||||
await page.getByRole('radio', { name: '班长', exact: true }).check();
|
||||
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('班长'));
|
||||
await page.getByRole('radio', { name: '主操', exact: true }).check();
|
||||
await page.getByRole('textbox', { name: '搜索题目' }).fill('作业');
|
||||
await page.getByRole('button', { name: '搜索', exact: true }).click();
|
||||
assert.equal(await page.locator('#yian-question-assistant .result').count(), 20);
|
||||
await page.getByRole('button', { name: '下一页', exact: true }).click();
|
||||
assert((await page.locator('#yian-question-assistant .pagination').innerText()).includes('21–'));
|
||||
assert(await page.locator('#yian-question-assistant .result').count() > 0);
|
||||
console.log('PASS 三岗位隔离、换岗位保留当前关键词、结果复制与20条分页');
|
||||
|
||||
const restrictions = () => page.evaluate(() => ({ select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), blocked: !document.body.dispatchEvent(new Event('selectstart', { bubbles: true, cancelable: true })), style: getComputedStyle(document.querySelector('#copy-target')).userSelect }));
|
||||
assert.deepEqual(await restrictions(), { select: null, context: null, blocked: false, style: 'text' });
|
||||
await page.getByRole('switch', { name: '允许复制文字' }).uncheck();
|
||||
assert.deepEqual(await restrictions(), { select: 'return false', context: 'return false', blocked: true, style: 'none' });
|
||||
await page.getByRole('switch', { name: '允许复制文字' }).check();
|
||||
await page.evaluate(() => { const range = document.createRange(); range.selectNodeContents(document.getElementById('copy-target')); const selection = getSelection(); selection.removeAllRanges(); selection.addRange(range); });
|
||||
await page.getByRole('button', { name: '用选中文字搜索', exact: true }).click();
|
||||
assert.equal(await page.getByRole('textbox', { name: '搜索题目' }).inputValue(), query);
|
||||
await page.locator('#yian-question-assistant .body').evaluate(node => { node.scrollTop = 0; });
|
||||
await page.screenshot({ path: path.join(qaDir, 'search-desktop.png') });
|
||||
console.log('PASS 允许复制开启/关闭恢复原限制,正文选中文字搜索');
|
||||
|
||||
await page.locator('#yian-question-assistant input[type=file]').setInputFiles({ name: 'invalid.csv', mimeType: 'text/csv', buffer: Buffer.from('题目,参考答案\n坏数据,A') });
|
||||
await page.waitForFunction(() => document.querySelector('#yian-question-assistant').shadowRoot.querySelector('.notice').textContent.includes('导入失败'));
|
||||
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().total), input.total);
|
||||
console.log('PASS 无效导入提示与原题库保留');
|
||||
|
||||
const second = await context.newPage(); capture(second);
|
||||
const secondURL = `http://localhost:${port}/different/application/route?flag=exercise`;
|
||||
await second.goto(secondURL); await second.addScriptTag({ content: sandboxScript }); await second.evaluate(() => YianAssistant.ready);
|
||||
await second.waitForFunction(() => YianAssistant.getState().pageReady);
|
||||
assert.equal(await second.evaluate(() => YianAssistant.getSnapshot().total), input.total);
|
||||
assert.equal(await second.evaluate(() => YianAssistant.getState().role), '主操');
|
||||
assert.equal(await second.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
|
||||
await second.getByRole('switch', { name: '自动选择答案' }).check();
|
||||
await second.waitForFunction(() => YianAssistant.getState().metrics.selected === 3);
|
||||
assert.deepEqual(await second.evaluate(() => window.optionClicks), ['1:B', '2:A', '2:C', '2:D', '3:B']);
|
||||
assert.equal(await second.evaluate(() => YianAssistant.getState().metrics.pending), 1);
|
||||
assert.equal(await second.evaluate(() => window.parseClicks + window.submitClicks), 0);
|
||||
await second.waitForTimeout(1500); assert.equal(await second.evaluate(() => window.optionClicks.length), 5);
|
||||
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
|
||||
await second.getByRole('switch', { name: '自动选择答案' }).check();
|
||||
await second.waitForTimeout(500); assert.equal(await second.evaluate(() => window.optionClicks.length), 5);
|
||||
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
|
||||
console.log('PASS 更换域名/路径后题库岗位共用,实际选项点击、换序、多选差集与无重复');
|
||||
|
||||
await second.evaluate(html => { history.pushState({}, '', '/another/random-route?flag=exercise'); const doc = new DOMParser().parseFromString(html, 'text/html'); document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest')); window.optionClicks = []; }, practiceHTML());
|
||||
await second.waitForFunction(() => YianAssistant.getContext().ready);
|
||||
await second.getByRole('switch', { name: '自动选择答案' }).check();
|
||||
await second.waitForFunction(() => window.optionClicks.length > 0);
|
||||
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
|
||||
const stoppedAt = await second.evaluate(() => window.optionClicks.length);
|
||||
await second.waitForTimeout(700); assert.equal(await second.evaluate(() => window.optionClicks.length), stoppedAt);
|
||||
await second.getByRole('switch', { name: '自动选择答案' }).check();
|
||||
await second.waitForFunction(() => { const m = YianAssistant.getState().metrics; return m.selected + m.locked === 3; });
|
||||
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
|
||||
const modeURL = second.url().replace('flag=exercise', 'flag=test');
|
||||
await second.evaluate(url => history.pushState({}, '', url), modeURL);
|
||||
await second.waitForTimeout(800); assert(await second.getByRole('switch', { name: '自动选择答案' }).isDisabled());
|
||||
assert.equal(await second.evaluate(() => YianAssistant.setAutoAnswer(true)), false);
|
||||
console.log('PASS 面板关闭立即停止后续点击、重新开启续作、明确考试模式禁用');
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.getByRole('button', { name: '复制题目与答案' }).first().click();
|
||||
await page.locator('#yian-question-assistant .body').evaluate(node => { node.scrollTop = 0; });
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
await page.screenshot({ path: path.join(qaDir, 'search-mobile.png') });
|
||||
await page.setViewportSize({ width: 1366, height: 900 });
|
||||
await page.locator('#yian-question-assistant .body').evaluate(node => { node.scrollTop = 0; });
|
||||
await page.locator('#yian-question-assistant').screenshot({ path: path.join(qaDir, 'assistant-panel.png') });
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS 桌面/手机面板、页面身份与内容、无错误覆盖层/控制台错误');
|
||||
console.log('截图:' + qaDir);
|
||||
} finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,179 @@
|
||||
/* Pure storage/matching checks, including the user-supplied ZIP. */
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const bankSource = fs.readFileSync(path.join(rootDir, 'bank-store.js'), 'utf8');
|
||||
const matcherSource = fs.readFileSync(path.join(rootDir, 'answer-matcher.js'), 'utf8');
|
||||
const JSZip = require(path.join(rootDir, 'vendor', 'jszip.min.js'));
|
||||
|
||||
function runtime(kind = 'legacy', memory = new Map()) {
|
||||
const environment = { TextDecoder, URL, URLSearchParams, JSZip, console };
|
||||
if (kind === 'legacy') {
|
||||
environment.GM_getValue = (key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback;
|
||||
environment.GM_setValue = (key, value) => { memory.set(key, structuredClone(value)); };
|
||||
} else if (kind === 'modern') {
|
||||
environment.GM = {
|
||||
getValue: async (key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback,
|
||||
setValue: async (key, value) => { memory.set(key, structuredClone(value)); }
|
||||
};
|
||||
} else {
|
||||
environment.localStorage = { getItem: key => memory.get(key) ?? null, setItem: (key, value) => memory.set(key, value) };
|
||||
}
|
||||
vm.createContext(environment);
|
||||
vm.runInContext(bankSource, environment);
|
||||
vm.runInContext(matcherSource, environment);
|
||||
return { store: environment.YianBankStore, matcher: environment.YianAnswerMatcher, environment, memory };
|
||||
}
|
||||
|
||||
function file(name, text) {
|
||||
const bytes = Buffer.isBuffer(text) ? text : Buffer.from(text);
|
||||
return { name, size: bytes.length, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) };
|
||||
}
|
||||
|
||||
function record(overrides = {}) {
|
||||
return {
|
||||
role: '主操', course: '风险管理', sourceUrl: 'http://old-host/clientcn/exam/risk/questions?title=x&flag=exercise', number: 1,
|
||||
type: '单选', question: '温度低于 -50.5 ℃ 时,不应进行的作业?',
|
||||
options: [{ label: 'A', text: '作业甲' }, { label: 'B', text: '作业乙' }, { label: 'C', text: '作业丙' }],
|
||||
answer: 'B', explanation: '', ...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function csv(rows) {
|
||||
const headers = ['题号', '题型', '题目', '选项A', '选项B', '选项C', '参考答案', '题目解析', '来源网址', '题库名称'];
|
||||
const cell = value => '"' + String(value ?? '').replace(/"/g, '""') + '"';
|
||||
return '\uFEFF' + [headers, ...rows.map(row => [row.number, row.type, row.question, ...['A', 'B', 'C'].map(label => row.options.find(option => option.label === label)?.text || ''), row.answer, row.explanation, row.sourceUrl, row.course])].map(row => row.map(cell).join(',')).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { store, matcher, memory } = runtime();
|
||||
assert.equal((await store.load()).storage, 'userscript');
|
||||
assert.equal((await store.load()).total, 0);
|
||||
assert.deepEqual(Array.from(store.roles), ['主操', '班长', '副操']);
|
||||
const parsed = store.parseCSV('\uFEFF"题目","选项A"\r\n"逗号,引号""和\n换行","甲"\r\n');
|
||||
assert.equal(parsed[1][0], '逗号,引号"和\n换行');
|
||||
assert.throws(() => store.parseCSV('a,b\n"未闭合'), /未闭合/);
|
||||
assert.throws(() => store.parseCSV('a,b\n"关闭"坏,b'), /非法字符/);
|
||||
assert.throws(() => store.parseCSV('a,b\n未"转义,b'), /未转义/);
|
||||
assert.notEqual(matcher.normalize('不应在 -50.5℃ 作业'), matcher.normalize('应在 50.5℃ 作业'));
|
||||
assert.notEqual(matcher.normalize('50.5'), matcher.normalize('505'));
|
||||
assert.equal(matcher.normalize('−50.5 ℃'), '-50.5 °c');
|
||||
|
||||
const escaped = record({ options: [{ label: 'A', text: "'-50" }, { label: 'B', text: "'引用" }, { label: 'C', text: "''-50" }] });
|
||||
let state = await store.importFiles([file('风险管理_题库_1题.csv', csv([escaped]))], { role: '主操' });
|
||||
assert.equal(state.records[0].options[0].text, '-50');
|
||||
assert.equal(state.records[0].options[1].text, "'引用");
|
||||
assert.equal(state.records[0].options[2].text, "''-50");
|
||||
assert.equal(state.records[0].correctTexts[0], "'引用");
|
||||
|
||||
state = await store.importFiles([file('风险管理.json', JSON.stringify({ title: '风险管理', sourceUrl: record().sourceUrl, questions: [{ ...record(), role: undefined }] }))], { role: '主操' });
|
||||
assert.equal(state.total, 1, '相同岗位/课程/来源的导入应替换旧数据');
|
||||
assert.equal(state.records[0].questionKey, matcher.questionKey(state.records[0]));
|
||||
assert.equal(state.records[0].optionKey, matcher.optionKey(state.records[0].options));
|
||||
state = await store.importFiles([file('班长.csv', csv([record({ course: '班长课程', sourceUrl: 'http://host/exam/leader/questions' })]))], { role: '班长' });
|
||||
assert.equal(state.total, 2, '导入一个岗位不丢失其他岗位');
|
||||
assert.equal(state.counts.主操, 1); assert.equal(state.counts.班长, 1);
|
||||
const revision = state.revision;
|
||||
await assert.rejects(store.importFiles([file('坏.csv', csv([record({ answer: 'H' })]))], { role: '主操' }), /不存在的选项/);
|
||||
assert.equal((await store.load()).revision, revision, '无效导入应保持原题库不变');
|
||||
await assert.rejects(store.importFiles([file('没有岗位.csv', csv([record()]))]), /选择主操/);
|
||||
await assert.rejects(store.importFiles([file('坏.json', JSON.stringify([record({ correctTexts: ['作业甲'] })]))]), /不一致/);
|
||||
await assert.rejects(store.importFiles([file('空.json', '{"records":[]}')]), /没有题目/);
|
||||
await store.setSetting('role', '班长');
|
||||
assert.equal(await store.getSetting('role', ''), '班长');
|
||||
assert.equal(await store.getSetting('missing', false), false);
|
||||
const reloaded = runtime('modern', memory);
|
||||
assert.equal((await reloaded.store.load()).total, 2);
|
||||
assert.equal(await reloaded.store.getSetting('role'), '班长');
|
||||
const origin = runtime('origin');
|
||||
assert.equal((await origin.store.load()).storage, 'origin');
|
||||
assert.equal((await origin.store.importFiles([file('风险管理.csv', csv([record()]))], { role: '主操' })).storage, 'origin');
|
||||
|
||||
const q = record({ options: [{ label: 'A', text: '作业乙' }, { label: 'B', text: '作业丙' }, { label: 'C', text: '作业甲' }] });
|
||||
let answer = matcher.match(q, { role: '主操', course: '风险管理' }, state);
|
||||
assert.equal(answer.status, 'matched'); assert.deepEqual(Array.from(answer.correctLabels), ['A']);
|
||||
assert.equal(matcher.match({ ...q, question: q.question.replace('不应', '应') }, { role: '主操' }, state).status, 'missing');
|
||||
assert.equal(matcher.match({ ...q, options: q.options.slice(0, 2) }, { role: '主操' }, state).status, 'missing');
|
||||
assert.equal(matcher.match({ ...q, type: '多选' }, { role: '主操' }, state).status, 'missing');
|
||||
assert.equal(matcher.match(q, {}, state).status, 'unsupported');
|
||||
assert.equal(matcher.match({ ...q, questionImages: ['image.png'] }, { role: '主操' }, state).status, 'unsupported');
|
||||
assert.equal(matcher.match({ ...q, options: [q.options[0], { label: 'B', text: q.options[0].text }] }, { role: '主操' }, state).status, 'unsupported');
|
||||
assert.deepEqual(Array.from(matcher.match({ ...q, options: q.options.map(option => ({ ...option, label: option.label + '.' })) }, { role: '主操' }, state).correctLabels), ['A']);
|
||||
assert.equal(matcher.sourceIdentity('http://one:8080/clientcn/exam/risk/questions?flag=exercise'), matcher.sourceIdentity('https://two/#/clientcn/exam/risk/questions?title=风险管理&flag=exercise'));
|
||||
|
||||
const conflict = store.validateRecord(record({ course: '重大危险源', sourceUrl: 'http://other/exam/danger/questions', answer: 'A' }));
|
||||
const sameAnswer = store.validateRecord(record({ number: 2, course: '课程二', sourceUrl: 'http://other/exam/another/questions' }));
|
||||
const groupedState = { records: [state.records[0], conflict, sameAnswer, state.records[1]], revision: 99 };
|
||||
assert.equal(matcher.match(q, { role: '主操' }, groupedState).status, 'conflict');
|
||||
assert.equal(matcher.match(q, { role: '主操', course: '风险管理' }, groupedState).status, 'matched');
|
||||
answer = matcher.match(q, { role: '主操', sourceUrl: 'http://new-ip:1234/#/clientcn/exam/risk/questions?flag=exercise' }, groupedState);
|
||||
assert.equal(answer.status, 'matched'); assert.equal(answer.records.length, 1);
|
||||
answer = matcher.match(q, { role: '主操', course: '重大危险源' }, groupedState);
|
||||
assert.equal(answer.status, 'matched'); assert.deepEqual(Array.from(answer.correctLabels), ['C']);
|
||||
assert.equal(matcher.search('温度', {}, groupedState).total, 0);
|
||||
assert.equal(matcher.search('', { role: '主操' }, groupedState).total, 0);
|
||||
let found = matcher.search('温度 -50.5 作业乙', { role: '主操' }, groupedState);
|
||||
assert.equal(found.total, 2, '同题同选项同答案合并,冲突答案分别展示');
|
||||
assert(found.items.every(item => item.conflict));
|
||||
assert(found.items.some(item => item.sources.length === 2));
|
||||
assert.equal(matcher.search('温度', { role: '班长' }, groupedState).total, 1);
|
||||
assert.equal(matcher.search('温度', { role: '主操', course: '风险管理' }, groupedState).items[0].conflict, false);
|
||||
assert.equal(matcher.search('温度不存在', { role: '主操' }, groupedState).total, 0);
|
||||
found = matcher.search('温度', { role: '主操', limit: 1, offset: 0 }, groupedState);
|
||||
assert.equal(found.items.length, 1); assert.equal(found.hasMore, true);
|
||||
assert.equal(matcher.search('温度', { role: '主操', limit: 1, offset: 1 }, groupedState).hasMore, false);
|
||||
|
||||
const multiple = store.validateRecord(record({ type: '多选', answer: 'AC' }));
|
||||
const multipleResult = matcher.match({ ...q, type: '多选' }, { role: '主操' }, { records: [multiple], revision: 1 });
|
||||
assert.equal(multipleResult.status, 'matched');
|
||||
assert.deepEqual(Array.from(multipleResult.correctLabels), ['B', 'C']);
|
||||
const quotaRuntime = runtime();
|
||||
await quotaRuntime.store.importFiles([file('风险管理.csv', csv([record()]))], { role: '主操' });
|
||||
quotaRuntime.environment.GM_setValue = () => { throw new Error('quota exceeded'); };
|
||||
await assert.rejects(quotaRuntime.store.importFiles([file('班长.csv', csv([record({ course: '班长课程' })]))], { role: '班长' }), /quota/);
|
||||
assert.equal((await quotaRuntime.store.load()).total, 1, '保存失败应保持可用的旧题库');
|
||||
|
||||
// Calls are serialized so independently imported roles cannot overwrite each other.
|
||||
const parallelRuntime = runtime();
|
||||
await Promise.all([
|
||||
parallelRuntime.store.importFiles([file('主操.csv', csv([record()]))], { role: '主操' }),
|
||||
parallelRuntime.store.importFiles([file('班长.csv', csv([record()]))], { role: '班长' })
|
||||
]);
|
||||
assert.equal((await parallelRuntime.store.load()).total, 2);
|
||||
|
||||
// ZIP all-or-nothing replacement; lock files and unrelated metadata are ignored.
|
||||
const zip = new JSZip();
|
||||
zip.file('题库/副操/风险管理.csv', csv([record({ sourceUrl: 'http://host/exam/a/questions' })]));
|
||||
zip.file('题库/副操/~$风险管理.csv', 'broken');
|
||||
zip.file('__MACOSX/._file.csv', 'broken');
|
||||
state = await store.importFiles([file('替换.zip', await zip.generateAsync({ type: 'nodebuffer' }))]);
|
||||
assert.equal(state.total, 1); assert.equal(state.counts.副操, 1); assert.equal(state.counts.主操, 0);
|
||||
const invalidZip = new JSZip();
|
||||
invalidZip.file('题库/主操/正常.csv', csv([record()]));
|
||||
invalidZip.file('题库/班长/损坏.csv', csv([record({ answer: 'H' })]));
|
||||
await assert.rejects(store.importFiles([file('损坏.zip', await invalidZip.generateAsync({ type: 'nodebuffer' }))]), /不存在的选项/);
|
||||
assert.equal((await store.load()).total, 1);
|
||||
await assert.rejects(store.importFiles([file('题库.zip', await zip.generateAsync({ type: 'nodebuffer' })), file('一份.csv', csv([record()]))]), /只选择一个/);
|
||||
console.log('PASS 本地存储、原子导入、CSV引号/换行/转义、岗位隔离、精确换序匹配、答案冲突、来源消歧与搜索分页');
|
||||
|
||||
const realZip = path.join(rootDir, '题库.zip');
|
||||
if (fs.existsSync(realZip)) {
|
||||
const started = performance.now();
|
||||
const imported = await store.importFiles([file('题库.zip', fs.readFileSync(realZip))]);
|
||||
assert.equal(imported.total, 5520);
|
||||
assert.equal(imported.counts.主操 + imported.counts.班长 + imported.counts.副操, 5520);
|
||||
assert(imported.records.every(row => row.correctTexts.length > 0));
|
||||
const courseConflicts = matcher.search('常压罐组收料作业中', { role: '主操', limit: 100 }, imported);
|
||||
assert(courseConflicts.items.some(item => item.conflict), '真实跨课程冲突应显式显示');
|
||||
const noSource = matcher.match(courseConflicts.items.find(item => item.conflict), { role: '主操' }, imported);
|
||||
assert.equal(noSource.status, 'conflict');
|
||||
const exact = imported.records[0];
|
||||
const matched = matcher.match(exact, { role: exact.role, course: exact.course, sourceUrl: exact.sourceUrl }, imported);
|
||||
assert.equal(matched.status, 'matched');
|
||||
assert.equal(new Set(imported.records.map(item => item.sourceFile)).size, 28);
|
||||
console.log('PASS 实际题库 ZIP:28 CSV,5520 题,岗位数量 ' + JSON.stringify(imported.counts) + ';导入耗时 ' + Math.round(performance.now() - started) + ' ms');
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,6 @@
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs']) {
|
||||
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
|
||||
if (result.status !== 0) process.exit(result.status || 1);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/* 自动选择真实浏览器回归:本机 HTTP fixture,绝不连接原网站或修改真实练习。 */
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const http = require('node:http');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const workspace = path.resolve(__dirname, '..');
|
||||
const coreSource = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
|
||||
const runnerSource = fs.readFileSync(path.join(workspace, 'answer-runner.js'), 'utf8');
|
||||
|
||||
function fixture(items) {
|
||||
const option = (question, label) => `<div class="pc_examtest_content_list_child${question.locked ? 2 : 1}"><div class="pc_examtest_content_list_des${(question.checked || []).includes(label) ? ' examtest_content_list_des_checked' : ''}" data-label="${label}" onclick="window.optionClicked(this)"><span class="examtest_content_list_des_optionsort">${label}</span>选项${label}</div></div>`;
|
||||
return `<app-examtest><div class="pc_examtest_title">测试练习</div>${items.map((question, index) => `<div app-exam-question><div class="pc_examtest_content" data-kind="${question.type}" data-nochange="${question.nochange ? '1' : '0'}"><div class="pc_examtest_content_question">[${question.type}] ${index + 1}. 测试题${index + 1}${question.locked ? '' : '<span class="pc_examtest_content_question_showanswer">查看答案</span>'}</div><div class="pc_examtest_content_list">${['A', 'B', 'C', 'D', 'E'].slice(0, question.type === '判断' ? 2 : 5).map(label => option(question, label)).join('')}</div>${question.locked ? '<div class="hidden pc_examtest_content_form_disabled"></div>' : ''}<div class="hidden"><div class="examtest_content_answer_left">参考答案:<span class="answer">B</span></div></div></div></div>`).join('')}</app-examtest>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const server = http.createServer((request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
|
||||
response.end('<!doctype html><meta charset="utf-8"><title>自动选择本机回归</title><style>.hidden{display:none}.pc_examtest_content{padding:12px}.pc_examtest_content_list_des{padding:6px}</style><body></body>');
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/practice`);
|
||||
await page.addScriptTag({ content: coreSource });
|
||||
await page.addScriptTag({ content: runnerSource });
|
||||
|
||||
async function setup(items, mode = 'normal') {
|
||||
await page.evaluate(({ html, mode }) => {
|
||||
window.qaRunner?.destroy();
|
||||
document.body.innerHTML = html;
|
||||
window.qaClicks = [];
|
||||
window.qaStates = [];
|
||||
window.qaMatchStarted = false;
|
||||
window.qaReleaseMatch = null;
|
||||
window.qaMode = mode;
|
||||
window.qaContext = { ready: true, practice: true, local: false, role: '主操', course: '测试练习', sourceUrl: location.href, routeKey: 'practice', root: document.querySelector('app-examtest'), pageSignature: 'stable', revision: 1 };
|
||||
// 只在真实 onclick 里更新状态,runner 必须点击选项才会发生选择或锁定。
|
||||
window.optionClicked = node => {
|
||||
const box = node.closest('.pc_examtest_content');
|
||||
window.qaClicks.push([box.dataset.kind, node.dataset.label]);
|
||||
if (box.dataset.nochange === '1') return;
|
||||
if (box.dataset.kind === '多选') {
|
||||
node.classList.toggle('examtest_content_list_des_checked');
|
||||
} else {
|
||||
box.querySelectorAll('.pc_examtest_content_list_des').forEach(option => option.classList.remove('examtest_content_list_des_checked'));
|
||||
node.classList.add('examtest_content_list_des_checked');
|
||||
box.querySelector('.pc_examtest_content_question_showanswer')?.remove();
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'hidden pc_examtest_content_form_disabled';
|
||||
box.append(marker);
|
||||
}
|
||||
};
|
||||
window.qaRunner = YianAnswerRunner.create({
|
||||
getContext: () => window.qaContext,
|
||||
match: async question => {
|
||||
window.qaMatchStarted = true;
|
||||
if (window.qaMode === 'delayed') await new Promise(resolve => { window.qaReleaseMatch = resolve; });
|
||||
if (window.qaMode === 'title') document.querySelector('.pc_examtest_title').textContent = '新练习';
|
||||
const labels = question.type === '多选' ? ['B', 'E'] : ['B'];
|
||||
return { status: 'matched', correctLabels: labels, correctTexts: labels.map(label => '选项' + label) };
|
||||
},
|
||||
onProgress: counts => {
|
||||
window.qaProgress = counts;
|
||||
if (window.qaMode === 'stop-after-first' && counts.selected === 1) window.qaRunner.stop('测试停止');
|
||||
},
|
||||
onState: (state, reason) => window.qaStates.push([state, reason])
|
||||
});
|
||||
}, { html: fixture(items), mode });
|
||||
}
|
||||
|
||||
async function delayedStart() {
|
||||
await page.evaluate(() => { window.qaPromise = qaRunner.start(); });
|
||||
await page.waitForFunction(() => window.qaMatchStarted && typeof window.qaReleaseMatch === 'function');
|
||||
}
|
||||
|
||||
async function finishMatch() {
|
||||
await page.evaluate(() => { window.qaReleaseMatch(); return qaPromise; });
|
||||
}
|
||||
|
||||
await setup([{ type: '单选' }, { type: '多选', checked: ['A'] }, { type: '判断' }, { type: '单选', checked: ['A'], locked: true }, { type: '单选', nochange: true }]);
|
||||
const startTime = Date.now();
|
||||
const initial = await page.evaluate(async () => {
|
||||
const first = qaRunner.start();
|
||||
const second = qaRunner.start();
|
||||
const samePromise = first === second;
|
||||
await first;
|
||||
return { counts: qaProgress, clicks: qaClicks, states: qaStates, samePromise };
|
||||
});
|
||||
assert.equal(initial.counts.selected, 3);
|
||||
assert.equal(initial.counts.locked, 1);
|
||||
assert.equal(initial.counts.total, 5);
|
||||
assert.deepEqual(initial.clicks.filter(([type]) => type !== '多选'), [['单选', 'B'], ['判断', 'B'], ['单选', 'B']]);
|
||||
console.log('PASS single/judgment selection and locked question preservation');
|
||||
|
||||
assert.equal(initial.counts.pending, 1);
|
||||
assert.deepEqual(initial.clicks.filter(([type]) => type === '多选'), [['多选', 'A'], ['多选', 'B'], ['多选', 'E']]);
|
||||
assert.deepEqual(await page.evaluate(() => Array.from(document.querySelectorAll('[data-kind="多选"] .examtest_content_list_des_checked')).map(node => node.dataset.label)), ['B', 'E']);
|
||||
console.log('PASS multiselect removes wrong choices then adds correct choices');
|
||||
|
||||
assert.equal(initial.counts.failed, 1);
|
||||
assert(initial.counts.details.some(item => item.status === 'failed' && item.reason.includes('限定时间')));
|
||||
assert(Date.now() - startTime < 10000, '无响应点击必须有界结束');
|
||||
assert.equal(initial.states.at(-1)[0], 'complete');
|
||||
console.log('PASS nonresponding option fails once and completes within limit');
|
||||
|
||||
assert(initial.samePromise, '并发 start 必须共用运行 Promise');
|
||||
await page.evaluate(() => qaRunner.start());
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), initial.clicks.length);
|
||||
console.log('PASS concurrent and repeated starts do not duplicate clicks');
|
||||
|
||||
await setup([{ type: '单选' }, { type: '单选' }], 'stop-after-first');
|
||||
await page.evaluate(() => qaRunner.start());
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 1);
|
||||
assert.equal(await page.evaluate(() => qaRunner.running), false);
|
||||
await page.evaluate(() => { window.qaMode = 'normal'; return qaRunner.start(); });
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 2);
|
||||
assert.equal(await page.evaluate(() => qaProgress.selected), 2);
|
||||
console.log('PASS switch off stops subsequent clicks and restart continues');
|
||||
|
||||
await setup([{ type: '单选' }], 'delayed');
|
||||
await delayedStart();
|
||||
await page.evaluate(() => { qaContext.role = '班长'; });
|
||||
await finishMatch();
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0);
|
||||
await page.evaluate(() => { window.qaMode = 'normal'; return qaRunner.start(); });
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 1);
|
||||
console.log('PASS role change cancels stale match and restart uses new role');
|
||||
|
||||
for (const field of ['revision', 'routeKey', 'sourceUrl']) {
|
||||
await setup([{ type: '单选' }], 'delayed');
|
||||
await delayedStart();
|
||||
await page.evaluate(field => { qaContext[field] = 'changed'; }, field);
|
||||
await finishMatch();
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0, field + ' 切换后不能点击旧题');
|
||||
}
|
||||
console.log('PASS bank revision, route and source changes cancel delayed clicks');
|
||||
|
||||
await setup([{ type: '单选' }], 'title');
|
||||
await page.evaluate(() => qaRunner.start());
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0);
|
||||
assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'stopped');
|
||||
await setup([{ type: '单选' }], 'delayed');
|
||||
await delayedStart();
|
||||
await page.evaluate(() => { location.hash = 'new-practice'; });
|
||||
await finishMatch();
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0);
|
||||
console.log('PASS live title and location changes stop before parent observer updates');
|
||||
|
||||
await setup([{ type: '多选' }], 'delayed');
|
||||
await delayedStart();
|
||||
await page.locator('.pc_examtest_content_list_des').first().click();
|
||||
await finishMatch();
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 1, '只允许真人本次点击生效');
|
||||
assert.equal(await page.evaluate(() => qaStates.at(-1)[0]), 'paused');
|
||||
console.log('PASS trusted manual answering pauses automatic selection');
|
||||
|
||||
await setup([{ type: '单选' }]);
|
||||
await page.evaluate(() => { qaContext.local = true; return qaRunner.start(); });
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0);
|
||||
await page.evaluate(() => { qaContext.local = false; qaContext.practice = false; return qaRunner.start(); });
|
||||
assert.equal(await page.evaluate(() => qaClicks.length), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS local/nonpractice pages blocked; no browser errors');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
+3
-3
@@ -121,7 +121,7 @@ function parseCSV(text) {
|
||||
document.querySelector('app-examtest').innerHTML = new DOMParser().parseFromString(html, 'text/html').querySelector('app-examtest').innerHTML;
|
||||
}, { url: practiceURL('缺答案练习'), html: fixture('缺答案练习', { missingAnswer: true }) });
|
||||
await page.waitForTimeout(2600); assert.equal(downloads.length, beforeMissing);
|
||||
assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('缺少 1 个答案'));
|
||||
assert((await page.locator('#yian-question-assistant .page-status').innerText()).includes('缺少 1 个网页答案'));
|
||||
const manual = page.waitForEvent('download'); await page.getByRole('button', { name: '导出 CSV', exact: true }).click();
|
||||
assert((await manual).suggestedFilename().includes('_不完整'));
|
||||
const missing = await page.evaluate(() => QuestionBankCore.extract(document));
|
||||
@@ -134,9 +134,9 @@ function parseCSV(text) {
|
||||
}, { url: practiceURL('缺题练习'), html: fixture('缺题练习', { cardCount: 4 }) });
|
||||
const beforeCount = downloads.length;
|
||||
await page.waitForTimeout(2600); assert.equal(downloads.length, beforeCount);
|
||||
assert((await page.locator('#yian-question-bank-exporter').innerText()).includes('3 / 4'));
|
||||
assert((await page.locator('#yian-question-assistant .page-status').innerText()).includes('3 / 4'));
|
||||
await page.evaluate(() => history.pushState({}, '', location.href.replace('flag=exercise', 'flag=exam')));
|
||||
await page.waitForTimeout(1100); assert.equal(await page.locator('#yian-question-bank-exporter').count(), 0);
|
||||
await page.waitForTimeout(1100); assert(await page.getByRole('button', { name: '导出 CSV', exact: true }).isDisabled());
|
||||
assert.equal(await page.evaluate(() => window.answerClicks), 0);
|
||||
console.log('PASS 缺题等待、考试页面不触发、不改变答题状态');
|
||||
|
||||
|
||||
Vendored
+651
@@ -0,0 +1,651 @@
|
||||
JSZip is dual licensed. At your choice you may use it under the MIT license *or* the GPLv3
|
||||
license.
|
||||
|
||||
The MIT License
|
||||
===============
|
||||
|
||||
Copyright (c) 2009-2016 Stuart Knightley, David Duponchel, Franz Buchinger, António Afonso
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
GPL version 3
|
||||
=============
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
(The MIT License)
|
||||
|
||||
Copyright (C) 2014-2017 by Vitaly Puzrin and Andrei Tuputcyn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
Vendored
+13
File diff suppressed because one or more lines are too long
+1731
-158
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user