From 0ea302bbab4370aaf854867f6b77b0838db3f6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E6=98=9F=E5=B9=BB=E6=B5=B7?= <30327324256@qq.com> Date: Sat, 10 Oct 2026 03:39:43 +0800 Subject: [PATCH] feat: add portable role-based question bank assistant --- README.md | 77 +- answer-matcher.js | 140 +++ answer-runner.js | 379 ++++++++ assistant-panel.js | 418 +++++++++ bank-store.js | 358 ++++++++ build-userscript.mjs | 26 +- copy-controller.js | 176 ++++ package.json | 7 +- practice-exporter.js | 311 ++++--- tests/assistant.cjs | 180 ++++ tests/bank-engine.cjs | 179 ++++ tests/run.cjs | 6 + tests/runner-browser.cjs | 176 ++++ tests/verify.cjs | 6 +- vendor/LICENSE-JSZip.md | 651 +++++++++++++ vendor/LICENSE-pako.txt | 21 + vendor/jszip.min.js | 13 + 谊安学题库导出.user.js | 1865 +++++++++++++++++++++++++++++++++++--- 18 files changed, 4659 insertions(+), 330 deletions(-) create mode 100644 answer-matcher.js create mode 100644 answer-runner.js create mode 100644 assistant-panel.js create mode 100644 bank-store.js create mode 100644 copy-controller.js create mode 100644 tests/assistant.cjs create mode 100644 tests/bank-engine.cjs create mode 100644 tests/run.cjs create mode 100644 tests/runner-browser.cjs create mode 100644 vendor/LICENSE-JSZip.md create mode 100644 vendor/LICENSE-pako.txt create mode 100644 vendor/jszip.min.js diff --git a/README.md b/README.md index c1cadba..556ded3 100644 --- a/README.md +++ b/README.md @@ -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//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`。 diff --git a/answer-matcher.js b/answer-matcher.js new file mode 100644 index 0000000..c43148a --- /dev/null +++ b/answer-matcher.js @@ -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); diff --git a/answer-runner.js b/answer-runner.js new file mode 100644 index 0000000..30f2d11 --- /dev/null +++ b/answer-runner.js @@ -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); diff --git a/assistant-panel.js b/assistant-panel.js new file mode 100644 index 0000000..eea1ec8 --- /dev/null +++ b/assistant-panel.js @@ -0,0 +1,418 @@ +/* 独立题库助手面板:页面正文与面板样式互不影响。 */ +(function (global) { + 'use strict'; + + const ROLES = ['主操', '班长', '副操']; + const PAGE_SIZE = 20; + const TYPE_NAMES = { single: '单选', multiple: '多选', judgment: '判断', unknown: '未知' }; + + function plain(value) { return String(value == null ? '' : value); } + function normalized(value) { return plain(value).replace(/\s+/g, ' ').trim(); } + function el(doc, tag, className, value) { + const node = doc.createElement(tag); + if (className) node.className = className; + if (value != null) node.textContent = value; + return node; + } + + function create(callbacks) { + callbacks = callbacks || {}; + const doc = global.document; + const state = { + autoAnswer: false, allowCopy: true, role: '', course: '', courses: [], + bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别练习页面', + canAnswer: false, canExport: false, metrics: {}, autoExport: true, expanded: false, loading: false + }; + let destroyed = false; + let selectionText = ''; + let currentOffset = 0; + let resultTotal = 0; + const host = doc.createElement('div'); + host.id = 'yian-question-assistant'; + host.setAttribute('data-yian-assistant', ''); + host.style.cssText = 'all:initial!important;position:fixed!important;right:16px!important;bottom:16px!important;z-index:2147483646!important;display:block!important;max-width:calc(100vw - 24px)!important;font:13px/1.5 system-ui,"Microsoft YaHei",sans-serif!important;color:#17253d!important;'; + const root = host.attachShadow({ mode: 'open' }); + const style = doc.createElement('style'); + style.textContent = ` + :host{font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif;color:#17253d;font-size:13px;line-height:1.5;color-scheme:light;--accent:#225aca;--muted:#65728a;--line:#e4e9f1;--surface:#f5f7fb} + *,*::before,*::after{box-sizing:border-box}button,input,select{font:inherit}button,input,select,label{-webkit-user-select:none;user-select:none}button{cursor:pointer}button:disabled,input:disabled,select:disabled{cursor:not-allowed;opacity:.5}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #b8d1ff;outline-offset:2px}button{border:1px solid var(--line);background:white;color:#273953;border-radius:7px;padding:6px 10px}button:hover:enabled{background:#f2f6ff;border-color:#b6caf1}input[type=text],select{width:100%;min-width:0;border:1px solid #cfd8e7;border-radius:7px;background:#fff;color:#17253d;padding:8px}select{padding:6px 8px}input[type=checkbox],input[type=radio]{accent-color:var(--accent)} + .panel{width:354px;max-width:calc(100vw - 24px);background:white;border:1px solid #dce3ee;border-radius:13px;box-shadow:0 8px 36px #14264528;overflow:hidden}.header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:#172a49;color:white}.brand{font-size:15px;font-weight:700;letter-spacing:.2px}.toggle{border:0;background:#ffffff17;color:white;padding:3px 9px;border-radius:5px;min-width:30px}.toggle:hover:enabled{background:#ffffff2c;color:white;border-color:transparent}.body{padding:13px;max-height:calc(100vh - 102px);max-height:calc(100dvh - 102px);overflow:auto;overscroll-behavior:contain}.body[hidden]{display:none}.panel.collapsed{width:auto;min-width:140px}.collapsed .header{padding:10px 12px;gap:16px}.switches{display:grid;gap:9px;padding-bottom:12px;border-bottom:1px solid var(--line)}.switch-row{display:flex;align-items:center;justify-content:space-between;position:relative;gap:12px;cursor:pointer;font-weight:600}.switch{appearance:none;-webkit-appearance:none;width:35px;height:21px;margin:0;border:0!important;border-radius:20px;background:#b7c0ce;position:relative;transition:background .15s}.switch::after{content:"";position:absolute;width:15px;height:15px;top:3px;left:3px;border-radius:50%;background:white;box-shadow:0 1px 3px #0002;transition:transform .15s}.switch:checked{background:var(--accent)}.switch:checked::after{transform:translateX(14px)}.hint{font-size:12px;color:var(--muted);margin:6px 0 0;overflow-wrap:anywhere}.section{padding-top:12px}.section-title{font-size:13px;font-weight:700;margin:0 0 7px}.import-row{display:flex;align-items:center;gap:10px}.import-row button{white-space:nowrap}.bank-info{font-size:12px;color:var(--muted);overflow-wrap:anywhere}.storage{color:#97702e;margin:4px 0 0;font-size:11px}.roles{display:flex;gap:6px;flex-wrap:wrap}.role{display:flex;align-items:center;gap:4px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;cursor:pointer;background:white;flex:1;justify-content:center;white-space:nowrap}.role.active{background:#edf3ff;border-color:#abc5fa;color:#194dab}.role input{margin:0}.role-count{font-size:10px;color:var(--muted)}.course-row{display:grid;grid-template-columns:36px 1fr;gap:7px;align-items:center;margin-top:8px}.search-row{display:flex;gap:6px;margin-top:9px}.search-row input{flex:1}.primary{background:var(--accent);border-color:var(--accent);color:white;white-space:nowrap}.primary:hover:enabled{background:#184baf;color:white;border-color:#184baf}.selection-search{margin-top:7px;font-size:12px;width:100%}.notice{padding:8px 9px;border-radius:7px;margin:10px 0 0;font-size:12px;overflow-wrap:anywhere;background:#edf3ff;color:#254e8b}.notice.warning{background:#fff5df;color:#8a5d11}.notice.error{background:#fff0ed;color:#a23a2c}.notice.success{background:#eaf7f0;color:#1e754c}.notice[hidden]{display:none}.results-title{font-size:12px;color:var(--muted);margin:11px 0 7px}.empty{background:var(--surface);border-radius:8px;padding:13px 10px;color:var(--muted);font-size:12px}.result{border:1px solid var(--line);border-radius:9px;padding:10px;margin-bottom:9px;-webkit-user-select:text;user-select:text}.question{font-weight:600;line-height:1.6;overflow-wrap:anywhere;margin:0 0 7px;white-space:pre-wrap}.type{display:inline-block;font-size:10px;font-weight:500;color:#556984;background:#eff3f8;padding:1px 5px;border-radius:4px;margin-right:5px;vertical-align:1px}.answer{font-weight:600;color:#1b714a;background:#edf8f1;border-radius:5px;padding:6px 7px;overflow-wrap:anywhere}.options{padding:0;margin:7px 0;list-style:none;font-size:12px;display:grid;gap:4px}.option{padding:3px 6px;border-radius:4px;overflow-wrap:anywhere;white-space:pre-wrap}.option.correct{color:#196442;background:#edf8f1;font-weight:600}.sources{font-size:11px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere;margin-top:5px}.conflict{color:#9d6012;background:#fff5df;border-radius:5px;font-size:11px;padding:6px;margin:6px 0}.copy-result{margin-top:8px;font-size:11px}.pagination{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted);margin:5px 0 0}.pagination button{font-size:11px;padding:4px 8px}.page-section{border-top:1px solid var(--line);margin-top:13px;padding-top:11px}.page-status{font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted)}.metrics{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11px;color:var(--muted);margin-top:6px}.unprocessed{font-size:11px;color:#8b621c;margin-top:7px}.unprocessed summary{cursor:pointer}.unprocessed-list{max-height:155px;overflow:auto;padding:7px;border-radius:6px;background:#fff7e8;margin-top:4px;display:grid;gap:5px;overflow-wrap:anywhere}.exports{display:flex;gap:6px;margin-top:9px}.exports button{flex:1;font-size:12px}.auto-export{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);margin-top:8px}.auto-export input{margin:0}.busy{color:#83692c}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){.body{padding:11px}.panel{width:350px}.role{padding:6px 5px}} + `; + root.appendChild(style); + const visibilityStyle = doc.createElement('style'); + visibilityStyle.textContent = '[hidden]{display:none!important}'; + root.appendChild(visibilityStyle); + const panel = el(doc, 'section', 'panel collapsed'); + panel.setAttribute('aria-label', '谊安学题库助手'); + const header = el(doc, 'div', 'header'); + const brand = el(doc, 'span', 'brand', '题库助手'); + const expand = el(doc, 'button', 'toggle', '展开'); + expand.type = 'button'; + expand.setAttribute('aria-label', '展开题库助手'); + expand.setAttribute('aria-expanded', 'false'); + header.append(brand, expand); + const body = el(doc, 'div', 'body'); + body.hidden = true; + panel.append(header, body); + root.appendChild(panel); + + function invoke(name) { + if (destroyed || typeof callbacks[name] !== 'function') return; + const args = Array.prototype.slice.call(arguments, 1); + try { + const result = callbacks[name].apply(null, args); + if (result && typeof result.catch === 'function') result.catch(function (error) { setNotice(error && error.message || '操作失败,请重试。', 'error'); }); + return result; + } catch (error) { setNotice(error && error.message || '操作失败,请重试。', 'error'); } + } + function setNotice(message, level) { + notice.textContent = plain(message); + notice.className = 'notice ' + (['warning', 'error', 'success'].includes(level) ? level : 'info'); + notice.hidden = !message; + } + function togglePanel() { state.expanded = !state.expanded; refresh(); } + expand.addEventListener('click', togglePanel); + brand.style.cursor = 'pointer'; + brand.addEventListener('click', togglePanel); + + const switches = el(doc, 'div', 'switches'); + function makeSwitch(labelText, callback) { + const label = el(doc, 'label', 'switch-row'); + const text = el(doc, 'span', '', labelText); + const input = el(doc, 'input', 'switch'); + input.type = 'checkbox'; + input.setAttribute('role', 'switch'); + input.setAttribute('aria-label', labelText); + label.append(text, input); + switches.appendChild(label); + input.addEventListener('change', callback); + return { input: input, label: label }; + } + const autoSwitch = makeSwitch('自动选择答案', function () { state.autoAnswer = autoSwitch.input.checked; invoke('onAutoChange', state.autoAnswer); refresh(); }); + const copySwitch = makeSwitch('允许复制文字', function () { state.allowCopy = copySwitch.input.checked; invoke('onCopyChange', state.allowCopy); refresh(); }); + const answerHint = el(doc, 'p', 'hint'); + switches.appendChild(answerHint); + body.appendChild(switches); + + const bankSection = el(doc, 'div', 'section'); + const importRow = el(doc, 'div', 'import-row'); + const importButton = el(doc, 'button', '', '导入题库'); + importButton.type = 'button'; + const fileInput = el(doc, 'input'); + fileInput.type = 'file'; + fileInput.accept = '.zip,.csv,.json,application/zip,text/csv,application/json'; + fileInput.multiple = true; + fileInput.hidden = true; + const bankInfo = el(doc, 'span', 'bank-info'); + importRow.append(importButton, bankInfo, fileInput); + const storageInfo = el(doc, 'p', 'storage'); + bankSection.append(importRow, storageInfo); + body.appendChild(bankSection); + importButton.addEventListener('click', function () { fileInput.click(); }); + fileInput.addEventListener('change', function () { + const files = Array.from(fileInput.files || []); + if (files.length) invoke('onImport', files); + fileInput.value = ''; + }); + + const searchSection = el(doc, 'div', 'section'); + searchSection.appendChild(el(doc, 'h2', 'section-title', '搜索题库')); + const roles = el(doc, 'div', 'roles'); + roles.setAttribute('role', 'radiogroup'); + roles.setAttribute('aria-label', '选择岗位'); + const roleInputs = {}; + const roleLabels = {}; + const roleCounters = {}; + ROLES.forEach(function (role) { + const label = el(doc, 'label', 'role'); + const input = el(doc, 'input'); + input.type = 'radio'; + input.name = 'yian-search-role'; + input.value = role; + input.setAttribute('aria-label', role); + const counter = el(doc, 'span', 'role-count'); + label.append(input, el(doc, 'span', '', role), counter); + roles.appendChild(label); + roleInputs[role] = input; + roleLabels[role] = label; + roleCounters[role] = counter; + input.addEventListener('change', function () { + if (!input.checked) return; + const query = searchInput.value; + state.role = role; + state.course = ''; + currentOffset = 0; + refresh(); + invoke('onRoleChange', role); + if (normalized(query)) invoke('onSearch', query, { offset: 0 }); + }); + }); + searchSection.appendChild(roles); + const courseRow = el(doc, 'label', 'course-row'); + const courseSelect = el(doc, 'select'); + courseSelect.setAttribute('aria-label', '选择课程'); + courseRow.append(el(doc, 'span', '', '课程'), courseSelect); + courseSelect.addEventListener('change', function () { + const query = searchInput.value; + state.course = courseSelect.value; + currentOffset = 0; + invoke('onCourseChange', state.course); + if (normalized(query)) invoke('onSearch', query, { offset: 0 }); + }); + searchSection.appendChild(courseRow); + const searchRow = el(doc, 'div', 'search-row'); + const searchInput = el(doc, 'input'); + searchInput.type = 'text'; + searchInput.placeholder = '输入题干、关键词或选项文字…'; + searchInput.setAttribute('aria-label', '搜索题目'); + const searchButton = el(doc, 'button', 'primary', '搜索'); + searchButton.type = 'button'; + searchRow.append(searchInput, searchButton); + searchSection.appendChild(searchRow); + const selectionButton = el(doc, 'button', 'selection-search', '用选中文字搜索'); + selectionButton.type = 'button'; + searchSection.appendChild(selectionButton); + const notice = el(doc, 'div', 'notice'); + notice.hidden = true; + notice.setAttribute('role', 'status'); + notice.setAttribute('aria-live', 'polite'); + searchSection.appendChild(notice); + const resultsTitle = el(doc, 'p', 'results-title'); + const results = el(doc, 'div', 'results'); + const pagination = el(doc, 'div', 'pagination'); + const previous = el(doc, 'button', '', '上一页'); + const pageLabel = el(doc, 'span'); + const next = el(doc, 'button', '', '下一页'); + previous.type = next.type = 'button'; + pagination.append(previous, pageLabel, next); + pagination.hidden = true; + searchSection.append(resultsTitle, results, pagination); + body.appendChild(searchSection); + + function search(offset) { + if (!state.role) { setNotice('请先选择岗位:主操、班长或副操。', 'warning'); return; } + if (!normalized(searchInput.value)) { setNotice('请输入题干、关键词或选项文字。', 'warning'); searchInput.focus(); return; } + currentOffset = Math.max(0, Number(offset) || 0); + invoke('onSearch', searchInput.value, { offset: currentOffset }); + } + searchButton.addEventListener('click', function () { search(0); }); + searchInput.addEventListener('keydown', function (event) { if (event.key === 'Enter' && !event.isComposing) { event.preventDefault(); search(0); } }); + previous.addEventListener('click', function () { search(Math.max(0, currentOffset - PAGE_SIZE)); }); + next.addEventListener('click', function () { search(currentOffset + PAGE_SIZE); }); + function rememberSelection() { + const selected = global.getSelection && global.getSelection(); + if (!selected || !selected.anchorNode || !selected.focusNode) return; + if (host.contains(selected.anchorNode) || host.contains(selected.focusNode) || root.contains(selected.anchorNode) || root.contains(selected.focusNode)) return; + const text = selected.toString().trim(); + if (text) selectionText = text; + } + doc.addEventListener('selectionchange', rememberSelection); + selectionButton.addEventListener('pointerdown', rememberSelection); + selectionButton.addEventListener('click', function () { + rememberSelection(); + if (!state.role) { setNotice('请先选择岗位:主操、班长或副操。', 'warning'); return; } + if (!selectionText) { setNotice('请先在网页正文选中题目文字。', 'warning'); return; } + searchInput.value = selectionText; + currentOffset = 0; + if (typeof callbacks.onSelectionSearch === 'function') invoke('onSelectionSearch', selectionText); + else search(0); + }); + + const pageSection = el(doc, 'div', 'page-section'); + pageSection.appendChild(el(doc, 'h2', 'section-title', '当前练习')); + const pageStatus = el(doc, 'div', 'page-status'); + const metrics = el(doc, 'div', 'metrics'); + const unprocessed = el(doc, 'details', 'unprocessed'); + const unprocessedSummary = el(doc, 'summary', '', '查看未处理题目'); + const unprocessedList = el(doc, 'div', 'unprocessed-list'); + unprocessed.append(unprocessedSummary, unprocessedList); + unprocessed.hidden = true; + const exportRow = el(doc, 'div', 'exports'); + const exportButtons = []; + ['csv', 'json', 'txt'].forEach(function (format) { + const button = el(doc, 'button', '', '导出 ' + format.toUpperCase()); + button.type = 'button'; + button.addEventListener('click', function () { invoke('onExport', format); }); + exportRow.appendChild(button); + exportButtons.push(button); + }); + const autoExportLabel = el(doc, 'label', 'auto-export'); + const autoExportInput = el(doc, 'input'); + autoExportInput.type = 'checkbox'; + autoExportLabel.append(autoExportInput, el(doc, 'span', '', '进入练习时自动导出 CSV')); + autoExportInput.addEventListener('change', function () { state.autoExport = autoExportInput.checked; invoke('onAutoExportChange', state.autoExport); }); + pageSection.append(pageStatus, metrics, unprocessed, exportRow, autoExportLabel); + body.appendChild(pageSection); + + function autoUnavailableReason() { + if (!state.role) return '请选择岗位后再开启自动选择。'; + if (!(Number(state.roleCounts && state.roleCounts[state.role]) > 0)) return '请导入包含' + state.role + '题目的题库。'; + if (!state.canAnswer) return state.pageStatus || '当前页面未识别到支持的练习题目。'; + return ''; + } + autoSwitch.label.addEventListener('pointerdown', function () { + const reason = autoUnavailableReason(); + if (!state.autoAnswer && reason) setNotice(reason, 'warning'); + }); + + let courseSignature = ''; + function refresh() { + if (destroyed) return; + panel.classList.toggle('collapsed', !state.expanded); + body.hidden = !state.expanded; + brand.textContent = state.expanded ? '谊安学助手' : '题库助手'; + expand.textContent = state.expanded ? '收起' : '展开'; + expand.setAttribute('aria-label', state.expanded ? '收起题库助手' : '展开题库助手'); + expand.setAttribute('aria-expanded', String(Boolean(state.expanded))); + const unavailable = autoUnavailableReason(); + autoSwitch.input.checked = Boolean(state.autoAnswer); + autoSwitch.input.disabled = !state.autoAnswer && (Boolean(unavailable) || Boolean(state.loading)); + autoSwitch.label.title = unavailable; + copySwitch.input.checked = Boolean(state.allowCopy); + answerHint.textContent = unavailable || (state.autoAnswer ? '正在自动选择;关闭即可停止后续操作。' : '开启后选择已匹配的答案,交卷由你操作。'); + bankInfo.textContent = state.loading ? '正在读取题库…' : '已加载 ' + Number(state.bankTotal || 0).toLocaleString('zh-CN') + ' 条' + (state.role ? ' · ' + state.role + ' ' + Number(state.roleCounts && state.roleCounts[state.role] || 0).toLocaleString('zh-CN') + ' 条' : ''); + bankInfo.classList.toggle('busy', Boolean(state.loading)); + importButton.disabled = Boolean(state.loading); + storageInfo.textContent = state.storageMode === 'origin' ? '此模式仅在当前网站保存题库和设置。' : ''; + storageInfo.hidden = !storageInfo.textContent; + ROLES.forEach(function (role) { + roleInputs[role].checked = state.role === role; + roleLabels[role].classList.toggle('active', state.role === role); + const count = Number(state.roleCounts && state.roleCounts[role] || 0); + roleCounters[role].textContent = count ? '(' + count.toLocaleString('zh-CN') + ')' : ''; + }); + const availableCourses = (state.courses || []).map(function (course) { + return typeof course === 'string' ? { value: course, label: course } : { value: plain(course.value == null ? (course.course || course.name) : course.value), label: plain(course.label || course.name || course.course || course.value) }; + }); + const signature = JSON.stringify(availableCourses); + if (signature !== courseSignature) { + courseSignature = signature; + courseSelect.replaceChildren(); + const all = el(doc, 'option', '', '全部课程'); + all.value = ''; + courseSelect.appendChild(all); + availableCourses.forEach(function (course) { const option = el(doc, 'option', '', course.label); option.value = course.value; courseSelect.appendChild(option); }); + } + courseSelect.value = state.course || ''; + courseSelect.disabled = !state.role || Boolean(state.loading); + searchButton.disabled = Boolean(state.loading); + selectionButton.disabled = Boolean(state.loading); + pageStatus.textContent = state.pageStatus || '等待识别练习页面'; + metrics.replaceChildren(); + const metricLabels = { selected: '已选择', pending: '待确认', missing: '未匹配', conflict: '冲突', locked: '已锁定', unsupported: '需人工核对', failed: '失败', skipped: '已跳过' }; + Object.keys(metricLabels).forEach(function (key) { + if (state.metrics && state.metrics[key] != null) metrics.appendChild(el(doc, 'span', '', metricLabels[key] + ' ' + Number(state.metrics[key] || 0))); + }); + const notProcessed = (state.metrics && state.metrics.details || []).filter(function (item) { return item.status !== 'selected'; }); + unprocessed.hidden = !notProcessed.length; + unprocessedSummary.textContent = '查看未处理题目(' + notProcessed.length + ')'; + unprocessedList.replaceChildren(); + notProcessed.forEach(function (item) { + unprocessedList.appendChild(el(doc, 'div', '', '第 ' + item.number + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。'))); + }); + exportButtons.forEach(function (button) { button.disabled = !state.canExport; }); + autoExportInput.checked = Boolean(state.autoExport); + previous.disabled = currentOffset === 0 || Boolean(state.loading); + next.disabled = currentOffset + PAGE_SIZE >= resultTotal || Boolean(state.loading); + } + + function optionsOf(item) { + if (Array.isArray(item.options)) return item.options.map(function (option, index) { + return typeof option === 'string' ? { label: String.fromCharCode(65 + index), text: option } : { label: plain(option.label || option.key || String.fromCharCode(65 + index)), text: plain(option.text == null ? option.value : option.text), correct: option.correct }; + }); + return Object.keys(item.options || {}).map(function (label) { return { label: label, text: plain(item.options[label]) }; }); + } + function sourcesOf(item) { + const entries = Array.isArray(item.sources) && item.sources.length ? item.sources : [{ role: item.role || state.role, course: item.course || item.title || item.bankTitle || '', file: item.filename || '' }]; + return Array.from(new Set(entries.map(function (source) { + if (typeof source === 'string') return source; + return [source.role || item.role, source.course || source.title || source.bankTitle || source.name, source.filename || source.file].filter(Boolean).join(' / '); + }).filter(Boolean))); + } + function answerInfo(item, options) { + const answer = plain(item.answer == null ? item.correctAnswer : item.answer); + const letters = plain(item.answerLabels || item.correctLabels || answer).replace(/[\s,,、;;.。]/g, '').toUpperCase(); + const texts = Array.isArray(item.answerTexts) ? item.answerTexts.map(normalized) : []; + const correctOptions = options.filter(function (option) { return option.correct || /^[A-Z]+$/.test(letters) && letters.includes(option.label.toUpperCase()) || texts.includes(normalized(option.text)); }); + const description = item.answerText || correctOptions.map(function (option) { return option.label + ' — ' + (option.text || '[图片选项]'); }).join(';') || texts.join(';'); + return { answer: answer, description: plain(description), correctOptions: correctOptions }; + } + async function copyText(value) { + const legacyClipboard = typeof GM_setClipboard === 'function' ? GM_setClipboard : global.GM_setClipboard; + const modernGM = typeof GM !== 'undefined' ? GM : global.GM; + if (typeof legacyClipboard === 'function') { await legacyClipboard(value, 'text'); return; } + if (modernGM && typeof modernGM.setClipboard === 'function') { await modernGM.setClipboard(value, 'text'); return; } + if (global.navigator.clipboard && typeof global.navigator.clipboard.writeText === 'function') { + try { await global.navigator.clipboard.writeText(value); return; } catch (_) { /* 再尝试浏览器复制命令。 */ } + } + const textarea = el(doc, 'textarea'); + textarea.value = value; + textarea.style.cssText = 'position:fixed;left:0;top:0;opacity:0;width:1px;height:1px;'; + root.appendChild(textarea); + textarea.focus(); + textarea.select(); + let copied = false; + try { copied = doc.execCommand('copy'); } finally { textarea.remove(); } + if (!copied) throw new Error('浏览器未允许复制,请选中结果文字手动复制。'); + } + function renderSearch(result) { + if (destroyed) return; + result = result || {}; + const items = (result.items || []).slice(0, PAGE_SIZE); + resultTotal = Number(result.total == null ? items.length : result.total) || 0; + currentOffset = Math.max(0, Number(result.offset == null ? currentOffset : result.offset) || 0); + resultsTitle.textContent = state.role ? '找到 ' + resultTotal.toLocaleString('zh-CN') + ' 道题 · 当前岗位:' + state.role : '先选择岗位,再搜索题目'; + results.replaceChildren(); + if (!items.length) results.appendChild(el(doc, 'div', 'empty', state.role ? '当前岗位未找到题目,可调整关键词、课程或切换岗位。' : '选择主操、班长或副操,导入题库后即可查找答案。')); + items.forEach(function (item) { + const card = el(doc, 'article', 'result'); + const title = el(doc, 'p', 'question'); + const type = TYPE_NAMES[item.type] || item.type || '题目'; + title.append(el(doc, 'span', 'type', type), doc.createTextNode(plain(item.question == null ? item.stem : item.question))); + card.appendChild(title); + const options = optionsOf(item); + const info = answerInfo(item, options); + card.appendChild(el(doc, 'div', 'answer', '正确答案:' + (info.answer || '见下方选项') + (info.description ? ' · ' + info.description : ''))); + const list = el(doc, 'ul', 'options'); + options.forEach(function (option) { + const correct = info.correctOptions.includes(option); + const row = el(doc, 'li', 'option' + (correct ? ' correct' : ''), option.label + '. ' + (option.text || '[图片选项]') + (correct ? ' ✓' : '')); + list.appendChild(row); + }); + card.appendChild(list); + if (item.conflict || item.conflicts && item.conflicts.length || item.ambiguous) card.appendChild(el(doc, 'div', 'conflict', '此题存在不同答案,请核对课程来源;自动选择会跳过不明确的匹配。')); + const sources = sourcesOf(item); + card.appendChild(el(doc, 'div', 'sources', '来源:' + (sources.join('\n') || '未知'))); + const copyButton = el(doc, 'button', 'copy-result', '复制题目与答案'); + copyButton.type = 'button'; + copyButton.addEventListener('click', async function () { + const content = ['[' + type + '] ' + plain(item.question == null ? item.stem : item.question)].concat(options.map(function (option) { return option.label + '. ' + (option.text || '[图片选项]'); }), ['正确答案:' + info.answer, info.description ? '对应选项:' + info.description : '', '来源:' + sources.join(';')]).filter(Boolean).join('\n'); + try { await copyText(content); setNotice('题目与答案已复制。', 'success'); } catch (error) { setNotice(error.message, 'error'); } + }); + card.appendChild(copyButton); + results.appendChild(card); + }); + pagination.hidden = resultTotal <= PAGE_SIZE; + pageLabel.textContent = resultTotal ? (currentOffset + 1) + '–' + Math.min(currentOffset + items.length, resultTotal) + ' / ' + resultTotal : '0 / 0'; + refresh(); + } + + function setState(partial) { + if (destroyed) return; + Object.assign(state, partial || {}); + refresh(); + } + function destroy() { + if (destroyed) return; + destroyed = true; + doc.removeEventListener('selectionchange', rememberSelection); + host.remove(); + } + (doc.body || doc.documentElement).appendChild(host); + renderSearch({ items: [], total: 0 }); + refresh(); + return { setState: setState, renderSearch: renderSearch, setNotice: setNotice, destroy: destroy, host: host, root: root }; + } + + global.YianAssistantPanel = { create: create }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/bank-store.js b/bank-store.js new file mode 100644 index 0000000..e0f3081 --- /dev/null +++ b/bank-store.js @@ -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); diff --git a/build-userscript.mjs b/build-userscript.mjs index 095ee5d..f9ece6d 100644 --- a/build-userscript.mjs +++ b/build-userscript.mjs @@ -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'); diff --git a/copy-controller.js b/copy-controller.js new file mode 100644 index 0000000..63d27ec --- /dev/null +++ b/copy-controller.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); diff --git a/package.json b/package.json index d8230e1..0750d09 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/practice-exporter.js b/practice-exporter.js index 2aed96e..7a79eda 100644 --- a/practice-exporter.js +++ b/practice-exporter.js @@ -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); } - }); - actions.append(button); + 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'); } - 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 courses() { + return Array.from(new Set(snapshot.records.filter(record => record.role === config.role).map(record => record.course))).sort(); } - - 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 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 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; + 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'); } + } } - 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 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(); })(); diff --git a/tests/assistant.cjs b/tests/assistant.cjs new file mode 100644 index 0000000..0755b8e --- /dev/null +++ b/tests/assistant.cjs @@ -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 `
安全领导力-1
${questions.map((q, index) => + `

[${q.type}] ${index + 1}. ${q.stem}查看答案

${q.options.map((text, i) => `
${String.fromCharCode(65 + i)}${text}
`).join('')}
`).join('')}
${questions.map((_, i) => `
${i + 1}
`).join('')}
重新练习
`; +} +function documentHTML(practice) { + return `${practice ? '助手练习验证' : '普通网页验证'}

${practice ? '练习页面' : '任意网址的题库搜索'}

广西华谊新材料有限公司涉及几种高危工艺

${practice ? practiceHTML() : '

网页没有练习题目,仍可使用岗位题库搜索与复制开关。

'}`; +} + +(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; }); diff --git a/tests/bank-engine.cjs b/tests/bank-engine.cjs new file mode 100644 index 0000000..9fb81dc --- /dev/null +++ b/tests/bank-engine.cjs @@ -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; }); diff --git a/tests/run.cjs b/tests/run.cjs new file mode 100644 index 0000000..025cfd8 --- /dev/null +++ b/tests/run.cjs @@ -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); +} diff --git a/tests/runner-browser.cjs b/tests/runner-browser.cjs new file mode 100644 index 0000000..5560325 --- /dev/null +++ b/tests/runner-browser.cjs @@ -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) => `
${label}选项${label}
`; + return `
测试练习
${items.map((question, index) => `
[${question.type}] ${index + 1}. 测试题${index + 1}${question.locked ? '' : '查看答案'}
${['A', 'B', 'C', 'D', 'E'].slice(0, question.type === '判断' ? 2 : 5).map(label => option(question, label)).join('')}
${question.locked ? '' : ''}
`).join('')}
`; +} + +async function main() { + const server = http.createServer((request, response) => { + response.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' }); + response.end('自动选择本机回归'); + }); + 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; }); diff --git a/tests/verify.cjs b/tests/verify.cjs index 4a003ba..8f94f34 100644 --- a/tests/verify.cjs +++ b/tests/verify.cjs @@ -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 缺题等待、考试页面不触发、不改变答题状态'); diff --git a/vendor/LICENSE-JSZip.md b/vendor/LICENSE-JSZip.md new file mode 100644 index 0000000..f8250b3 --- /dev/null +++ b/vendor/LICENSE-JSZip.md @@ -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. + 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 diff --git a/vendor/LICENSE-pako.txt b/vendor/LICENSE-pako.txt new file mode 100644 index 0000000..a934ef8 --- /dev/null +++ b/vendor/LICENSE-pako.txt @@ -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. diff --git a/vendor/jszip.min.js b/vendor/jszip.min.js new file mode 100644 index 0000000..ff4cfd5 --- /dev/null +++ b/vendor/jszip.min.js @@ -0,0 +1,13 @@ +/*! + +JSZip v3.10.1 - A JavaScript class for generating and reading zip files + + +(c) 2009-2016 Stuart Knightley +Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown. + +JSZip uses the library pako released under the MIT license : +https://github.com/nodeca/pako/blob/main/LICENSE +*/ + +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,h){function u(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(l)return l(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return u(t||e)},i,i.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,e=0;e>2,s=(3&t)<<4|r>>4,a=1>6:64,o=2>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),l[h++]=t,64!==s&&(l[h++]=r),64!==a&&(l[h++]=n);return l}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils");var o=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}(0|t,e,e.length,0):function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t.charCodeAt(a))];return-1^e}(0|t,e,e.length,0):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n=null;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function h(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(h,a),h.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=function(e){t.push({data:e,meta:t.meta})}},r.compressWorker=function(e){return new h("Deflate",e)},r.uncompressWorker=function(){return new h("Inflate",{})}},{"./stream/GenericWorker":28,"./utils":32,pako:38}],8:[function(e,t,r){"use strict";function A(e,t){var r,n="";for(r=0;r>>=8;return n}function n(e,t,r,n,i,s){var a,o,h=e.file,u=e.compression,l=s!==O.utf8encode,f=I.transformTo("string",s(h.name)),c=I.transformTo("string",O.utf8encode(h.name)),d=h.comment,p=I.transformTo("string",s(d)),m=I.transformTo("string",O.utf8encode(d)),_=c.length!==h.name.length,g=m.length!==d.length,b="",v="",y="",w=h.dir,k=h.date,x={crc32:0,compressedSize:0,uncompressedSize:0};t&&!r||(x.crc32=e.crc32,x.compressedSize=e.compressedSize,x.uncompressedSize=e.uncompressedSize);var S=0;t&&(S|=8),l||!_&&!g||(S|=2048);var z=0,C=0;w&&(z|=16),"UNIX"===i?(C=798,z|=function(e,t){var r=e;return e||(r=t?16893:33204),(65535&r)<<16}(h.unixPermissions,w)):(C=20,z|=function(e){return 63&(e||0)}(h.dosPermissions)),a=k.getUTCHours(),a<<=6,a|=k.getUTCMinutes(),a<<=5,a|=k.getUTCSeconds()/2,o=k.getUTCFullYear()-1980,o<<=4,o|=k.getUTCMonth()+1,o<<=5,o|=k.getUTCDate(),_&&(v=A(1,1)+A(B(f),4)+c,b+="up"+A(v.length,2)+v),g&&(y=A(1,1)+A(B(p),4)+m,b+="uc"+A(y.length,2)+y);var E="";return E+="\n\0",E+=A(S,2),E+=u.magic,E+=A(a,2),E+=A(o,2),E+=A(x.crc32,4),E+=A(x.compressedSize,4),E+=A(x.uncompressedSize,4),E+=A(f.length,2),E+=A(b.length,2),{fileRecord:R.LOCAL_FILE_HEADER+E+f+b,dirRecord:R.CENTRAL_FILE_HEADER+A(C,2)+E+A(p.length,2)+"\0\0\0\0"+A(z,4)+A(n,4)+f+b+p}}var I=e("../utils"),i=e("../stream/GenericWorker"),O=e("../utf8"),B=e("../crc32"),R=e("../signature");function s(e,t,r,n){i.call(this,"ZipFileWorker"),this.bytesWritten=0,this.zipComment=t,this.zipPlatform=r,this.encodeFileName=n,this.streamFiles=e,this.accumulate=!1,this.contentBuffer=[],this.dirRecords=[],this.currentSourceOffset=0,this.entriesCount=0,this.currentFile=null,this._sources=[]}I.inherits(s,i),s.prototype.push=function(e){var t=e.meta.percent||0,r=this.entriesCount,n=this._sources.length;this.accumulate?this.contentBuffer.push(e):(this.bytesWritten+=e.data.length,i.prototype.push.call(this,{data:e.data,meta:{currentFile:this.currentFile,percent:r?(t+100*(r-n-1))/r:100}}))},s.prototype.openedSource=function(e){this.currentSourceOffset=this.bytesWritten,this.currentFile=e.file.name;var t=this.streamFiles&&!e.file.dir;if(t){var r=n(e,t,!1,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);this.push({data:r.fileRecord,meta:{percent:0}})}else this.accumulate=!0},s.prototype.closedSource=function(e){this.accumulate=!1;var t=this.streamFiles&&!e.file.dir,r=n(e,t,!0,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);if(this.dirRecords.push(r.dirRecord),t)this.push({data:function(e){return R.DATA_DESCRIPTOR+A(e.crc32,4)+A(e.compressedSize,4)+A(e.uncompressedSize,4)}(e),meta:{percent:100}});else for(this.push({data:r.fileRecord,meta:{percent:0}});this.contentBuffer.length;)this.push(this.contentBuffer.shift());this.currentFile=null},s.prototype.flush=function(){for(var e=this.bytesWritten,t=0;t=this.index;t--)r=(r<<8)+this.byteAt(t);return this.index+=e,r},readString:function(e){return n.transformTo("string",this.readData(e))},readData:function(){},lastIndexOfSignature:function(){},readAndCheckSignature:function(){},readDate:function(){var e=this.readInt(4);return new Date(Date.UTC(1980+(e>>25&127),(e>>21&15)-1,e>>16&31,e>>11&31,e>>5&63,(31&e)<<1))}},t.exports=i},{"../utils":32}],19:[function(e,t,r){"use strict";var n=e("./Uint8ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./Uint8ArrayReader":21}],20:[function(e,t,r){"use strict";var n=e("./DataReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.byteAt=function(e){return this.data.charCodeAt(this.zero+e)},i.prototype.lastIndexOfSignature=function(e){return this.data.lastIndexOf(e)-this.zero},i.prototype.readAndCheckSignature=function(e){return e===this.readData(4)},i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./DataReader":18}],21:[function(e,t,r){"use strict";var n=e("./ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){if(this.checkOffset(e),0===e)return new Uint8Array(0);var t=this.data.subarray(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./ArrayReader":17}],22:[function(e,t,r){"use strict";var n=e("../utils"),i=e("../support"),s=e("./ArrayReader"),a=e("./StringReader"),o=e("./NodeBufferReader"),h=e("./Uint8ArrayReader");t.exports=function(e){var t=n.getTypeOf(e);return n.checkSupport(t),"string"!==t||i.uint8array?"nodebuffer"===t?new o(e):i.uint8array?new h(n.transformTo("uint8array",e)):new s(n.transformTo("array",e)):new a(e)}},{"../support":30,"../utils":32,"./ArrayReader":17,"./NodeBufferReader":19,"./StringReader":20,"./Uint8ArrayReader":21}],23:[function(e,t,r){"use strict";r.LOCAL_FILE_HEADER="PK",r.CENTRAL_FILE_HEADER="PK",r.CENTRAL_DIRECTORY_END="PK",r.ZIP64_CENTRAL_DIRECTORY_LOCATOR="PK",r.ZIP64_CENTRAL_DIRECTORY_END="PK",r.DATA_DESCRIPTOR="PK\b"},{}],24:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../utils");function s(e){n.call(this,"ConvertWorker to "+e),this.destType=e}i.inherits(s,n),s.prototype.processChunk=function(e){this.push({data:i.transformTo(this.destType,e.data),meta:e.meta})},t.exports=s},{"../utils":32,"./GenericWorker":28}],25:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../crc32");function s(){n.call(this,"Crc32Probe"),this.withStreamInfo("crc32",0)}e("../utils").inherits(s,n),s.prototype.processChunk=function(e){this.streamInfo.crc32=i(e.data,this.streamInfo.crc32||0),this.push(e)},t.exports=s},{"../crc32":4,"../utils":32,"./GenericWorker":28}],26:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataLengthProbe for "+e),this.propName=e,this.withStreamInfo(e,0)}n.inherits(s,i),s.prototype.processChunk=function(e){if(e){var t=this.streamInfo[this.propName]||0;this.streamInfo[this.propName]=t+e.data.length}i.prototype.processChunk.call(this,e)},t.exports=s},{"../utils":32,"./GenericWorker":28}],27:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataWorker");var t=this;this.dataIsReady=!1,this.index=0,this.max=0,this.data=null,this.type="",this._tickScheduled=!1,e.then(function(e){t.dataIsReady=!0,t.data=e,t.max=e&&e.length||0,t.type=n.getTypeOf(e),t.isPaused||t._tickAndRepeat()},function(e){t.error(e)})}n.inherits(s,i),s.prototype.cleanUp=function(){i.prototype.cleanUp.call(this),this.data=null},s.prototype.resume=function(){return!!i.prototype.resume.call(this)&&(!this._tickScheduled&&this.dataIsReady&&(this._tickScheduled=!0,n.delay(this._tickAndRepeat,[],this)),!0)},s.prototype._tickAndRepeat=function(){this._tickScheduled=!1,this.isPaused||this.isFinished||(this._tick(),this.isFinished||(n.delay(this._tickAndRepeat,[],this),this._tickScheduled=!0))},s.prototype._tick=function(){if(this.isPaused||this.isFinished)return!1;var e=null,t=Math.min(this.max,this.index+16384);if(this.index>=this.max)return this.end();switch(this.type){case"string":e=this.data.substring(this.index,t);break;case"uint8array":e=this.data.subarray(this.index,t);break;case"array":case"nodebuffer":e=this.data.slice(this.index,t)}return this.index=t,this.push({data:e,meta:{percent:this.max?this.index/this.max*100:0}})},t.exports=s},{"../utils":32,"./GenericWorker":28}],28:[function(e,t,r){"use strict";function n(e){this.name=e||"default",this.streamInfo={},this.generatedError=null,this.extraStreamInfo={},this.isPaused=!0,this.isFinished=!1,this.isLocked=!1,this._listeners={data:[],end:[],error:[]},this.previous=null}n.prototype={push:function(e){this.emit("data",e)},end:function(){if(this.isFinished)return!1;this.flush();try{this.emit("end"),this.cleanUp(),this.isFinished=!0}catch(e){this.emit("error",e)}return!0},error:function(e){return!this.isFinished&&(this.isPaused?this.generatedError=e:(this.isFinished=!0,this.emit("error",e),this.previous&&this.previous.error(e),this.cleanUp()),!0)},on:function(e,t){return this._listeners[e].push(t),this},cleanUp:function(){this.streamInfo=this.generatedError=this.extraStreamInfo=null,this._listeners=[]},emit:function(e,t){if(this._listeners[e])for(var r=0;r "+e:e}},t.exports=n},{}],29:[function(e,t,r){"use strict";var h=e("../utils"),i=e("./ConvertWorker"),s=e("./GenericWorker"),u=e("../base64"),n=e("../support"),a=e("../external"),o=null;if(n.nodestream)try{o=e("../nodejs/NodejsStreamOutputAdapter")}catch(e){}function l(e,o){return new a.Promise(function(t,r){var n=[],i=e._internalType,s=e._outputType,a=e._mimeType;e.on("data",function(e,t){n.push(e),o&&o(t)}).on("error",function(e){n=[],r(e)}).on("end",function(){try{var e=function(e,t,r){switch(e){case"blob":return h.newBlob(h.transformTo("arraybuffer",t),r);case"base64":return u.encode(t);default:return h.transformTo(e,t)}}(s,function(e,t){var r,n=0,i=null,s=0;for(r=0;r>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t}(e)},s.utf8decode=function(e){return h.nodebuffer?o.transformTo("nodebuffer",e).toString("utf-8"):function(e){var t,r,n,i,s=e.length,a=new Array(2*s);for(t=r=0;t>10&1023,a[r++]=56320|1023&n)}return a.length!==r&&(a.subarray?a=a.subarray(0,r):a.length=r),o.applyFromCharCode(a)}(e=o.transformTo(h.uint8array?"uint8array":"array",e))},o.inherits(a,n),a.prototype.processChunk=function(e){var t=o.transformTo(h.uint8array?"uint8array":"array",e.data);if(this.leftOver&&this.leftOver.length){if(h.uint8array){var r=t;(t=new Uint8Array(r.length+this.leftOver.length)).set(this.leftOver,0),t.set(r,this.leftOver.length)}else t=this.leftOver.concat(t);this.leftOver=null}var n=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}(t),i=t;n!==t.length&&(h.uint8array?(i=t.subarray(0,n),this.leftOver=t.subarray(n,t.length)):(i=t.slice(0,n),this.leftOver=t.slice(n,t.length))),this.push({data:s.utf8decode(i),meta:e.meta})},a.prototype.flush=function(){this.leftOver&&this.leftOver.length&&(this.push({data:s.utf8decode(this.leftOver),meta:{}}),this.leftOver=null)},s.Utf8DecodeWorker=a,o.inherits(l,n),l.prototype.processChunk=function(e){this.push({data:s.utf8encode(e.data),meta:e.meta})},s.Utf8EncodeWorker=l},{"./nodejsUtils":14,"./stream/GenericWorker":28,"./support":30,"./utils":32}],32:[function(e,t,a){"use strict";var o=e("./support"),h=e("./base64"),r=e("./nodejsUtils"),u=e("./external");function n(e){return e}function l(e,t){for(var r=0;r>8;this.dir=!!(16&this.externalFileAttributes),0==e&&(this.dosPermissions=63&this.externalFileAttributes),3==e&&(this.unixPermissions=this.externalFileAttributes>>16&65535),this.dir||"/"!==this.fileNameStr.slice(-1)||(this.dir=!0)},parseZIP64ExtraField:function(){if(this.extraFields[1]){var e=n(this.extraFields[1].value);this.uncompressedSize===s.MAX_VALUE_32BITS&&(this.uncompressedSize=e.readInt(8)),this.compressedSize===s.MAX_VALUE_32BITS&&(this.compressedSize=e.readInt(8)),this.localHeaderOffset===s.MAX_VALUE_32BITS&&(this.localHeaderOffset=e.readInt(8)),this.diskNumberStart===s.MAX_VALUE_32BITS&&(this.diskNumberStart=e.readInt(4))}},readExtraFields:function(e){var t,r,n,i=e.index+this.extraFieldsLength;for(this.extraFields||(this.extraFields={});e.index+4>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t},r.buf2binstring=function(e){return l(e,e.length)},r.binstring2buf=function(e){for(var t=new h.Buf8(e.length),r=0,n=t.length;r>10&1023,o[n++]=56320|1023&i)}return l(o,n)},r.utf8border=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}},{"./common":41}],43:[function(e,t,r){"use strict";t.exports=function(e,t,r,n){for(var i=65535&e|0,s=e>>>16&65535|0,a=0;0!==r;){for(r-=a=2e3>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}},{}],46:[function(e,t,r){"use strict";var h,c=e("../utils/common"),u=e("./trees"),d=e("./adler32"),p=e("./crc32"),n=e("./messages"),l=0,f=4,m=0,_=-2,g=-1,b=4,i=2,v=8,y=9,s=286,a=30,o=19,w=2*s+1,k=15,x=3,S=258,z=S+x+1,C=42,E=113,A=1,I=2,O=3,B=4;function R(e,t){return e.msg=n[t],t}function T(e){return(e<<1)-(4e.avail_out&&(r=e.avail_out),0!==r&&(c.arraySet(e.output,t.pending_buf,t.pending_out,r,e.next_out),e.next_out+=r,t.pending_out+=r,e.total_out+=r,e.avail_out-=r,t.pending-=r,0===t.pending&&(t.pending_out=0))}function N(e,t){u._tr_flush_block(e,0<=e.block_start?e.block_start:-1,e.strstart-e.block_start,t),e.block_start=e.strstart,F(e.strm)}function U(e,t){e.pending_buf[e.pending++]=t}function P(e,t){e.pending_buf[e.pending++]=t>>>8&255,e.pending_buf[e.pending++]=255&t}function L(e,t){var r,n,i=e.max_chain_length,s=e.strstart,a=e.prev_length,o=e.nice_match,h=e.strstart>e.w_size-z?e.strstart-(e.w_size-z):0,u=e.window,l=e.w_mask,f=e.prev,c=e.strstart+S,d=u[s+a-1],p=u[s+a];e.prev_length>=e.good_match&&(i>>=2),o>e.lookahead&&(o=e.lookahead);do{if(u[(r=t)+a]===p&&u[r+a-1]===d&&u[r]===u[s]&&u[++r]===u[s+1]){s+=2,r++;do{}while(u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&sh&&0!=--i);return a<=e.lookahead?a:e.lookahead}function j(e){var t,r,n,i,s,a,o,h,u,l,f=e.w_size;do{if(i=e.window_size-e.lookahead-e.strstart,e.strstart>=f+(f-z)){for(c.arraySet(e.window,e.window,f,f,0),e.match_start-=f,e.strstart-=f,e.block_start-=f,t=r=e.hash_size;n=e.head[--t],e.head[t]=f<=n?n-f:0,--r;);for(t=r=f;n=e.prev[--t],e.prev[t]=f<=n?n-f:0,--r;);i+=f}if(0===e.strm.avail_in)break;if(a=e.strm,o=e.window,h=e.strstart+e.lookahead,u=i,l=void 0,l=a.avail_in,u=x)for(s=e.strstart-e.insert,e.ins_h=e.window[s],e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x)if(n=u._tr_tally(e,e.strstart-e.match_start,e.match_length-x),e.lookahead-=e.match_length,e.match_length<=e.max_lazy_match&&e.lookahead>=x){for(e.match_length--;e.strstart++,e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x&&e.match_length<=e.prev_length){for(i=e.strstart+e.lookahead-x,n=u._tr_tally(e,e.strstart-1-e.prev_match,e.prev_length-x),e.lookahead-=e.prev_length-1,e.prev_length-=2;++e.strstart<=i&&(e.ins_h=(e.ins_h<e.pending_buf_size-5&&(r=e.pending_buf_size-5);;){if(e.lookahead<=1){if(j(e),0===e.lookahead&&t===l)return A;if(0===e.lookahead)break}e.strstart+=e.lookahead,e.lookahead=0;var n=e.block_start+r;if((0===e.strstart||e.strstart>=n)&&(e.lookahead=e.strstart-n,e.strstart=n,N(e,!1),0===e.strm.avail_out))return A;if(e.strstart-e.block_start>=e.w_size-z&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):(e.strstart>e.block_start&&(N(e,!1),e.strm.avail_out),A)}),new M(4,4,8,4,Z),new M(4,5,16,8,Z),new M(4,6,32,32,Z),new M(4,4,16,16,W),new M(8,16,32,32,W),new M(8,16,128,128,W),new M(8,32,128,256,W),new M(32,128,258,1024,W),new M(32,258,258,4096,W)],r.deflateInit=function(e,t){return Y(e,t,v,15,8,0)},r.deflateInit2=Y,r.deflateReset=K,r.deflateResetKeep=G,r.deflateSetHeader=function(e,t){return e&&e.state?2!==e.state.wrap?_:(e.state.gzhead=t,m):_},r.deflate=function(e,t){var r,n,i,s;if(!e||!e.state||5>8&255),U(n,n.gzhead.time>>16&255),U(n,n.gzhead.time>>24&255),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,255&n.gzhead.os),n.gzhead.extra&&n.gzhead.extra.length&&(U(n,255&n.gzhead.extra.length),U(n,n.gzhead.extra.length>>8&255)),n.gzhead.hcrc&&(e.adler=p(e.adler,n.pending_buf,n.pending,0)),n.gzindex=0,n.status=69):(U(n,0),U(n,0),U(n,0),U(n,0),U(n,0),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,3),n.status=E);else{var a=v+(n.w_bits-8<<4)<<8;a|=(2<=n.strategy||n.level<2?0:n.level<6?1:6===n.level?2:3)<<6,0!==n.strstart&&(a|=32),a+=31-a%31,n.status=E,P(n,a),0!==n.strstart&&(P(n,e.adler>>>16),P(n,65535&e.adler)),e.adler=1}if(69===n.status)if(n.gzhead.extra){for(i=n.pending;n.gzindex<(65535&n.gzhead.extra.length)&&(n.pending!==n.pending_buf_size||(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending!==n.pending_buf_size));)U(n,255&n.gzhead.extra[n.gzindex]),n.gzindex++;n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),n.gzindex===n.gzhead.extra.length&&(n.gzindex=0,n.status=73)}else n.status=73;if(73===n.status)if(n.gzhead.name){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.gzindex=0,n.status=91)}else n.status=91;if(91===n.status)if(n.gzhead.comment){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.status=103)}else n.status=103;if(103===n.status&&(n.gzhead.hcrc?(n.pending+2>n.pending_buf_size&&F(e),n.pending+2<=n.pending_buf_size&&(U(n,255&e.adler),U(n,e.adler>>8&255),e.adler=0,n.status=E)):n.status=E),0!==n.pending){if(F(e),0===e.avail_out)return n.last_flush=-1,m}else if(0===e.avail_in&&T(t)<=T(r)&&t!==f)return R(e,-5);if(666===n.status&&0!==e.avail_in)return R(e,-5);if(0!==e.avail_in||0!==n.lookahead||t!==l&&666!==n.status){var o=2===n.strategy?function(e,t){for(var r;;){if(0===e.lookahead&&(j(e),0===e.lookahead)){if(t===l)return A;break}if(e.match_length=0,r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++,r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):3===n.strategy?function(e,t){for(var r,n,i,s,a=e.window;;){if(e.lookahead<=S){if(j(e),e.lookahead<=S&&t===l)return A;if(0===e.lookahead)break}if(e.match_length=0,e.lookahead>=x&&0e.lookahead&&(e.match_length=e.lookahead)}if(e.match_length>=x?(r=u._tr_tally(e,1,e.match_length-x),e.lookahead-=e.match_length,e.strstart+=e.match_length,e.match_length=0):(r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++),r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):h[n.level].func(n,t);if(o!==O&&o!==B||(n.status=666),o===A||o===O)return 0===e.avail_out&&(n.last_flush=-1),m;if(o===I&&(1===t?u._tr_align(n):5!==t&&(u._tr_stored_block(n,0,0,!1),3===t&&(D(n.head),0===n.lookahead&&(n.strstart=0,n.block_start=0,n.insert=0))),F(e),0===e.avail_out))return n.last_flush=-1,m}return t!==f?m:n.wrap<=0?1:(2===n.wrap?(U(n,255&e.adler),U(n,e.adler>>8&255),U(n,e.adler>>16&255),U(n,e.adler>>24&255),U(n,255&e.total_in),U(n,e.total_in>>8&255),U(n,e.total_in>>16&255),U(n,e.total_in>>24&255)):(P(n,e.adler>>>16),P(n,65535&e.adler)),F(e),0=r.w_size&&(0===s&&(D(r.head),r.strstart=0,r.block_start=0,r.insert=0),u=new c.Buf8(r.w_size),c.arraySet(u,t,l-r.w_size,r.w_size,0),t=u,l=r.w_size),a=e.avail_in,o=e.next_in,h=e.input,e.avail_in=l,e.next_in=0,e.input=t,j(r);r.lookahead>=x;){for(n=r.strstart,i=r.lookahead-(x-1);r.ins_h=(r.ins_h<>>=y=v>>>24,p-=y,0===(y=v>>>16&255))C[s++]=65535&v;else{if(!(16&y)){if(0==(64&y)){v=m[(65535&v)+(d&(1<>>=y,p-=y),p<15&&(d+=z[n++]<>>=y=v>>>24,p-=y,!(16&(y=v>>>16&255))){if(0==(64&y)){v=_[(65535&v)+(d&(1<>>=y,p-=y,(y=s-a)>3,d&=(1<<(p-=w<<3))-1,e.next_in=n,e.next_out=s,e.avail_in=n>>24&255)+(e>>>8&65280)+((65280&e)<<8)+((255&e)<<24)}function s(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new I.Buf16(320),this.work=new I.Buf16(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}function a(e){var t;return e&&e.state?(t=e.state,e.total_in=e.total_out=t.total=0,e.msg="",t.wrap&&(e.adler=1&t.wrap),t.mode=P,t.last=0,t.havedict=0,t.dmax=32768,t.head=null,t.hold=0,t.bits=0,t.lencode=t.lendyn=new I.Buf32(n),t.distcode=t.distdyn=new I.Buf32(i),t.sane=1,t.back=-1,N):U}function o(e){var t;return e&&e.state?((t=e.state).wsize=0,t.whave=0,t.wnext=0,a(e)):U}function h(e,t){var r,n;return e&&e.state?(n=e.state,t<0?(r=0,t=-t):(r=1+(t>>4),t<48&&(t&=15)),t&&(t<8||15=s.wsize?(I.arraySet(s.window,t,r-s.wsize,s.wsize,0),s.wnext=0,s.whave=s.wsize):(n<(i=s.wsize-s.wnext)&&(i=n),I.arraySet(s.window,t,r-n,i,s.wnext),(n-=i)?(I.arraySet(s.window,t,r-n,n,0),s.wnext=n,s.whave=s.wsize):(s.wnext+=i,s.wnext===s.wsize&&(s.wnext=0),s.whave>>8&255,r.check=B(r.check,E,2,0),l=u=0,r.mode=2;break}if(r.flags=0,r.head&&(r.head.done=!1),!(1&r.wrap)||(((255&u)<<8)+(u>>8))%31){e.msg="incorrect header check",r.mode=30;break}if(8!=(15&u)){e.msg="unknown compression method",r.mode=30;break}if(l-=4,k=8+(15&(u>>>=4)),0===r.wbits)r.wbits=k;else if(k>r.wbits){e.msg="invalid window size",r.mode=30;break}r.dmax=1<>8&1),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=3;case 3:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>8&255,E[2]=u>>>16&255,E[3]=u>>>24&255,r.check=B(r.check,E,4,0)),l=u=0,r.mode=4;case 4:for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>8),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=5;case 5:if(1024&r.flags){for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>>8&255,r.check=B(r.check,E,2,0)),l=u=0}else r.head&&(r.head.extra=null);r.mode=6;case 6:if(1024&r.flags&&(o<(d=r.length)&&(d=o),d&&(r.head&&(k=r.head.extra_len-r.length,r.head.extra||(r.head.extra=new Array(r.head.extra_len)),I.arraySet(r.head.extra,n,s,d,k)),512&r.flags&&(r.check=B(r.check,n,d,s)),o-=d,s+=d,r.length-=d),r.length))break e;r.length=0,r.mode=7;case 7:if(2048&r.flags){if(0===o)break e;for(d=0;k=n[s+d++],r.head&&k&&r.length<65536&&(r.head.name+=String.fromCharCode(k)),k&&d>9&1,r.head.done=!0),e.adler=r.check=0,r.mode=12;break;case 10:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>=7&l,l-=7&l,r.mode=27;break}for(;l<3;){if(0===o)break e;o--,u+=n[s++]<>>=1)){case 0:r.mode=14;break;case 1:if(j(r),r.mode=20,6!==t)break;u>>>=2,l-=2;break e;case 2:r.mode=17;break;case 3:e.msg="invalid block type",r.mode=30}u>>>=2,l-=2;break;case 14:for(u>>>=7&l,l-=7&l;l<32;){if(0===o)break e;o--,u+=n[s++]<>>16^65535)){e.msg="invalid stored block lengths",r.mode=30;break}if(r.length=65535&u,l=u=0,r.mode=15,6===t)break e;case 15:r.mode=16;case 16:if(d=r.length){if(o>>=5,l-=5,r.ndist=1+(31&u),u>>>=5,l-=5,r.ncode=4+(15&u),u>>>=4,l-=4,286>>=3,l-=3}for(;r.have<19;)r.lens[A[r.have++]]=0;if(r.lencode=r.lendyn,r.lenbits=7,S={bits:r.lenbits},x=T(0,r.lens,0,19,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid code lengths set",r.mode=30;break}r.have=0,r.mode=19;case 19:for(;r.have>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=_,l-=_,r.lens[r.have++]=b;else{if(16===b){for(z=_+2;l>>=_,l-=_,0===r.have){e.msg="invalid bit length repeat",r.mode=30;break}k=r.lens[r.have-1],d=3+(3&u),u>>>=2,l-=2}else if(17===b){for(z=_+3;l>>=_)),u>>>=3,l-=3}else{for(z=_+7;l>>=_)),u>>>=7,l-=7}if(r.have+d>r.nlen+r.ndist){e.msg="invalid bit length repeat",r.mode=30;break}for(;d--;)r.lens[r.have++]=k}}if(30===r.mode)break;if(0===r.lens[256]){e.msg="invalid code -- missing end-of-block",r.mode=30;break}if(r.lenbits=9,S={bits:r.lenbits},x=T(D,r.lens,0,r.nlen,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid literal/lengths set",r.mode=30;break}if(r.distbits=6,r.distcode=r.distdyn,S={bits:r.distbits},x=T(F,r.lens,r.nlen,r.ndist,r.distcode,0,r.work,S),r.distbits=S.bits,x){e.msg="invalid distances set",r.mode=30;break}if(r.mode=20,6===t)break e;case 20:r.mode=21;case 21:if(6<=o&&258<=h){e.next_out=a,e.avail_out=h,e.next_in=s,e.avail_in=o,r.hold=u,r.bits=l,R(e,c),a=e.next_out,i=e.output,h=e.avail_out,s=e.next_in,n=e.input,o=e.avail_in,u=r.hold,l=r.bits,12===r.mode&&(r.back=-1);break}for(r.back=0;g=(C=r.lencode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,r.length=b,0===g){r.mode=26;break}if(32&g){r.back=-1,r.mode=12;break}if(64&g){e.msg="invalid literal/length code",r.mode=30;break}r.extra=15&g,r.mode=22;case 22:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}r.was=r.length,r.mode=23;case 23:for(;g=(C=r.distcode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,64&g){e.msg="invalid distance code",r.mode=30;break}r.offset=b,r.extra=15&g,r.mode=24;case 24:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}if(r.offset>r.dmax){e.msg="invalid distance too far back",r.mode=30;break}r.mode=25;case 25:if(0===h)break e;if(d=c-h,r.offset>d){if((d=r.offset-d)>r.whave&&r.sane){e.msg="invalid distance too far back",r.mode=30;break}p=d>r.wnext?(d-=r.wnext,r.wsize-d):r.wnext-d,d>r.length&&(d=r.length),m=r.window}else m=i,p=a-r.offset,d=r.length;for(hd?(m=R[T+a[v]],A[I+a[v]]):(m=96,0),h=1<>S)+(u-=h)]=p<<24|m<<16|_|0,0!==u;);for(h=1<>=1;if(0!==h?(E&=h-1,E+=h):E=0,v++,0==--O[b]){if(b===w)break;b=t[r+a[v]]}if(k>>7)]}function U(e,t){e.pending_buf[e.pending++]=255&t,e.pending_buf[e.pending++]=t>>>8&255}function P(e,t,r){e.bi_valid>d-r?(e.bi_buf|=t<>d-e.bi_valid,e.bi_valid+=r-d):(e.bi_buf|=t<>>=1,r<<=1,0<--t;);return r>>>1}function Z(e,t,r){var n,i,s=new Array(g+1),a=0;for(n=1;n<=g;n++)s[n]=a=a+r[n-1]<<1;for(i=0;i<=t;i++){var o=e[2*i+1];0!==o&&(e[2*i]=j(s[o]++,o))}}function W(e){var t;for(t=0;t>1;1<=r;r--)G(e,s,r);for(i=h;r=e.heap[1],e.heap[1]=e.heap[e.heap_len--],G(e,s,1),n=e.heap[1],e.heap[--e.heap_max]=r,e.heap[--e.heap_max]=n,s[2*i]=s[2*r]+s[2*n],e.depth[i]=(e.depth[r]>=e.depth[n]?e.depth[r]:e.depth[n])+1,s[2*r+1]=s[2*n+1]=i,e.heap[1]=i++,G(e,s,1),2<=e.heap_len;);e.heap[--e.heap_max]=e.heap[1],function(e,t){var r,n,i,s,a,o,h=t.dyn_tree,u=t.max_code,l=t.stat_desc.static_tree,f=t.stat_desc.has_stree,c=t.stat_desc.extra_bits,d=t.stat_desc.extra_base,p=t.stat_desc.max_length,m=0;for(s=0;s<=g;s++)e.bl_count[s]=0;for(h[2*e.heap[e.heap_max]+1]=0,r=e.heap_max+1;r<_;r++)p<(s=h[2*h[2*(n=e.heap[r])+1]+1]+1)&&(s=p,m++),h[2*n+1]=s,u>=7;n>>=1)if(1&r&&0!==e.dyn_ltree[2*t])return o;if(0!==e.dyn_ltree[18]||0!==e.dyn_ltree[20]||0!==e.dyn_ltree[26])return h;for(t=32;t>>3,(s=e.static_len+3+7>>>3)<=i&&(i=s)):i=s=r+5,r+4<=i&&-1!==t?J(e,t,r,n):4===e.strategy||s===i?(P(e,2+(n?1:0),3),K(e,z,C)):(P(e,4+(n?1:0),3),function(e,t,r,n){var i;for(P(e,t-257,5),P(e,r-1,5),P(e,n-4,4),i=0;i>>8&255,e.pending_buf[e.d_buf+2*e.last_lit+1]=255&t,e.pending_buf[e.l_buf+e.last_lit]=255&r,e.last_lit++,0===t?e.dyn_ltree[2*r]++:(e.matches++,t--,e.dyn_ltree[2*(A[r]+u+1)]++,e.dyn_dtree[2*N(t)]++),e.last_lit===e.lit_bufsize-1},r._tr_align=function(e){P(e,2,3),L(e,m,z),function(e){16===e.bi_valid?(U(e,e.bi_buf),e.bi_buf=0,e.bi_valid=0):8<=e.bi_valid&&(e.pending_buf[e.pending++]=255&e.bi_buf,e.bi_buf>>=8,e.bi_valid-=8)}(e)}},{"../utils/common":41}],53:[function(e,t,r){"use strict";t.exports=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0}},{}],54:[function(e,t,r){(function(e){!function(r,n){"use strict";if(!r.setImmediate){var i,s,t,a,o=1,h={},u=!1,l=r.document,e=Object.getPrototypeOf&&Object.getPrototypeOf(r);e=e&&e.setTimeout?e:r,i="[object process]"==={}.toString.call(r.process)?function(e){process.nextTick(function(){c(e)})}:function(){if(r.postMessage&&!r.importScripts){var e=!0,t=r.onmessage;return r.onmessage=function(){e=!1},r.postMessage("","*"),r.onmessage=t,e}}()?(a="setImmediate$"+Math.random()+"$",r.addEventListener?r.addEventListener("message",d,!1):r.attachEvent("onmessage",d),function(e){r.postMessage(a+e,"*")}):r.MessageChannel?((t=new MessageChannel).port1.onmessage=function(e){c(e.data)},function(e){t.port2.postMessage(e)}):l&&"onreadystatechange"in l.createElement("script")?(s=l.documentElement,function(e){var t=l.createElement("script");t.onreadystatechange=function(){c(e),t.onreadystatechange=null,s.removeChild(t),t=null},s.appendChild(t)}):function(e){setTimeout(c,0,e)},e.setImmediate=function(e){"function"!=typeof e&&(e=new Function(""+e));for(var t=new Array(arguments.length-1),r=0;r + +(c) 2009-2016 Stuart Knightley +Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown. + +JSZip uses the library pako released under the MIT license : +https://github.com/nodeca/pako/blob/main/LICENSE +*/ + +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,h){function u(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(l)return l(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return u(t||e)},i,i.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,e=0;e>2,s=(3&t)<<4|r>>4,a=1>6:64,o=2>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),l[h++]=t,64!==s&&(l[h++]=r),64!==a&&(l[h++]=n);return l}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils");var o=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}(0|t,e,e.length,0):function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t.charCodeAt(a))];return-1^e}(0|t,e,e.length,0):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n=null;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function h(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(h,a),h.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=function(e){t.push({data:e,meta:t.meta})}},r.compressWorker=function(e){return new h("Deflate",e)},r.uncompressWorker=function(){return new h("Inflate",{})}},{"./stream/GenericWorker":28,"./utils":32,pako:38}],8:[function(e,t,r){"use strict";function A(e,t){var r,n="";for(r=0;r>>=8;return n}function n(e,t,r,n,i,s){var a,o,h=e.file,u=e.compression,l=s!==O.utf8encode,f=I.transformTo("string",s(h.name)),c=I.transformTo("string",O.utf8encode(h.name)),d=h.comment,p=I.transformTo("string",s(d)),m=I.transformTo("string",O.utf8encode(d)),_=c.length!==h.name.length,g=m.length!==d.length,b="",v="",y="",w=h.dir,k=h.date,x={crc32:0,compressedSize:0,uncompressedSize:0};t&&!r||(x.crc32=e.crc32,x.compressedSize=e.compressedSize,x.uncompressedSize=e.uncompressedSize);var S=0;t&&(S|=8),l||!_&&!g||(S|=2048);var z=0,C=0;w&&(z|=16),"UNIX"===i?(C=798,z|=function(e,t){var r=e;return e||(r=t?16893:33204),(65535&r)<<16}(h.unixPermissions,w)):(C=20,z|=function(e){return 63&(e||0)}(h.dosPermissions)),a=k.getUTCHours(),a<<=6,a|=k.getUTCMinutes(),a<<=5,a|=k.getUTCSeconds()/2,o=k.getUTCFullYear()-1980,o<<=4,o|=k.getUTCMonth()+1,o<<=5,o|=k.getUTCDate(),_&&(v=A(1,1)+A(B(f),4)+c,b+="up"+A(v.length,2)+v),g&&(y=A(1,1)+A(B(p),4)+m,b+="uc"+A(y.length,2)+y);var E="";return E+="\n\0",E+=A(S,2),E+=u.magic,E+=A(a,2),E+=A(o,2),E+=A(x.crc32,4),E+=A(x.compressedSize,4),E+=A(x.uncompressedSize,4),E+=A(f.length,2),E+=A(b.length,2),{fileRecord:R.LOCAL_FILE_HEADER+E+f+b,dirRecord:R.CENTRAL_FILE_HEADER+A(C,2)+E+A(p.length,2)+"\0\0\0\0"+A(z,4)+A(n,4)+f+b+p}}var I=e("../utils"),i=e("../stream/GenericWorker"),O=e("../utf8"),B=e("../crc32"),R=e("../signature");function s(e,t,r,n){i.call(this,"ZipFileWorker"),this.bytesWritten=0,this.zipComment=t,this.zipPlatform=r,this.encodeFileName=n,this.streamFiles=e,this.accumulate=!1,this.contentBuffer=[],this.dirRecords=[],this.currentSourceOffset=0,this.entriesCount=0,this.currentFile=null,this._sources=[]}I.inherits(s,i),s.prototype.push=function(e){var t=e.meta.percent||0,r=this.entriesCount,n=this._sources.length;this.accumulate?this.contentBuffer.push(e):(this.bytesWritten+=e.data.length,i.prototype.push.call(this,{data:e.data,meta:{currentFile:this.currentFile,percent:r?(t+100*(r-n-1))/r:100}}))},s.prototype.openedSource=function(e){this.currentSourceOffset=this.bytesWritten,this.currentFile=e.file.name;var t=this.streamFiles&&!e.file.dir;if(t){var r=n(e,t,!1,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);this.push({data:r.fileRecord,meta:{percent:0}})}else this.accumulate=!0},s.prototype.closedSource=function(e){this.accumulate=!1;var t=this.streamFiles&&!e.file.dir,r=n(e,t,!0,this.currentSourceOffset,this.zipPlatform,this.encodeFileName);if(this.dirRecords.push(r.dirRecord),t)this.push({data:function(e){return R.DATA_DESCRIPTOR+A(e.crc32,4)+A(e.compressedSize,4)+A(e.uncompressedSize,4)}(e),meta:{percent:100}});else for(this.push({data:r.fileRecord,meta:{percent:0}});this.contentBuffer.length;)this.push(this.contentBuffer.shift());this.currentFile=null},s.prototype.flush=function(){for(var e=this.bytesWritten,t=0;t=this.index;t--)r=(r<<8)+this.byteAt(t);return this.index+=e,r},readString:function(e){return n.transformTo("string",this.readData(e))},readData:function(){},lastIndexOfSignature:function(){},readAndCheckSignature:function(){},readDate:function(){var e=this.readInt(4);return new Date(Date.UTC(1980+(e>>25&127),(e>>21&15)-1,e>>16&31,e>>11&31,e>>5&63,(31&e)<<1))}},t.exports=i},{"../utils":32}],19:[function(e,t,r){"use strict";var n=e("./Uint8ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./Uint8ArrayReader":21}],20:[function(e,t,r){"use strict";var n=e("./DataReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.byteAt=function(e){return this.data.charCodeAt(this.zero+e)},i.prototype.lastIndexOfSignature=function(e){return this.data.lastIndexOf(e)-this.zero},i.prototype.readAndCheckSignature=function(e){return e===this.readData(4)},i.prototype.readData=function(e){this.checkOffset(e);var t=this.data.slice(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./DataReader":18}],21:[function(e,t,r){"use strict";var n=e("./ArrayReader");function i(e){n.call(this,e)}e("../utils").inherits(i,n),i.prototype.readData=function(e){if(this.checkOffset(e),0===e)return new Uint8Array(0);var t=this.data.subarray(this.zero+this.index,this.zero+this.index+e);return this.index+=e,t},t.exports=i},{"../utils":32,"./ArrayReader":17}],22:[function(e,t,r){"use strict";var n=e("../utils"),i=e("../support"),s=e("./ArrayReader"),a=e("./StringReader"),o=e("./NodeBufferReader"),h=e("./Uint8ArrayReader");t.exports=function(e){var t=n.getTypeOf(e);return n.checkSupport(t),"string"!==t||i.uint8array?"nodebuffer"===t?new o(e):i.uint8array?new h(n.transformTo("uint8array",e)):new s(n.transformTo("array",e)):new a(e)}},{"../support":30,"../utils":32,"./ArrayReader":17,"./NodeBufferReader":19,"./StringReader":20,"./Uint8ArrayReader":21}],23:[function(e,t,r){"use strict";r.LOCAL_FILE_HEADER="PK",r.CENTRAL_FILE_HEADER="PK",r.CENTRAL_DIRECTORY_END="PK",r.ZIP64_CENTRAL_DIRECTORY_LOCATOR="PK",r.ZIP64_CENTRAL_DIRECTORY_END="PK",r.DATA_DESCRIPTOR="PK\b"},{}],24:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../utils");function s(e){n.call(this,"ConvertWorker to "+e),this.destType=e}i.inherits(s,n),s.prototype.processChunk=function(e){this.push({data:i.transformTo(this.destType,e.data),meta:e.meta})},t.exports=s},{"../utils":32,"./GenericWorker":28}],25:[function(e,t,r){"use strict";var n=e("./GenericWorker"),i=e("../crc32");function s(){n.call(this,"Crc32Probe"),this.withStreamInfo("crc32",0)}e("../utils").inherits(s,n),s.prototype.processChunk=function(e){this.streamInfo.crc32=i(e.data,this.streamInfo.crc32||0),this.push(e)},t.exports=s},{"../crc32":4,"../utils":32,"./GenericWorker":28}],26:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataLengthProbe for "+e),this.propName=e,this.withStreamInfo(e,0)}n.inherits(s,i),s.prototype.processChunk=function(e){if(e){var t=this.streamInfo[this.propName]||0;this.streamInfo[this.propName]=t+e.data.length}i.prototype.processChunk.call(this,e)},t.exports=s},{"../utils":32,"./GenericWorker":28}],27:[function(e,t,r){"use strict";var n=e("../utils"),i=e("./GenericWorker");function s(e){i.call(this,"DataWorker");var t=this;this.dataIsReady=!1,this.index=0,this.max=0,this.data=null,this.type="",this._tickScheduled=!1,e.then(function(e){t.dataIsReady=!0,t.data=e,t.max=e&&e.length||0,t.type=n.getTypeOf(e),t.isPaused||t._tickAndRepeat()},function(e){t.error(e)})}n.inherits(s,i),s.prototype.cleanUp=function(){i.prototype.cleanUp.call(this),this.data=null},s.prototype.resume=function(){return!!i.prototype.resume.call(this)&&(!this._tickScheduled&&this.dataIsReady&&(this._tickScheduled=!0,n.delay(this._tickAndRepeat,[],this)),!0)},s.prototype._tickAndRepeat=function(){this._tickScheduled=!1,this.isPaused||this.isFinished||(this._tick(),this.isFinished||(n.delay(this._tickAndRepeat,[],this),this._tickScheduled=!0))},s.prototype._tick=function(){if(this.isPaused||this.isFinished)return!1;var e=null,t=Math.min(this.max,this.index+16384);if(this.index>=this.max)return this.end();switch(this.type){case"string":e=this.data.substring(this.index,t);break;case"uint8array":e=this.data.subarray(this.index,t);break;case"array":case"nodebuffer":e=this.data.slice(this.index,t)}return this.index=t,this.push({data:e,meta:{percent:this.max?this.index/this.max*100:0}})},t.exports=s},{"../utils":32,"./GenericWorker":28}],28:[function(e,t,r){"use strict";function n(e){this.name=e||"default",this.streamInfo={},this.generatedError=null,this.extraStreamInfo={},this.isPaused=!0,this.isFinished=!1,this.isLocked=!1,this._listeners={data:[],end:[],error:[]},this.previous=null}n.prototype={push:function(e){this.emit("data",e)},end:function(){if(this.isFinished)return!1;this.flush();try{this.emit("end"),this.cleanUp(),this.isFinished=!0}catch(e){this.emit("error",e)}return!0},error:function(e){return!this.isFinished&&(this.isPaused?this.generatedError=e:(this.isFinished=!0,this.emit("error",e),this.previous&&this.previous.error(e),this.cleanUp()),!0)},on:function(e,t){return this._listeners[e].push(t),this},cleanUp:function(){this.streamInfo=this.generatedError=this.extraStreamInfo=null,this._listeners=[]},emit:function(e,t){if(this._listeners[e])for(var r=0;r "+e:e}},t.exports=n},{}],29:[function(e,t,r){"use strict";var h=e("../utils"),i=e("./ConvertWorker"),s=e("./GenericWorker"),u=e("../base64"),n=e("../support"),a=e("../external"),o=null;if(n.nodestream)try{o=e("../nodejs/NodejsStreamOutputAdapter")}catch(e){}function l(e,o){return new a.Promise(function(t,r){var n=[],i=e._internalType,s=e._outputType,a=e._mimeType;e.on("data",function(e,t){n.push(e),o&&o(t)}).on("error",function(e){n=[],r(e)}).on("end",function(){try{var e=function(e,t,r){switch(e){case"blob":return h.newBlob(h.transformTo("arraybuffer",t),r);case"base64":return u.encode(t);default:return h.transformTo(e,t)}}(s,function(e,t){var r,n=0,i=null,s=0;for(r=0;r>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t}(e)},s.utf8decode=function(e){return h.nodebuffer?o.transformTo("nodebuffer",e).toString("utf-8"):function(e){var t,r,n,i,s=e.length,a=new Array(2*s);for(t=r=0;t>10&1023,a[r++]=56320|1023&n)}return a.length!==r&&(a.subarray?a=a.subarray(0,r):a.length=r),o.applyFromCharCode(a)}(e=o.transformTo(h.uint8array?"uint8array":"array",e))},o.inherits(a,n),a.prototype.processChunk=function(e){var t=o.transformTo(h.uint8array?"uint8array":"array",e.data);if(this.leftOver&&this.leftOver.length){if(h.uint8array){var r=t;(t=new Uint8Array(r.length+this.leftOver.length)).set(this.leftOver,0),t.set(r,this.leftOver.length)}else t=this.leftOver.concat(t);this.leftOver=null}var n=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}(t),i=t;n!==t.length&&(h.uint8array?(i=t.subarray(0,n),this.leftOver=t.subarray(n,t.length)):(i=t.slice(0,n),this.leftOver=t.slice(n,t.length))),this.push({data:s.utf8decode(i),meta:e.meta})},a.prototype.flush=function(){this.leftOver&&this.leftOver.length&&(this.push({data:s.utf8decode(this.leftOver),meta:{}}),this.leftOver=null)},s.Utf8DecodeWorker=a,o.inherits(l,n),l.prototype.processChunk=function(e){this.push({data:s.utf8encode(e.data),meta:e.meta})},s.Utf8EncodeWorker=l},{"./nodejsUtils":14,"./stream/GenericWorker":28,"./support":30,"./utils":32}],32:[function(e,t,a){"use strict";var o=e("./support"),h=e("./base64"),r=e("./nodejsUtils"),u=e("./external");function n(e){return e}function l(e,t){for(var r=0;r>8;this.dir=!!(16&this.externalFileAttributes),0==e&&(this.dosPermissions=63&this.externalFileAttributes),3==e&&(this.unixPermissions=this.externalFileAttributes>>16&65535),this.dir||"/"!==this.fileNameStr.slice(-1)||(this.dir=!0)},parseZIP64ExtraField:function(){if(this.extraFields[1]){var e=n(this.extraFields[1].value);this.uncompressedSize===s.MAX_VALUE_32BITS&&(this.uncompressedSize=e.readInt(8)),this.compressedSize===s.MAX_VALUE_32BITS&&(this.compressedSize=e.readInt(8)),this.localHeaderOffset===s.MAX_VALUE_32BITS&&(this.localHeaderOffset=e.readInt(8)),this.diskNumberStart===s.MAX_VALUE_32BITS&&(this.diskNumberStart=e.readInt(4))}},readExtraFields:function(e){var t,r,n,i=e.index+this.extraFieldsLength;for(this.extraFields||(this.extraFields={});e.index+4>>6:(r<65536?t[s++]=224|r>>>12:(t[s++]=240|r>>>18,t[s++]=128|r>>>12&63),t[s++]=128|r>>>6&63),t[s++]=128|63&r);return t},r.buf2binstring=function(e){return l(e,e.length)},r.binstring2buf=function(e){for(var t=new h.Buf8(e.length),r=0,n=t.length;r>10&1023,o[n++]=56320|1023&i)}return l(o,n)},r.utf8border=function(e,t){var r;for((t=t||e.length)>e.length&&(t=e.length),r=t-1;0<=r&&128==(192&e[r]);)r--;return r<0?t:0===r?t:r+u[e[r]]>t?r:t}},{"./common":41}],43:[function(e,t,r){"use strict";t.exports=function(e,t,r,n){for(var i=65535&e|0,s=e>>>16&65535|0,a=0;0!==r;){for(r-=a=2e3>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a>>8^i[255&(e^t[a])];return-1^e}},{}],46:[function(e,t,r){"use strict";var h,c=e("../utils/common"),u=e("./trees"),d=e("./adler32"),p=e("./crc32"),n=e("./messages"),l=0,f=4,m=0,_=-2,g=-1,b=4,i=2,v=8,y=9,s=286,a=30,o=19,w=2*s+1,k=15,x=3,S=258,z=S+x+1,C=42,E=113,A=1,I=2,O=3,B=4;function R(e,t){return e.msg=n[t],t}function T(e){return(e<<1)-(4e.avail_out&&(r=e.avail_out),0!==r&&(c.arraySet(e.output,t.pending_buf,t.pending_out,r,e.next_out),e.next_out+=r,t.pending_out+=r,e.total_out+=r,e.avail_out-=r,t.pending-=r,0===t.pending&&(t.pending_out=0))}function N(e,t){u._tr_flush_block(e,0<=e.block_start?e.block_start:-1,e.strstart-e.block_start,t),e.block_start=e.strstart,F(e.strm)}function U(e,t){e.pending_buf[e.pending++]=t}function P(e,t){e.pending_buf[e.pending++]=t>>>8&255,e.pending_buf[e.pending++]=255&t}function L(e,t){var r,n,i=e.max_chain_length,s=e.strstart,a=e.prev_length,o=e.nice_match,h=e.strstart>e.w_size-z?e.strstart-(e.w_size-z):0,u=e.window,l=e.w_mask,f=e.prev,c=e.strstart+S,d=u[s+a-1],p=u[s+a];e.prev_length>=e.good_match&&(i>>=2),o>e.lookahead&&(o=e.lookahead);do{if(u[(r=t)+a]===p&&u[r+a-1]===d&&u[r]===u[s]&&u[++r]===u[s+1]){s+=2,r++;do{}while(u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&sh&&0!=--i);return a<=e.lookahead?a:e.lookahead}function j(e){var t,r,n,i,s,a,o,h,u,l,f=e.w_size;do{if(i=e.window_size-e.lookahead-e.strstart,e.strstart>=f+(f-z)){for(c.arraySet(e.window,e.window,f,f,0),e.match_start-=f,e.strstart-=f,e.block_start-=f,t=r=e.hash_size;n=e.head[--t],e.head[t]=f<=n?n-f:0,--r;);for(t=r=f;n=e.prev[--t],e.prev[t]=f<=n?n-f:0,--r;);i+=f}if(0===e.strm.avail_in)break;if(a=e.strm,o=e.window,h=e.strstart+e.lookahead,u=i,l=void 0,l=a.avail_in,u=x)for(s=e.strstart-e.insert,e.ins_h=e.window[s],e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x)if(n=u._tr_tally(e,e.strstart-e.match_start,e.match_length-x),e.lookahead-=e.match_length,e.match_length<=e.max_lazy_match&&e.lookahead>=x){for(e.match_length--;e.strstart++,e.ins_h=(e.ins_h<=x&&(e.ins_h=(e.ins_h<=x&&e.match_length<=e.prev_length){for(i=e.strstart+e.lookahead-x,n=u._tr_tally(e,e.strstart-1-e.prev_match,e.prev_length-x),e.lookahead-=e.prev_length-1,e.prev_length-=2;++e.strstart<=i&&(e.ins_h=(e.ins_h<e.pending_buf_size-5&&(r=e.pending_buf_size-5);;){if(e.lookahead<=1){if(j(e),0===e.lookahead&&t===l)return A;if(0===e.lookahead)break}e.strstart+=e.lookahead,e.lookahead=0;var n=e.block_start+r;if((0===e.strstart||e.strstart>=n)&&(e.lookahead=e.strstart-n,e.strstart=n,N(e,!1),0===e.strm.avail_out))return A;if(e.strstart-e.block_start>=e.w_size-z&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):(e.strstart>e.block_start&&(N(e,!1),e.strm.avail_out),A)}),new M(4,4,8,4,Z),new M(4,5,16,8,Z),new M(4,6,32,32,Z),new M(4,4,16,16,W),new M(8,16,32,32,W),new M(8,16,128,128,W),new M(8,32,128,256,W),new M(32,128,258,1024,W),new M(32,258,258,4096,W)],r.deflateInit=function(e,t){return Y(e,t,v,15,8,0)},r.deflateInit2=Y,r.deflateReset=K,r.deflateResetKeep=G,r.deflateSetHeader=function(e,t){return e&&e.state?2!==e.state.wrap?_:(e.state.gzhead=t,m):_},r.deflate=function(e,t){var r,n,i,s;if(!e||!e.state||5>8&255),U(n,n.gzhead.time>>16&255),U(n,n.gzhead.time>>24&255),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,255&n.gzhead.os),n.gzhead.extra&&n.gzhead.extra.length&&(U(n,255&n.gzhead.extra.length),U(n,n.gzhead.extra.length>>8&255)),n.gzhead.hcrc&&(e.adler=p(e.adler,n.pending_buf,n.pending,0)),n.gzindex=0,n.status=69):(U(n,0),U(n,0),U(n,0),U(n,0),U(n,0),U(n,9===n.level?2:2<=n.strategy||n.level<2?4:0),U(n,3),n.status=E);else{var a=v+(n.w_bits-8<<4)<<8;a|=(2<=n.strategy||n.level<2?0:n.level<6?1:6===n.level?2:3)<<6,0!==n.strstart&&(a|=32),a+=31-a%31,n.status=E,P(n,a),0!==n.strstart&&(P(n,e.adler>>>16),P(n,65535&e.adler)),e.adler=1}if(69===n.status)if(n.gzhead.extra){for(i=n.pending;n.gzindex<(65535&n.gzhead.extra.length)&&(n.pending!==n.pending_buf_size||(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending!==n.pending_buf_size));)U(n,255&n.gzhead.extra[n.gzindex]),n.gzindex++;n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),n.gzindex===n.gzhead.extra.length&&(n.gzindex=0,n.status=73)}else n.status=73;if(73===n.status)if(n.gzhead.name){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.gzindex=0,n.status=91)}else n.status=91;if(91===n.status)if(n.gzhead.comment){i=n.pending;do{if(n.pending===n.pending_buf_size&&(n.gzhead.hcrc&&n.pending>i&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),F(e),i=n.pending,n.pending===n.pending_buf_size)){s=1;break}s=n.gzindexi&&(e.adler=p(e.adler,n.pending_buf,n.pending-i,i)),0===s&&(n.status=103)}else n.status=103;if(103===n.status&&(n.gzhead.hcrc?(n.pending+2>n.pending_buf_size&&F(e),n.pending+2<=n.pending_buf_size&&(U(n,255&e.adler),U(n,e.adler>>8&255),e.adler=0,n.status=E)):n.status=E),0!==n.pending){if(F(e),0===e.avail_out)return n.last_flush=-1,m}else if(0===e.avail_in&&T(t)<=T(r)&&t!==f)return R(e,-5);if(666===n.status&&0!==e.avail_in)return R(e,-5);if(0!==e.avail_in||0!==n.lookahead||t!==l&&666!==n.status){var o=2===n.strategy?function(e,t){for(var r;;){if(0===e.lookahead&&(j(e),0===e.lookahead)){if(t===l)return A;break}if(e.match_length=0,r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++,r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):3===n.strategy?function(e,t){for(var r,n,i,s,a=e.window;;){if(e.lookahead<=S){if(j(e),e.lookahead<=S&&t===l)return A;if(0===e.lookahead)break}if(e.match_length=0,e.lookahead>=x&&0e.lookahead&&(e.match_length=e.lookahead)}if(e.match_length>=x?(r=u._tr_tally(e,1,e.match_length-x),e.lookahead-=e.match_length,e.strstart+=e.match_length,e.match_length=0):(r=u._tr_tally(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++),r&&(N(e,!1),0===e.strm.avail_out))return A}return e.insert=0,t===f?(N(e,!0),0===e.strm.avail_out?O:B):e.last_lit&&(N(e,!1),0===e.strm.avail_out)?A:I}(n,t):h[n.level].func(n,t);if(o!==O&&o!==B||(n.status=666),o===A||o===O)return 0===e.avail_out&&(n.last_flush=-1),m;if(o===I&&(1===t?u._tr_align(n):5!==t&&(u._tr_stored_block(n,0,0,!1),3===t&&(D(n.head),0===n.lookahead&&(n.strstart=0,n.block_start=0,n.insert=0))),F(e),0===e.avail_out))return n.last_flush=-1,m}return t!==f?m:n.wrap<=0?1:(2===n.wrap?(U(n,255&e.adler),U(n,e.adler>>8&255),U(n,e.adler>>16&255),U(n,e.adler>>24&255),U(n,255&e.total_in),U(n,e.total_in>>8&255),U(n,e.total_in>>16&255),U(n,e.total_in>>24&255)):(P(n,e.adler>>>16),P(n,65535&e.adler)),F(e),0=r.w_size&&(0===s&&(D(r.head),r.strstart=0,r.block_start=0,r.insert=0),u=new c.Buf8(r.w_size),c.arraySet(u,t,l-r.w_size,r.w_size,0),t=u,l=r.w_size),a=e.avail_in,o=e.next_in,h=e.input,e.avail_in=l,e.next_in=0,e.input=t,j(r);r.lookahead>=x;){for(n=r.strstart,i=r.lookahead-(x-1);r.ins_h=(r.ins_h<>>=y=v>>>24,p-=y,0===(y=v>>>16&255))C[s++]=65535&v;else{if(!(16&y)){if(0==(64&y)){v=m[(65535&v)+(d&(1<>>=y,p-=y),p<15&&(d+=z[n++]<>>=y=v>>>24,p-=y,!(16&(y=v>>>16&255))){if(0==(64&y)){v=_[(65535&v)+(d&(1<>>=y,p-=y,(y=s-a)>3,d&=(1<<(p-=w<<3))-1,e.next_in=n,e.next_out=s,e.avail_in=n>>24&255)+(e>>>8&65280)+((65280&e)<<8)+((255&e)<<24)}function s(){this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new I.Buf16(320),this.work=new I.Buf16(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}function a(e){var t;return e&&e.state?(t=e.state,e.total_in=e.total_out=t.total=0,e.msg="",t.wrap&&(e.adler=1&t.wrap),t.mode=P,t.last=0,t.havedict=0,t.dmax=32768,t.head=null,t.hold=0,t.bits=0,t.lencode=t.lendyn=new I.Buf32(n),t.distcode=t.distdyn=new I.Buf32(i),t.sane=1,t.back=-1,N):U}function o(e){var t;return e&&e.state?((t=e.state).wsize=0,t.whave=0,t.wnext=0,a(e)):U}function h(e,t){var r,n;return e&&e.state?(n=e.state,t<0?(r=0,t=-t):(r=1+(t>>4),t<48&&(t&=15)),t&&(t<8||15=s.wsize?(I.arraySet(s.window,t,r-s.wsize,s.wsize,0),s.wnext=0,s.whave=s.wsize):(n<(i=s.wsize-s.wnext)&&(i=n),I.arraySet(s.window,t,r-n,i,s.wnext),(n-=i)?(I.arraySet(s.window,t,r-n,n,0),s.wnext=n,s.whave=s.wsize):(s.wnext+=i,s.wnext===s.wsize&&(s.wnext=0),s.whave>>8&255,r.check=B(r.check,E,2,0),l=u=0,r.mode=2;break}if(r.flags=0,r.head&&(r.head.done=!1),!(1&r.wrap)||(((255&u)<<8)+(u>>8))%31){e.msg="incorrect header check",r.mode=30;break}if(8!=(15&u)){e.msg="unknown compression method",r.mode=30;break}if(l-=4,k=8+(15&(u>>>=4)),0===r.wbits)r.wbits=k;else if(k>r.wbits){e.msg="invalid window size",r.mode=30;break}r.dmax=1<>8&1),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=3;case 3:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>8&255,E[2]=u>>>16&255,E[3]=u>>>24&255,r.check=B(r.check,E,4,0)),l=u=0,r.mode=4;case 4:for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>8),512&r.flags&&(E[0]=255&u,E[1]=u>>>8&255,r.check=B(r.check,E,2,0)),l=u=0,r.mode=5;case 5:if(1024&r.flags){for(;l<16;){if(0===o)break e;o--,u+=n[s++]<>>8&255,r.check=B(r.check,E,2,0)),l=u=0}else r.head&&(r.head.extra=null);r.mode=6;case 6:if(1024&r.flags&&(o<(d=r.length)&&(d=o),d&&(r.head&&(k=r.head.extra_len-r.length,r.head.extra||(r.head.extra=new Array(r.head.extra_len)),I.arraySet(r.head.extra,n,s,d,k)),512&r.flags&&(r.check=B(r.check,n,d,s)),o-=d,s+=d,r.length-=d),r.length))break e;r.length=0,r.mode=7;case 7:if(2048&r.flags){if(0===o)break e;for(d=0;k=n[s+d++],r.head&&k&&r.length<65536&&(r.head.name+=String.fromCharCode(k)),k&&d>9&1,r.head.done=!0),e.adler=r.check=0,r.mode=12;break;case 10:for(;l<32;){if(0===o)break e;o--,u+=n[s++]<>>=7&l,l-=7&l,r.mode=27;break}for(;l<3;){if(0===o)break e;o--,u+=n[s++]<>>=1)){case 0:r.mode=14;break;case 1:if(j(r),r.mode=20,6!==t)break;u>>>=2,l-=2;break e;case 2:r.mode=17;break;case 3:e.msg="invalid block type",r.mode=30}u>>>=2,l-=2;break;case 14:for(u>>>=7&l,l-=7&l;l<32;){if(0===o)break e;o--,u+=n[s++]<>>16^65535)){e.msg="invalid stored block lengths",r.mode=30;break}if(r.length=65535&u,l=u=0,r.mode=15,6===t)break e;case 15:r.mode=16;case 16:if(d=r.length){if(o>>=5,l-=5,r.ndist=1+(31&u),u>>>=5,l-=5,r.ncode=4+(15&u),u>>>=4,l-=4,286>>=3,l-=3}for(;r.have<19;)r.lens[A[r.have++]]=0;if(r.lencode=r.lendyn,r.lenbits=7,S={bits:r.lenbits},x=T(0,r.lens,0,19,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid code lengths set",r.mode=30;break}r.have=0,r.mode=19;case 19:for(;r.have>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=_,l-=_,r.lens[r.have++]=b;else{if(16===b){for(z=_+2;l>>=_,l-=_,0===r.have){e.msg="invalid bit length repeat",r.mode=30;break}k=r.lens[r.have-1],d=3+(3&u),u>>>=2,l-=2}else if(17===b){for(z=_+3;l>>=_)),u>>>=3,l-=3}else{for(z=_+7;l>>=_)),u>>>=7,l-=7}if(r.have+d>r.nlen+r.ndist){e.msg="invalid bit length repeat",r.mode=30;break}for(;d--;)r.lens[r.have++]=k}}if(30===r.mode)break;if(0===r.lens[256]){e.msg="invalid code -- missing end-of-block",r.mode=30;break}if(r.lenbits=9,S={bits:r.lenbits},x=T(D,r.lens,0,r.nlen,r.lencode,0,r.work,S),r.lenbits=S.bits,x){e.msg="invalid literal/lengths set",r.mode=30;break}if(r.distbits=6,r.distcode=r.distdyn,S={bits:r.distbits},x=T(F,r.lens,r.nlen,r.ndist,r.distcode,0,r.work,S),r.distbits=S.bits,x){e.msg="invalid distances set",r.mode=30;break}if(r.mode=20,6===t)break e;case 20:r.mode=21;case 21:if(6<=o&&258<=h){e.next_out=a,e.avail_out=h,e.next_in=s,e.avail_in=o,r.hold=u,r.bits=l,R(e,c),a=e.next_out,i=e.output,h=e.avail_out,s=e.next_in,n=e.input,o=e.avail_in,u=r.hold,l=r.bits,12===r.mode&&(r.back=-1);break}for(r.back=0;g=(C=r.lencode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,r.length=b,0===g){r.mode=26;break}if(32&g){r.back=-1,r.mode=12;break}if(64&g){e.msg="invalid literal/length code",r.mode=30;break}r.extra=15&g,r.mode=22;case 22:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}r.was=r.length,r.mode=23;case 23:for(;g=(C=r.distcode[u&(1<>>16&255,b=65535&C,!((_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>v)])>>>16&255,b=65535&C,!(v+(_=C>>>24)<=l);){if(0===o)break e;o--,u+=n[s++]<>>=v,l-=v,r.back+=v}if(u>>>=_,l-=_,r.back+=_,64&g){e.msg="invalid distance code",r.mode=30;break}r.offset=b,r.extra=15&g,r.mode=24;case 24:if(r.extra){for(z=r.extra;l>>=r.extra,l-=r.extra,r.back+=r.extra}if(r.offset>r.dmax){e.msg="invalid distance too far back",r.mode=30;break}r.mode=25;case 25:if(0===h)break e;if(d=c-h,r.offset>d){if((d=r.offset-d)>r.whave&&r.sane){e.msg="invalid distance too far back",r.mode=30;break}p=d>r.wnext?(d-=r.wnext,r.wsize-d):r.wnext-d,d>r.length&&(d=r.length),m=r.window}else m=i,p=a-r.offset,d=r.length;for(hd?(m=R[T+a[v]],A[I+a[v]]):(m=96,0),h=1<>S)+(u-=h)]=p<<24|m<<16|_|0,0!==u;);for(h=1<>=1;if(0!==h?(E&=h-1,E+=h):E=0,v++,0==--O[b]){if(b===w)break;b=t[r+a[v]]}if(k>>7)]}function U(e,t){e.pending_buf[e.pending++]=255&t,e.pending_buf[e.pending++]=t>>>8&255}function P(e,t,r){e.bi_valid>d-r?(e.bi_buf|=t<>d-e.bi_valid,e.bi_valid+=r-d):(e.bi_buf|=t<>>=1,r<<=1,0<--t;);return r>>>1}function Z(e,t,r){var n,i,s=new Array(g+1),a=0;for(n=1;n<=g;n++)s[n]=a=a+r[n-1]<<1;for(i=0;i<=t;i++){var o=e[2*i+1];0!==o&&(e[2*i]=j(s[o]++,o))}}function W(e){var t;for(t=0;t>1;1<=r;r--)G(e,s,r);for(i=h;r=e.heap[1],e.heap[1]=e.heap[e.heap_len--],G(e,s,1),n=e.heap[1],e.heap[--e.heap_max]=r,e.heap[--e.heap_max]=n,s[2*i]=s[2*r]+s[2*n],e.depth[i]=(e.depth[r]>=e.depth[n]?e.depth[r]:e.depth[n])+1,s[2*r+1]=s[2*n+1]=i,e.heap[1]=i++,G(e,s,1),2<=e.heap_len;);e.heap[--e.heap_max]=e.heap[1],function(e,t){var r,n,i,s,a,o,h=t.dyn_tree,u=t.max_code,l=t.stat_desc.static_tree,f=t.stat_desc.has_stree,c=t.stat_desc.extra_bits,d=t.stat_desc.extra_base,p=t.stat_desc.max_length,m=0;for(s=0;s<=g;s++)e.bl_count[s]=0;for(h[2*e.heap[e.heap_max]+1]=0,r=e.heap_max+1;r<_;r++)p<(s=h[2*h[2*(n=e.heap[r])+1]+1]+1)&&(s=p,m++),h[2*n+1]=s,u>=7;n>>=1)if(1&r&&0!==e.dyn_ltree[2*t])return o;if(0!==e.dyn_ltree[18]||0!==e.dyn_ltree[20]||0!==e.dyn_ltree[26])return h;for(t=32;t>>3,(s=e.static_len+3+7>>>3)<=i&&(i=s)):i=s=r+5,r+4<=i&&-1!==t?J(e,t,r,n):4===e.strategy||s===i?(P(e,2+(n?1:0),3),K(e,z,C)):(P(e,4+(n?1:0),3),function(e,t,r,n){var i;for(P(e,t-257,5),P(e,r-1,5),P(e,n-4,4),i=0;i>>8&255,e.pending_buf[e.d_buf+2*e.last_lit+1]=255&t,e.pending_buf[e.l_buf+e.last_lit]=255&r,e.last_lit++,0===t?e.dyn_ltree[2*r]++:(e.matches++,t--,e.dyn_ltree[2*(A[r]+u+1)]++,e.dyn_dtree[2*N(t)]++),e.last_lit===e.lit_bufsize-1},r._tr_align=function(e){P(e,2,3),L(e,m,z),function(e){16===e.bi_valid?(U(e,e.bi_buf),e.bi_buf=0,e.bi_valid=0):8<=e.bi_valid&&(e.pending_buf[e.pending++]=255&e.bi_buf,e.bi_buf>>=8,e.bi_valid-=8)}(e)}},{"../utils/common":41}],53:[function(e,t,r){"use strict";t.exports=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0}},{}],54:[function(e,t,r){(function(e){!function(r,n){"use strict";if(!r.setImmediate){var i,s,t,a,o=1,h={},u=!1,l=r.document,e=Object.getPrototypeOf&&Object.getPrototypeOf(r);e=e&&e.setTimeout?e:r,i="[object process]"==={}.toString.call(r.process)?function(e){process.nextTick(function(){c(e)})}:function(){if(r.postMessage&&!r.importScripts){var e=!0,t=r.onmessage;return r.onmessage=function(){e=!1},r.postMessage("","*"),r.onmessage=t,e}}()?(a="setImmediate$"+Math.random()+"$",r.addEventListener?r.addEventListener("message",d,!1):r.attachEvent("onmessage",d),function(e){r.postMessage(a+e,"*")}):r.MessageChannel?((t=new MessageChannel).port1.onmessage=function(e){c(e.data)},function(e){t.port2.postMessage(e)}):l&&"onreadystatechange"in l.createElement("script")?(s=l.documentElement,function(e){var t=l.createElement("script");t.onreadystatechange=function(){c(e),t.onreadystatechange=null,s.removeChild(t),t=null},s.appendChild(t)}):function(e){setTimeout(c,0,e)},e.setImmediate=function(e){"function"!=typeof e&&(e=new Function(""+e));for(var t=new Array(arguments.length-1),r=0;r 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); + +/* 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); + +/* 仅解除正文的选择 / 右键内联限制;保留并恢复网页自身的设置。 */ +(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); + +/* 独立题库助手面板:页面正文与面板样式互不影响。 */ +(function (global) { + 'use strict'; + + const ROLES = ['主操', '班长', '副操']; + const PAGE_SIZE = 20; + const TYPE_NAMES = { single: '单选', multiple: '多选', judgment: '判断', unknown: '未知' }; + + function plain(value) { return String(value == null ? '' : value); } + function normalized(value) { return plain(value).replace(/\s+/g, ' ').trim(); } + function el(doc, tag, className, value) { + const node = doc.createElement(tag); + if (className) node.className = className; + if (value != null) node.textContent = value; + return node; + } + + function create(callbacks) { + callbacks = callbacks || {}; + const doc = global.document; + const state = { + autoAnswer: false, allowCopy: true, role: '', course: '', courses: [], + bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别练习页面', + canAnswer: false, canExport: false, metrics: {}, autoExport: true, expanded: false, loading: false + }; + let destroyed = false; + let selectionText = ''; + let currentOffset = 0; + let resultTotal = 0; + const host = doc.createElement('div'); + host.id = 'yian-question-assistant'; + host.setAttribute('data-yian-assistant', ''); + host.style.cssText = 'all:initial!important;position:fixed!important;right:16px!important;bottom:16px!important;z-index:2147483646!important;display:block!important;max-width:calc(100vw - 24px)!important;font:13px/1.5 system-ui,"Microsoft YaHei",sans-serif!important;color:#17253d!important;'; + const root = host.attachShadow({ mode: 'open' }); + const style = doc.createElement('style'); + style.textContent = ` + :host{font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif;color:#17253d;font-size:13px;line-height:1.5;color-scheme:light;--accent:#225aca;--muted:#65728a;--line:#e4e9f1;--surface:#f5f7fb} + *,*::before,*::after{box-sizing:border-box}button,input,select{font:inherit}button,input,select,label{-webkit-user-select:none;user-select:none}button{cursor:pointer}button:disabled,input:disabled,select:disabled{cursor:not-allowed;opacity:.5}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #b8d1ff;outline-offset:2px}button{border:1px solid var(--line);background:white;color:#273953;border-radius:7px;padding:6px 10px}button:hover:enabled{background:#f2f6ff;border-color:#b6caf1}input[type=text],select{width:100%;min-width:0;border:1px solid #cfd8e7;border-radius:7px;background:#fff;color:#17253d;padding:8px}select{padding:6px 8px}input[type=checkbox],input[type=radio]{accent-color:var(--accent)} + .panel{width:354px;max-width:calc(100vw - 24px);background:white;border:1px solid #dce3ee;border-radius:13px;box-shadow:0 8px 36px #14264528;overflow:hidden}.header{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:#172a49;color:white}.brand{font-size:15px;font-weight:700;letter-spacing:.2px}.toggle{border:0;background:#ffffff17;color:white;padding:3px 9px;border-radius:5px;min-width:30px}.toggle:hover:enabled{background:#ffffff2c;color:white;border-color:transparent}.body{padding:13px;max-height:calc(100vh - 102px);max-height:calc(100dvh - 102px);overflow:auto;overscroll-behavior:contain}.body[hidden]{display:none}.panel.collapsed{width:auto;min-width:140px}.collapsed .header{padding:10px 12px;gap:16px}.switches{display:grid;gap:9px;padding-bottom:12px;border-bottom:1px solid var(--line)}.switch-row{display:flex;align-items:center;justify-content:space-between;position:relative;gap:12px;cursor:pointer;font-weight:600}.switch{appearance:none;-webkit-appearance:none;width:35px;height:21px;margin:0;border:0!important;border-radius:20px;background:#b7c0ce;position:relative;transition:background .15s}.switch::after{content:"";position:absolute;width:15px;height:15px;top:3px;left:3px;border-radius:50%;background:white;box-shadow:0 1px 3px #0002;transition:transform .15s}.switch:checked{background:var(--accent)}.switch:checked::after{transform:translateX(14px)}.hint{font-size:12px;color:var(--muted);margin:6px 0 0;overflow-wrap:anywhere}.section{padding-top:12px}.section-title{font-size:13px;font-weight:700;margin:0 0 7px}.import-row{display:flex;align-items:center;gap:10px}.import-row button{white-space:nowrap}.bank-info{font-size:12px;color:var(--muted);overflow-wrap:anywhere}.storage{color:#97702e;margin:4px 0 0;font-size:11px}.roles{display:flex;gap:6px;flex-wrap:wrap}.role{display:flex;align-items:center;gap:4px;padding:6px 8px;border:1px solid var(--line);border-radius:7px;cursor:pointer;background:white;flex:1;justify-content:center;white-space:nowrap}.role.active{background:#edf3ff;border-color:#abc5fa;color:#194dab}.role input{margin:0}.role-count{font-size:10px;color:var(--muted)}.course-row{display:grid;grid-template-columns:36px 1fr;gap:7px;align-items:center;margin-top:8px}.search-row{display:flex;gap:6px;margin-top:9px}.search-row input{flex:1}.primary{background:var(--accent);border-color:var(--accent);color:white;white-space:nowrap}.primary:hover:enabled{background:#184baf;color:white;border-color:#184baf}.selection-search{margin-top:7px;font-size:12px;width:100%}.notice{padding:8px 9px;border-radius:7px;margin:10px 0 0;font-size:12px;overflow-wrap:anywhere;background:#edf3ff;color:#254e8b}.notice.warning{background:#fff5df;color:#8a5d11}.notice.error{background:#fff0ed;color:#a23a2c}.notice.success{background:#eaf7f0;color:#1e754c}.notice[hidden]{display:none}.results-title{font-size:12px;color:var(--muted);margin:11px 0 7px}.empty{background:var(--surface);border-radius:8px;padding:13px 10px;color:var(--muted);font-size:12px}.result{border:1px solid var(--line);border-radius:9px;padding:10px;margin-bottom:9px;-webkit-user-select:text;user-select:text}.question{font-weight:600;line-height:1.6;overflow-wrap:anywhere;margin:0 0 7px;white-space:pre-wrap}.type{display:inline-block;font-size:10px;font-weight:500;color:#556984;background:#eff3f8;padding:1px 5px;border-radius:4px;margin-right:5px;vertical-align:1px}.answer{font-weight:600;color:#1b714a;background:#edf8f1;border-radius:5px;padding:6px 7px;overflow-wrap:anywhere}.options{padding:0;margin:7px 0;list-style:none;font-size:12px;display:grid;gap:4px}.option{padding:3px 6px;border-radius:4px;overflow-wrap:anywhere;white-space:pre-wrap}.option.correct{color:#196442;background:#edf8f1;font-weight:600}.sources{font-size:11px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere;margin-top:5px}.conflict{color:#9d6012;background:#fff5df;border-radius:5px;font-size:11px;padding:6px;margin:6px 0}.copy-result{margin-top:8px;font-size:11px}.pagination{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:var(--muted);margin:5px 0 0}.pagination button{font-size:11px;padding:4px 8px}.page-section{border-top:1px solid var(--line);margin-top:13px;padding-top:11px}.page-status{font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted)}.metrics{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11px;color:var(--muted);margin-top:6px}.unprocessed{font-size:11px;color:#8b621c;margin-top:7px}.unprocessed summary{cursor:pointer}.unprocessed-list{max-height:155px;overflow:auto;padding:7px;border-radius:6px;background:#fff7e8;margin-top:4px;display:grid;gap:5px;overflow-wrap:anywhere}.exports{display:flex;gap:6px;margin-top:9px}.exports button{flex:1;font-size:12px}.auto-export{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);margin-top:8px}.auto-export input{margin:0}.busy{color:#83692c}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){.body{padding:11px}.panel{width:350px}.role{padding:6px 5px}} + `; + root.appendChild(style); + const visibilityStyle = doc.createElement('style'); + visibilityStyle.textContent = '[hidden]{display:none!important}'; + root.appendChild(visibilityStyle); + const panel = el(doc, 'section', 'panel collapsed'); + panel.setAttribute('aria-label', '谊安学题库助手'); + const header = el(doc, 'div', 'header'); + const brand = el(doc, 'span', 'brand', '题库助手'); + const expand = el(doc, 'button', 'toggle', '展开'); + expand.type = 'button'; + expand.setAttribute('aria-label', '展开题库助手'); + expand.setAttribute('aria-expanded', 'false'); + header.append(brand, expand); + const body = el(doc, 'div', 'body'); + body.hidden = true; + panel.append(header, body); + root.appendChild(panel); + + function invoke(name) { + if (destroyed || typeof callbacks[name] !== 'function') return; + const args = Array.prototype.slice.call(arguments, 1); + try { + const result = callbacks[name].apply(null, args); + if (result && typeof result.catch === 'function') result.catch(function (error) { setNotice(error && error.message || '操作失败,请重试。', 'error'); }); + return result; + } catch (error) { setNotice(error && error.message || '操作失败,请重试。', 'error'); } + } + function setNotice(message, level) { + notice.textContent = plain(message); + notice.className = 'notice ' + (['warning', 'error', 'success'].includes(level) ? level : 'info'); + notice.hidden = !message; + } + function togglePanel() { state.expanded = !state.expanded; refresh(); } + expand.addEventListener('click', togglePanel); + brand.style.cursor = 'pointer'; + brand.addEventListener('click', togglePanel); + + const switches = el(doc, 'div', 'switches'); + function makeSwitch(labelText, callback) { + const label = el(doc, 'label', 'switch-row'); + const text = el(doc, 'span', '', labelText); + const input = el(doc, 'input', 'switch'); + input.type = 'checkbox'; + input.setAttribute('role', 'switch'); + input.setAttribute('aria-label', labelText); + label.append(text, input); + switches.appendChild(label); + input.addEventListener('change', callback); + return { input: input, label: label }; + } + const autoSwitch = makeSwitch('自动选择答案', function () { state.autoAnswer = autoSwitch.input.checked; invoke('onAutoChange', state.autoAnswer); refresh(); }); + const copySwitch = makeSwitch('允许复制文字', function () { state.allowCopy = copySwitch.input.checked; invoke('onCopyChange', state.allowCopy); refresh(); }); + const answerHint = el(doc, 'p', 'hint'); + switches.appendChild(answerHint); + body.appendChild(switches); + + const bankSection = el(doc, 'div', 'section'); + const importRow = el(doc, 'div', 'import-row'); + const importButton = el(doc, 'button', '', '导入题库'); + importButton.type = 'button'; + const fileInput = el(doc, 'input'); + fileInput.type = 'file'; + fileInput.accept = '.zip,.csv,.json,application/zip,text/csv,application/json'; + fileInput.multiple = true; + fileInput.hidden = true; + const bankInfo = el(doc, 'span', 'bank-info'); + importRow.append(importButton, bankInfo, fileInput); + const storageInfo = el(doc, 'p', 'storage'); + bankSection.append(importRow, storageInfo); + body.appendChild(bankSection); + importButton.addEventListener('click', function () { fileInput.click(); }); + fileInput.addEventListener('change', function () { + const files = Array.from(fileInput.files || []); + if (files.length) invoke('onImport', files); + fileInput.value = ''; + }); + + const searchSection = el(doc, 'div', 'section'); + searchSection.appendChild(el(doc, 'h2', 'section-title', '搜索题库')); + const roles = el(doc, 'div', 'roles'); + roles.setAttribute('role', 'radiogroup'); + roles.setAttribute('aria-label', '选择岗位'); + const roleInputs = {}; + const roleLabels = {}; + const roleCounters = {}; + ROLES.forEach(function (role) { + const label = el(doc, 'label', 'role'); + const input = el(doc, 'input'); + input.type = 'radio'; + input.name = 'yian-search-role'; + input.value = role; + input.setAttribute('aria-label', role); + const counter = el(doc, 'span', 'role-count'); + label.append(input, el(doc, 'span', '', role), counter); + roles.appendChild(label); + roleInputs[role] = input; + roleLabels[role] = label; + roleCounters[role] = counter; + input.addEventListener('change', function () { + if (!input.checked) return; + const query = searchInput.value; + state.role = role; + state.course = ''; + currentOffset = 0; + refresh(); + invoke('onRoleChange', role); + if (normalized(query)) invoke('onSearch', query, { offset: 0 }); + }); + }); + searchSection.appendChild(roles); + const courseRow = el(doc, 'label', 'course-row'); + const courseSelect = el(doc, 'select'); + courseSelect.setAttribute('aria-label', '选择课程'); + courseRow.append(el(doc, 'span', '', '课程'), courseSelect); + courseSelect.addEventListener('change', function () { + const query = searchInput.value; + state.course = courseSelect.value; + currentOffset = 0; + invoke('onCourseChange', state.course); + if (normalized(query)) invoke('onSearch', query, { offset: 0 }); + }); + searchSection.appendChild(courseRow); + const searchRow = el(doc, 'div', 'search-row'); + const searchInput = el(doc, 'input'); + searchInput.type = 'text'; + searchInput.placeholder = '输入题干、关键词或选项文字…'; + searchInput.setAttribute('aria-label', '搜索题目'); + const searchButton = el(doc, 'button', 'primary', '搜索'); + searchButton.type = 'button'; + searchRow.append(searchInput, searchButton); + searchSection.appendChild(searchRow); + const selectionButton = el(doc, 'button', 'selection-search', '用选中文字搜索'); + selectionButton.type = 'button'; + searchSection.appendChild(selectionButton); + const notice = el(doc, 'div', 'notice'); + notice.hidden = true; + notice.setAttribute('role', 'status'); + notice.setAttribute('aria-live', 'polite'); + searchSection.appendChild(notice); + const resultsTitle = el(doc, 'p', 'results-title'); + const results = el(doc, 'div', 'results'); + const pagination = el(doc, 'div', 'pagination'); + const previous = el(doc, 'button', '', '上一页'); + const pageLabel = el(doc, 'span'); + const next = el(doc, 'button', '', '下一页'); + previous.type = next.type = 'button'; + pagination.append(previous, pageLabel, next); + pagination.hidden = true; + searchSection.append(resultsTitle, results, pagination); + body.appendChild(searchSection); + + function search(offset) { + if (!state.role) { setNotice('请先选择岗位:主操、班长或副操。', 'warning'); return; } + if (!normalized(searchInput.value)) { setNotice('请输入题干、关键词或选项文字。', 'warning'); searchInput.focus(); return; } + currentOffset = Math.max(0, Number(offset) || 0); + invoke('onSearch', searchInput.value, { offset: currentOffset }); + } + searchButton.addEventListener('click', function () { search(0); }); + searchInput.addEventListener('keydown', function (event) { if (event.key === 'Enter' && !event.isComposing) { event.preventDefault(); search(0); } }); + previous.addEventListener('click', function () { search(Math.max(0, currentOffset - PAGE_SIZE)); }); + next.addEventListener('click', function () { search(currentOffset + PAGE_SIZE); }); + function rememberSelection() { + const selected = global.getSelection && global.getSelection(); + if (!selected || !selected.anchorNode || !selected.focusNode) return; + if (host.contains(selected.anchorNode) || host.contains(selected.focusNode) || root.contains(selected.anchorNode) || root.contains(selected.focusNode)) return; + const text = selected.toString().trim(); + if (text) selectionText = text; + } + doc.addEventListener('selectionchange', rememberSelection); + selectionButton.addEventListener('pointerdown', rememberSelection); + selectionButton.addEventListener('click', function () { + rememberSelection(); + if (!state.role) { setNotice('请先选择岗位:主操、班长或副操。', 'warning'); return; } + if (!selectionText) { setNotice('请先在网页正文选中题目文字。', 'warning'); return; } + searchInput.value = selectionText; + currentOffset = 0; + if (typeof callbacks.onSelectionSearch === 'function') invoke('onSelectionSearch', selectionText); + else search(0); + }); + + const pageSection = el(doc, 'div', 'page-section'); + pageSection.appendChild(el(doc, 'h2', 'section-title', '当前练习')); + const pageStatus = el(doc, 'div', 'page-status'); + const metrics = el(doc, 'div', 'metrics'); + const unprocessed = el(doc, 'details', 'unprocessed'); + const unprocessedSummary = el(doc, 'summary', '', '查看未处理题目'); + const unprocessedList = el(doc, 'div', 'unprocessed-list'); + unprocessed.append(unprocessedSummary, unprocessedList); + unprocessed.hidden = true; + const exportRow = el(doc, 'div', 'exports'); + const exportButtons = []; + ['csv', 'json', 'txt'].forEach(function (format) { + const button = el(doc, 'button', '', '导出 ' + format.toUpperCase()); + button.type = 'button'; + button.addEventListener('click', function () { invoke('onExport', format); }); + exportRow.appendChild(button); + exportButtons.push(button); + }); + const autoExportLabel = el(doc, 'label', 'auto-export'); + const autoExportInput = el(doc, 'input'); + autoExportInput.type = 'checkbox'; + autoExportLabel.append(autoExportInput, el(doc, 'span', '', '进入练习时自动导出 CSV')); + autoExportInput.addEventListener('change', function () { state.autoExport = autoExportInput.checked; invoke('onAutoExportChange', state.autoExport); }); + pageSection.append(pageStatus, metrics, unprocessed, exportRow, autoExportLabel); + body.appendChild(pageSection); + + function autoUnavailableReason() { + if (!state.role) return '请选择岗位后再开启自动选择。'; + if (!(Number(state.roleCounts && state.roleCounts[state.role]) > 0)) return '请导入包含' + state.role + '题目的题库。'; + if (!state.canAnswer) return state.pageStatus || '当前页面未识别到支持的练习题目。'; + return ''; + } + autoSwitch.label.addEventListener('pointerdown', function () { + const reason = autoUnavailableReason(); + if (!state.autoAnswer && reason) setNotice(reason, 'warning'); + }); + + let courseSignature = ''; + function refresh() { + if (destroyed) return; + panel.classList.toggle('collapsed', !state.expanded); + body.hidden = !state.expanded; + brand.textContent = state.expanded ? '谊安学助手' : '题库助手'; + expand.textContent = state.expanded ? '收起' : '展开'; + expand.setAttribute('aria-label', state.expanded ? '收起题库助手' : '展开题库助手'); + expand.setAttribute('aria-expanded', String(Boolean(state.expanded))); + const unavailable = autoUnavailableReason(); + autoSwitch.input.checked = Boolean(state.autoAnswer); + autoSwitch.input.disabled = !state.autoAnswer && (Boolean(unavailable) || Boolean(state.loading)); + autoSwitch.label.title = unavailable; + copySwitch.input.checked = Boolean(state.allowCopy); + answerHint.textContent = unavailable || (state.autoAnswer ? '正在自动选择;关闭即可停止后续操作。' : '开启后选择已匹配的答案,交卷由你操作。'); + bankInfo.textContent = state.loading ? '正在读取题库…' : '已加载 ' + Number(state.bankTotal || 0).toLocaleString('zh-CN') + ' 条' + (state.role ? ' · ' + state.role + ' ' + Number(state.roleCounts && state.roleCounts[state.role] || 0).toLocaleString('zh-CN') + ' 条' : ''); + bankInfo.classList.toggle('busy', Boolean(state.loading)); + importButton.disabled = Boolean(state.loading); + storageInfo.textContent = state.storageMode === 'origin' ? '此模式仅在当前网站保存题库和设置。' : ''; + storageInfo.hidden = !storageInfo.textContent; + ROLES.forEach(function (role) { + roleInputs[role].checked = state.role === role; + roleLabels[role].classList.toggle('active', state.role === role); + const count = Number(state.roleCounts && state.roleCounts[role] || 0); + roleCounters[role].textContent = count ? '(' + count.toLocaleString('zh-CN') + ')' : ''; + }); + const availableCourses = (state.courses || []).map(function (course) { + return typeof course === 'string' ? { value: course, label: course } : { value: plain(course.value == null ? (course.course || course.name) : course.value), label: plain(course.label || course.name || course.course || course.value) }; + }); + const signature = JSON.stringify(availableCourses); + if (signature !== courseSignature) { + courseSignature = signature; + courseSelect.replaceChildren(); + const all = el(doc, 'option', '', '全部课程'); + all.value = ''; + courseSelect.appendChild(all); + availableCourses.forEach(function (course) { const option = el(doc, 'option', '', course.label); option.value = course.value; courseSelect.appendChild(option); }); + } + courseSelect.value = state.course || ''; + courseSelect.disabled = !state.role || Boolean(state.loading); + searchButton.disabled = Boolean(state.loading); + selectionButton.disabled = Boolean(state.loading); + pageStatus.textContent = state.pageStatus || '等待识别练习页面'; + metrics.replaceChildren(); + const metricLabels = { selected: '已选择', pending: '待确认', missing: '未匹配', conflict: '冲突', locked: '已锁定', unsupported: '需人工核对', failed: '失败', skipped: '已跳过' }; + Object.keys(metricLabels).forEach(function (key) { + if (state.metrics && state.metrics[key] != null) metrics.appendChild(el(doc, 'span', '', metricLabels[key] + ' ' + Number(state.metrics[key] || 0))); + }); + const notProcessed = (state.metrics && state.metrics.details || []).filter(function (item) { return item.status !== 'selected'; }); + unprocessed.hidden = !notProcessed.length; + unprocessedSummary.textContent = '查看未处理题目(' + notProcessed.length + ')'; + unprocessedList.replaceChildren(); + notProcessed.forEach(function (item) { + unprocessedList.appendChild(el(doc, 'div', '', '第 ' + item.number + ' 题 · ' + (metricLabels[item.status] || '已跳过') + ':' + (item.reason || '请人工核对。'))); + }); + exportButtons.forEach(function (button) { button.disabled = !state.canExport; }); + autoExportInput.checked = Boolean(state.autoExport); + previous.disabled = currentOffset === 0 || Boolean(state.loading); + next.disabled = currentOffset + PAGE_SIZE >= resultTotal || Boolean(state.loading); + } + + function optionsOf(item) { + if (Array.isArray(item.options)) return item.options.map(function (option, index) { + return typeof option === 'string' ? { label: String.fromCharCode(65 + index), text: option } : { label: plain(option.label || option.key || String.fromCharCode(65 + index)), text: plain(option.text == null ? option.value : option.text), correct: option.correct }; + }); + return Object.keys(item.options || {}).map(function (label) { return { label: label, text: plain(item.options[label]) }; }); + } + function sourcesOf(item) { + const entries = Array.isArray(item.sources) && item.sources.length ? item.sources : [{ role: item.role || state.role, course: item.course || item.title || item.bankTitle || '', file: item.filename || '' }]; + return Array.from(new Set(entries.map(function (source) { + if (typeof source === 'string') return source; + return [source.role || item.role, source.course || source.title || source.bankTitle || source.name, source.filename || source.file].filter(Boolean).join(' / '); + }).filter(Boolean))); + } + function answerInfo(item, options) { + const answer = plain(item.answer == null ? item.correctAnswer : item.answer); + const letters = plain(item.answerLabels || item.correctLabels || answer).replace(/[\s,,、;;.。]/g, '').toUpperCase(); + const texts = Array.isArray(item.answerTexts) ? item.answerTexts.map(normalized) : []; + const correctOptions = options.filter(function (option) { return option.correct || /^[A-Z]+$/.test(letters) && letters.includes(option.label.toUpperCase()) || texts.includes(normalized(option.text)); }); + const description = item.answerText || correctOptions.map(function (option) { return option.label + ' — ' + (option.text || '[图片选项]'); }).join(';') || texts.join(';'); + return { answer: answer, description: plain(description), correctOptions: correctOptions }; + } + async function copyText(value) { + const legacyClipboard = typeof GM_setClipboard === 'function' ? GM_setClipboard : global.GM_setClipboard; + const modernGM = typeof GM !== 'undefined' ? GM : global.GM; + if (typeof legacyClipboard === 'function') { await legacyClipboard(value, 'text'); return; } + if (modernGM && typeof modernGM.setClipboard === 'function') { await modernGM.setClipboard(value, 'text'); return; } + if (global.navigator.clipboard && typeof global.navigator.clipboard.writeText === 'function') { + try { await global.navigator.clipboard.writeText(value); return; } catch (_) { /* 再尝试浏览器复制命令。 */ } + } + const textarea = el(doc, 'textarea'); + textarea.value = value; + textarea.style.cssText = 'position:fixed;left:0;top:0;opacity:0;width:1px;height:1px;'; + root.appendChild(textarea); + textarea.focus(); + textarea.select(); + let copied = false; + try { copied = doc.execCommand('copy'); } finally { textarea.remove(); } + if (!copied) throw new Error('浏览器未允许复制,请选中结果文字手动复制。'); + } + function renderSearch(result) { + if (destroyed) return; + result = result || {}; + const items = (result.items || []).slice(0, PAGE_SIZE); + resultTotal = Number(result.total == null ? items.length : result.total) || 0; + currentOffset = Math.max(0, Number(result.offset == null ? currentOffset : result.offset) || 0); + resultsTitle.textContent = state.role ? '找到 ' + resultTotal.toLocaleString('zh-CN') + ' 道题 · 当前岗位:' + state.role : '先选择岗位,再搜索题目'; + results.replaceChildren(); + if (!items.length) results.appendChild(el(doc, 'div', 'empty', state.role ? '当前岗位未找到题目,可调整关键词、课程或切换岗位。' : '选择主操、班长或副操,导入题库后即可查找答案。')); + items.forEach(function (item) { + const card = el(doc, 'article', 'result'); + const title = el(doc, 'p', 'question'); + const type = TYPE_NAMES[item.type] || item.type || '题目'; + title.append(el(doc, 'span', 'type', type), doc.createTextNode(plain(item.question == null ? item.stem : item.question))); + card.appendChild(title); + const options = optionsOf(item); + const info = answerInfo(item, options); + card.appendChild(el(doc, 'div', 'answer', '正确答案:' + (info.answer || '见下方选项') + (info.description ? ' · ' + info.description : ''))); + const list = el(doc, 'ul', 'options'); + options.forEach(function (option) { + const correct = info.correctOptions.includes(option); + const row = el(doc, 'li', 'option' + (correct ? ' correct' : ''), option.label + '. ' + (option.text || '[图片选项]') + (correct ? ' ✓' : '')); + list.appendChild(row); + }); + card.appendChild(list); + if (item.conflict || item.conflicts && item.conflicts.length || item.ambiguous) card.appendChild(el(doc, 'div', 'conflict', '此题存在不同答案,请核对课程来源;自动选择会跳过不明确的匹配。')); + const sources = sourcesOf(item); + card.appendChild(el(doc, 'div', 'sources', '来源:' + (sources.join('\n') || '未知'))); + const copyButton = el(doc, 'button', 'copy-result', '复制题目与答案'); + copyButton.type = 'button'; + copyButton.addEventListener('click', async function () { + const content = ['[' + type + '] ' + plain(item.question == null ? item.stem : item.question)].concat(options.map(function (option) { return option.label + '. ' + (option.text || '[图片选项]'); }), ['正确答案:' + info.answer, info.description ? '对应选项:' + info.description : '', '来源:' + sources.join(';')]).filter(Boolean).join('\n'); + try { await copyText(content); setNotice('题目与答案已复制。', 'success'); } catch (error) { setNotice(error.message, 'error'); } + }); + card.appendChild(copyButton); + results.appendChild(card); + }); + pagination.hidden = resultTotal <= PAGE_SIZE; + pageLabel.textContent = resultTotal ? (currentOffset + 1) + '–' + Math.min(currentOffset + items.length, resultTotal) + ' / ' + resultTotal : '0 / 0'; + refresh(); + } + + function setState(partial) { + if (destroyed) return; + Object.assign(state, partial || {}); + refresh(); + } + function destroy() { + if (destroyed) return; + destroyed = true; + doc.removeEventListener('selectionchange', rememberSelection); + host.remove(); + } + (doc.body || doc.documentElement).appendChild(host); + renderSearch({ items: [], total: 0 }); + refresh(); + return { setState: setState, renderSearch: renderSearch, setNotice: setNotice, destroy: destroy, host: host, root: root }; + } + + global.YianAssistantPanel = { create: create }; +})(typeof window !== 'undefined' ? window : globalThis); + +/* 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); + +(function () { + 'use strict'; 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); } - }); - actions.append(button); + 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'); } - 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 courses() { + return Array.from(new Set(snapshot.records.filter(record => record.role === config.role).map(record => record.course))).sort(); } - - 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 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 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; + 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'); } + } } - 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 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(); })();