From 6b04243428f8d744e82d699ff4ad13893f27462c Mon Sep 17 00:00:00 2001 From: suanyuit-cyber <346708943+suanyuit-cyber@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:08:28 +0800 Subject: [PATCH] feat: add Huayi question bank assistant v1.1.0 --- .gitattributes | 5 + .gitignore | 31 + README.md | 108 +++ build.cjs | 25 + dist/huayi-question-bank.user.js | 1460 ++++++++++++++++++++++++++++++ package.json | 12 + src/adapter.js | 529 +++++++++++ src/app.js | 504 +++++++++++ src/core.js | 399 ++++++++ tests/adapter.test.cjs | 320 +++++++ tests/core.test.cjs | 300 ++++++ tests/fixtures/question.html | 26 + tests/integration.test.cjs | 629 +++++++++++++ 13 files changed, 4348 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.cjs create mode 100644 dist/huayi-question-bank.user.js create mode 100644 package.json create mode 100644 src/adapter.js create mode 100644 src/app.js create mode 100644 src/core.js create mode 100644 tests/adapter.test.cjs create mode 100644 tests/core.test.cjs create mode 100644 tests/fixtures/question.html create mode 100644 tests/integration.test.cjs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2fa97ba --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f14b529 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Dependencies and local build/test caches +node_modules/ +coverage/ +playwright-report/ +test-results/ +.cache/ +*.log + +# Local credentials and editor state +.env +.env.* +!.env.example +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Saved learning-platform pages/resources remain local. +/*.html +/*_files/ + +# Personal question-bank exports and backups remain local. +/*.csv +/*.tsv +/*.xlsx +/*.json +!/package.json +!/package-lock.json +/exports/ +/backups/ +/screenshots/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e76fb1 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# 华谊题库助手 + +用于华谊学习平台练习页面的油猴脚本:按题库采集题目和平台提供的标准答案,恢复题目选择复制,按实际名称导出 CSV,一次导入多个题库,输入文字或划选题干查答案。当前版本 **1.1.0**。 + +安装文件:[`dist/huayi-question-bank.user.js`](dist/huayi-question-bank.user.js)。这是构建后的完整脚本,无须安装 Node.js 或运行服务器。 + +## 安装与开始使用 + +1. 在浏览器中安装并启用 Tampermonkey(油猴)。 +2. 打开油猴管理面板,选择“新建脚本”,将安装文件的全部内容粘贴进去并保存。 +3. 打开或刷新 `https://www.shhuayi-elearning.cn/` 下的练习页面。安装后应重新进入练习,以便观察首次加载的题目响应。 +4. 右下角出现“题库助手”。默认开始采集正常加载的练习题;已加载的批量题目会一起记录。若答案需要作答后才返回,则在正常练习后补齐。 +5. 在“文字搜索”中输入或粘贴至少两个字符,按回车或点“搜索”。也可以在页面划选题干后松开鼠标自动查询。 +6. 在“题库 / 导出”中检查实际题库名、范围内题数及文件名预览,再导出 CSV 或备份 JSON。默认只导出当前题库,全部题库需要手动选择。 + +浏览器需允许油猴在该网站运行用户脚本。若没有出现面板,先确认扩展已启用、该站点有运行权限,再刷新页面。 + +## 本次方案:按题库分别统计、搜索和导出 + +旧版会累积的原因是:默认选择“全部题库”,主统计也是所有本地记录的总数,导出文件统一叫“华谊题库”。保存多个题库本身是正常行为,但当前库的展示与导出范围应该明确区分。 + +1. 识别当前选中的培训大纲条目、练习标题和任务 ID;接口提供的题库 ID 用于建立对应。切换期间旧题目与晚到响应保留原来源,不能套上新库名称。 +2. 面板顶部始终显示当前实际题库名和本库题数。导出页默认“当前题库”;切换左侧题库后自动跟随,统计、CSV / JSON 内容和文件名预览一起更新。 +3. 需要合并备份时,手动选择“全部题库”。题库总数与全部题数在“已保存题库”单独显示,各库名称和题数分别列出。 +4. 文件名使用实际题库名称,例如 `华谊集团化工总控工(高级)-单选题库1_500题_20261029.csv`。Windows 不允许的文件名字符会替换成下划线;全部备份明确命名“全部题库”。 +5. 对旧数据,只在正常响应重新确认了相同题库 ID 的准确名称时修正该 ID 的历史名称;不按题型或相同题干猜测其他题库的归属。 + +## 文字搜索 + +- 输入或粘贴完整题干、题干片段、选项内容或标准答案关键词,支持按钮、回车和输入后自动查询,也可点“清空”。 +- “搜索题库”可选当前库、任意已保存库或全部库;默认当前库。搜索范围与导出范围独立,跨库查答案不会让导出自动变成全部。 +- “搜索内容”可选全部文字、仅题干、仅选项、仅标准答案;多个关键词可用空格分隔,按同时包含处理。 +- 结果标注命中题干、选项或答案,显示来源题库。只把已确认答案用于答案字段搜索,冲突和待补记录仍可通过题干找到。 +- 每次最多展示 50 条结果,可细化关键词继续查找。 + +## 一次导入多个题库 + +点击“导入多个题库”,在文件选择器中用 Ctrl / Shift 同时选择多个 CSV 和 JSON。两种格式可以混选;文件中已有的题库名称、ID 和答案分别保留。单个合并备份文件也可包含多个题库。 + +没有来源题库名称的文件使用文件名(不含扩展名)作为名称。例如 `判断题库2.csv` 中没有来源列,导入后显示“判断题库2”。相同题目重复导入会合并,不同题库分别管理。若任一文件格式错误,本次整批不导入,并显示具体出错文件。 + +导入成功后可在“已保存题库”查看各库名称和题数,再在搜索 / 导出下拉框选择相应题库。导入不会改变页面当前正在练习的题库,也不会自动将导出范围切为全部。 + +## 隐藏、收起与恢复 + +| 操作 | 效果 | 恢复方式 | +| --- | --- | --- | +| 右上角“收起” | 只显示面板标题栏 | 点击“展开” | +| 右上角“隐藏” | 面板隐藏,默认保留圆形“题库”按钮 | 点击悬浮按钮 | +| 设置中取消“隐藏时保留悬浮按钮”,再隐藏 | 面板和悬浮按钮均隐藏 | **Alt + Shift + Q** 或油猴菜单 | +| 油猴菜单“完全隐藏题库助手” | 直接完全隐藏 | **Alt + Shift + Q** 或菜单“显示题库助手” | + +显示状态、悬浮按钮偏好和复制开关会保留,刷新后继续生效。隐藏时划选题干仍会更新查询,但不会重新弹出面板。隐藏与暂停采集是独立操作;需要暂停时,使用“题库 / 导出”中的“暂停采集”。 + +## 正确答案如何防止错位 + +- 保存标准答案的文字内容、正确选项 ID,以及同一条题目的完整选项。原始 A/B/C 字母仅供参考。 +- 查到答案后,核验当前题干、题型和完整选项集合,再根据当前顺序显示字母。 +- 例如采集时正确答案为 B“提高温度”,当前页面该内容移至 C,面板显示“提高温度;当前页面对应:C”。 +- 多选题保存正确选项的完整集合;判断题保存“正确 / 错误”。 +- 未取得标准答案时显示“待补”。学生选择、答题卡上的对错和题号不会被当作标准答案。 +- 重复选项无法唯一对应、选项内容发生变化、同一题出现不同答案时,显示未确认或冲突,冲突内容保留在备份中。 + +## CSV 与 JSON + +CSV 使用 UTF-8 BOM,兼容 Excel 查看,支持中文、逗号、双引号及单元格换行。列包括:来源题库、题库 ID、题目 ID、题型、题干、各选项、正确答案内容、采集时答案字母、解析、答案状态、答案来源、采集时间和完整记录 JSON。 + +**助手导出的 CSV 含“完整记录JSON”列,回导时以该列为准。** 如果要在 Excel 中手工修改可读列并回导,请删除“完整记录JSON”整列,再导入;修改该列之外的展示文字不会改变完整记录。长数字 ID 和以公式字符开头的内容在可读列使用文本保护,原文保存在 JSON 中。 + +也可导入自己的标准 CSV。最小示例: + +```csv +来源题库,题型,题干,选项A,选项B,正确答案内容,答案状态 +示例题库,单选题,示例题干,选项一,选项二,选项二,已确认 +示例题库,判断题,示例判断题,正确,错误,错误,已确认 +``` + +“正确答案内容”填写完整答案文字。多选使用 JSON 字符串数组或在同一单元格中逐行填写完整选项,不能仅填写 A/B 字母。“我的答案”列不作为标准答案读取。建议直接以助手导出的表格作为模板。 + +JSON 备份包含 `schemaVersion: 1` 和 `records` 数组,保留完整选项标识、来源、答案和冲突。两种格式均按题目合并,导入不会清空现有题库。每个文件限制 20 MB,每批文件合计限制 50 MB、50000 条记录。 + +## 采集范围与当前验证边界 + +脚本在当前账号正常练习时,观察已收到的题目 / 答案响应,并补充当前页面 DOM。不主动提交答案。接口没有提供标准答案时,保留待补状态;仅安装脚本不会凭空补出答案。 + +目前适配了保存页面中的真实题目结构,以及业务脚本中可确认的练习接口:`/upm/start`、`/upm/wrong/start`、`/pra/{id}/questions` 和对应单题作答结果。接口适配限制在本站及学习平台域名。脚本不发送题库到外部服务器,来源 URL 不保存查询参数。 + +本地分析时使用的原保存 HTML 显示判断题库共 500 题,实际只含第 62 题的题干和选项,没有该题标准答案。原始网页及其资源文件不纳入仓库;仓库中的浏览器测试使用脱敏的最小题目结构夹具。离线页面不能单独还原 500 道题或全部答案,在线正常练习能否一次返回全部题目和全部标准答案,仍需要在实际登录页面确认。 + +题库保存在油猴脚本存储中,关闭面板、刷新或暂停采集不会清空。更换浏览器、卸载脚本或清理扩展数据前,请先导出 JSON 备份。 + +## 源码与验证 + +无需第三方前端依赖,源码分为: + +- `src/core.js`:答案规范化、去重与冲突、检索、字母映射、CSV。 +- `src/adapter.js`:本站响应观察、题目 DOM 读取、复制恢复。 +- `src/app.js`:面板、隐藏恢复、存储、导入导出。 + +```powershell +npm run build +npm test +npm run check +``` + +真实浏览器测试使用 `npm run test:browser`,需要现有 Playwright 和 Chromium;可通过 `QB_PLAYWRIGHT_MODULE`、`QB_CHROMIUM_EXECUTABLE` 指定环境路径。测试固定读取 `tests/fixtures/question.html`,使用本地模拟接口,不需要原始保存网页或平台账号。构建、语法检查和核心单元测试只需 Node.js,无第三方依赖。 + +修改源码后执行 `npm run build`,再把新的 `dist/huayi-question-bank.user.js` 更新到油猴。 diff --git a/build.cjs b/build.cjs new file mode 100644 index 0000000..7f631e8 --- /dev/null +++ b/build.cjs @@ -0,0 +1,25 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const root = __dirname; +const header = `// ==UserScript== +// @name 华谊题库助手 · CSV导出与划词检索 +// @namespace local.huayi.question-bank +// @version 1.1.0 +// @description 按题库采集与同名CSV导出,支持多题库批量导入、文字搜索、划词查答案、选择复制与隐藏面板。 +// @match https://www.shhuayi-elearning.cn/* +// @match https://shhuayi-elearning.cn/* +// @run-at document-start +// @noframes +// @grant unsafeWindow +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_registerMenuCommand +// @grant GM_addValueChangeListener +// ==/UserScript== +`; +const sources = ['src/core.js', 'src/adapter.js', 'src/app.js']; +const body = sources.map(file => `\n// ---- ${file} ----\n${fs.readFileSync(path.join(root, file), 'utf8')}`).join('\n'); +fs.mkdirSync(path.join(root, 'dist'), { recursive: true }); +const target = path.join(root, 'dist/huayi-question-bank.user.js'); +fs.writeFileSync(target, `${header}\n(function () {\n'use strict';\n${body}\n})();\n`, 'utf8'); +console.log(`已生成 ${target}`); diff --git a/dist/huayi-question-bank.user.js b/dist/huayi-question-bank.user.js new file mode 100644 index 0000000..b918f27 --- /dev/null +++ b/dist/huayi-question-bank.user.js @@ -0,0 +1,1460 @@ +// ==UserScript== +// @name 华谊题库助手 · CSV导出与划词检索 +// @namespace local.huayi.question-bank +// @version 1.1.0 +// @description 按题库采集与同名CSV导出,支持多题库批量导入、文字搜索、划词查答案、选择复制与隐藏面板。 +// @match https://www.shhuayi-elearning.cn/* +// @match https://shhuayi-elearning.cn/* +// @run-at document-start +// @noframes +// @grant unsafeWindow +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_registerMenuCommand +// @grant GM_addValueChangeListener +// ==/UserScript== + +(function () { +'use strict'; + +// ---- src/core.js ---- +/* Pure question-bank operations. No page access or network requests. */ +const QBCore = (() => { + 'use strict'; + + const TYPES = new Set(['single', 'multiple', 'judge', 'unknown']); + const STATUSES = new Set(['confirmed', 'missing', 'conflict']); + const TYPE_NAMES = { single: '单选题', multiple: '多选题', judge: '判断题', unknown: '未知' }; + const STATUS_NAMES = { confirmed: '已确认', missing: '待补', conflict: '冲突' }; + const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key); + const text = (value) => value == null ? '' : String(value); + const unique = (values) => [...new Set(values)]; + const clean = (value) => text(value).replace(/\u00a0/g, ' ').trim(); + // Do not use NFKC: it changes superscripts, units and some mathematical symbols. + const normalized = (value) => clean(value).replace(/\s+/gu, ''); + const normalizedStem = (value) => normalized(clean(value).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, '')); + const sorted = (values) => [...values].sort(); + const sameArray = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b)); + + function idText(value, name) { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error(`${name} 是不安全的数字,请以字符串提供`); + if (value != null && !['string', 'number'].includes(typeof value)) throw new Error(`${name} 必须是字符串`); + return clean(value); + } + + function stringArray(value, name) { + if (value == null) return []; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' && typeof item !== 'number')) { + throw new Error(`${name} 必须是字符串数组`); + } + return unique(value.map((item) => idText(item, name)).filter(Boolean)); + } + + function judgeText(value) { + const result = clean(value); + if (/^(正确|对|是|√|true)$/i.test(result)) return '正确'; + if (/^(错误|错|否|×|false)$/i.test(result)) return '错误'; + return result; + } + + const answerText = (value, type) => type === 'judge' ? judgeText(value) : clean(value); + const comparableAnswer = (value, type) => normalized(answerText(value, type)); + const optionContents = (record) => record.options.map((option) => comparableAnswer(option.text, record.type)); + const sameContent = (a, b) => normalizedStem(a.stem) === normalizedStem(b.stem) && + (a.type === b.type || a.type === 'unknown' || b.type === 'unknown') && sameArray(optionContents(a), optionContents(b)); + + function sourceInfo(value) { + const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + return { kind: clean(source.kind) || 'unknown', url: clean(source.url), observedAt: clean(source.observedAt) || new Date().toISOString() }; + } + + function recordKey(record) { + const bank = record.bankId || record.bankName; + if (record.questionId) return `id:${JSON.stringify([bank, record.questionId])}`; + // Keep the complete signature, rather than a short hash whose collisions could merge answers. + return `fp:${JSON.stringify([bank, record.type, normalizedStem(record.stem), sorted(optionContents(record))])}`; + } + + function normalizeRecord(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('题目记录必须是对象'); + if (typeof input.stem !== 'string' || !input.stem.trim()) throw new Error('题干不能为空'); + const type = input.type == null ? 'unknown' : input.type; + if (!TYPES.has(type)) throw new Error(`不支持的题型:${text(type)}`); + if (input.answerStatus != null && !STATUSES.has(input.answerStatus)) throw new Error(`无效答案状态:${text(input.answerStatus)}`); + if (input.options != null && !Array.isArray(input.options)) throw new Error('选项必须是数组'); + const options = (input.options || []).map((option, index) => { + if (typeof option === 'string') option = { text: option }; + if (!option || typeof option !== 'object' || typeof option.text !== 'string' || !option.text.trim()) throw new Error('选项内容不能为空'); + return { id: idText(option.id, '选项ID'), text: clean(option.text), label: clean(option.label) || (index < 26 ? String.fromCharCode(65 + index) : String(index + 1)) }; + }); + const correctOptionIds = stringArray(input.correctOptionIds, '正确选项ID'); + let answerTexts = stringArray(input.answerTexts, '正确答案内容').map((value) => answerText(value, type)); + answerTexts = unique(answerTexts); + let answerStatus = input.answerStatus || (correctOptionIds.length || answerTexts.length ? 'confirmed' : 'missing'); + const conflicts = Array.isArray(input.conflicts) ? input.conflicts.filter((item) => item && typeof item === 'object').map((item) => JSON.parse(JSON.stringify(item))) : []; + let inconsistency = ''; + const derivedTexts = []; + if (correctOptionIds.length && options.length) { + for (const id of correctOptionIds) { + const matches = options.filter((option) => option.id === id); + if (matches.length !== 1) inconsistency = '正确选项ID无法唯一对应已保存选项'; + else derivedTexts.push(answerText(matches[0].text, type)); + } + if (!inconsistency && answerTexts.length && !sameArray(answerTexts.map((value) => comparableAnswer(value, type)), derivedTexts.map((value) => comparableAnswer(value, type)))) { + inconsistency = '正确选项ID与正确答案内容不一致'; + } + if (!answerTexts.length && !inconsistency) answerTexts = unique(derivedTexts); + } + if (['single', 'judge'].includes(type) && (answerTexts.length > 1 || correctOptionIds.length > 1)) inconsistency = '单选或判断题存在多个正确答案'; + if (options.length && ['single', 'multiple', 'judge'].includes(type) && answerTexts.some((answer) => !options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(answer, type)))) { + inconsistency = '正确答案内容不属于已保存选项'; + } + if (answerStatus === 'confirmed' && !answerTexts.length && !correctOptionIds.length) answerStatus = 'missing'; + if (answerStatus !== 'missing' && inconsistency) { + answerStatus = 'conflict'; + if (!conflicts.some((item) => item.reason === inconsistency)) conflicts.push({ reason: inconsistency, answerTexts: [...answerTexts], correctOptionIds: [...correctOptionIds] }); + } + // Explicitly missing data must never carry an apparently confirmed answer forward. + if (answerStatus === 'missing') answerTexts = []; + const source = sourceInfo(input.source); + const record = { + bankId: idText(input.bankId, '题库ID'), bankName: clean(input.bankName) || '未命名题库', + questionId: idText(input.questionId, '题目ID'), type, stem: clean(input.stem), options, + correctOptionIds: answerStatus === 'missing' ? [] : correctOptionIds, answerTexts, answerStatus, + explanation: clean(input.explanation), source, updatedAt: clean(input.updatedAt) || source.observedAt, conflicts, + }; + if (input.answerSource || answerStatus === 'confirmed') record.answerSource = sourceInfo(input.answerSource || source); + record.key = recordKey(record); + return record; + } + + function answerSignature(record) { + if (record.answerTexts.length) return JSON.stringify(sorted(record.answerTexts.map((value) => comparableAnswer(value, record.type)))); + return JSON.stringify(sorted(record.correctOptionIds.map((value) => `id:${value}`))); + } + + function conflictSnapshot(record, reason) { + return { reason, stem: record.stem, options: record.options.map((option) => ({ ...option })), answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds], source: record.answerSource || record.source }; + } + + function distinctConflicts(conflicts) { + const byContent = new Map(); + for (const item of conflicts) { + const key = JSON.stringify([item.reason, item.stem, item.options, item.answerTexts, item.correctOptionIds]); + if (!byContent.has(key)) byContent.set(key, item); + } + return [...byContent.values()].slice(-40); + } + + function mergeRecord(existing, incoming) { + const oldRecord = normalizeRecord(existing); + const newRecord = normalizeRecord(incoming); + if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录'); + const contentMatches = sameContent(oldRecord, newRecord); + const explanation = newRecord.explanation || oldRecord.explanation; + const base = { ...newRecord, explanation }; + const conflicts = [...oldRecord.conflicts, ...newRecord.conflicts]; + const conflictAlready = oldRecord.answerStatus === 'conflict' || newRecord.answerStatus === 'conflict'; + const answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' && answerSignature(oldRecord) !== answerSignature(newRecord); + const contentChanged = !contentMatches && oldRecord.answerStatus !== 'missing'; + if (conflictAlready || answersDiffer || contentChanged) { + const reason = contentChanged ? '同一题目ID的题干或选项发生变化' : '同一题目出现不同或不一致的标准答案'; + if (oldRecord.answerStatus !== 'missing') conflicts.push(conflictSnapshot(oldRecord, reason)); + if (newRecord.answerStatus !== 'missing') conflicts.push(conflictSnapshot(newRecord, reason)); + const answerRecord = newRecord.answerStatus !== 'missing' ? newRecord : oldRecord; + return normalizeRecord({ ...base, answerTexts: answerRecord.answerTexts, correctOptionIds: answerRecord.correctOptionIds, + answerSource: answerRecord.answerSource || answerRecord.source, answerStatus: 'conflict', + conflicts: distinctConflicts(conflicts) }); + } + if (newRecord.answerStatus === 'missing' && oldRecord.answerStatus === 'confirmed') { + // The incoming DOM may use different IDs/order. Keep the original answer's own options and IDs together. + return normalizeRecord({ ...oldRecord, bankName: newRecord.bankName || oldRecord.bankName, explanation, + updatedAt: newRecord.updatedAt, source: newRecord.source, answerSource: oldRecord.answerSource || oldRecord.source }); + } + return normalizeRecord({ ...base, conflicts }); + } + + function upsert(recordsArray, incomingRecords) { + if (!Array.isArray(recordsArray) || !Array.isArray(incomingRecords)) throw new Error('题库必须是数组'); + const records = new Map(); + const lastSeen = new Map(); + for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) { + const record = normalizeRecord(item); + records.set(record.key, records.has(record.key) ? mergeRecord(records.get(record.key), record) : record); + lastSeen.set(record.key, index); + } + // The saved DOM has no stable question ID. Upgrade it only when the complete + // content and bank identify one unique server record in both directions. + const anonymous = [...records.values()].filter((record) => !record.questionId); + const identified = [...records.values()].filter((record) => record.questionId); + const sameBank = (a, b) => { + if (a.bankId && b.bankId) return a.bankId === b.bankId; + if (!a.bankName || !b.bankName || a.bankName === '未命名题库' || b.bankName === '未命名题库') return false; + return normalized(a.bankName) === normalized(b.bankName); + }; + const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && a.type === b.type && sameBank(a, b) && sameContent(a, b); + const candidates = new Map(anonymous.map((record) => [record.key, identified.filter((other) => possiblePair(record, other))])); + for (const record of anonymous) { + const matches = candidates.get(record.key); + if (matches.length !== 1) continue; + const target = matches[0]; + if (anonymous.filter((other) => candidates.get(other.key).some((candidate) => candidate.key === target.key)).length !== 1) continue; + const promoted = normalizeRecord({ ...record, bankId: target.bankId, bankName: target.bankName, questionId: target.questionId }); + const currentTarget = records.get(target.key); + const merged = lastSeen.get(record.key) < lastSeen.get(target.key) ? mergeRecord(promoted, currentTarget) : mergeRecord(currentTarget, promoted); + records.set(target.key, merged); + records.delete(record.key); + } + return [...records.values()]; + } + + function bigrams(value) { + const chars = Array.from(value); + if (chars.length < 2) return new Set(chars); + return new Set(chars.slice(0, -1).map((char, index) => char + chars[index + 1])); + } + + function search(records, query, { limit = 8, field = 'all', offset = 0 } = {}) { + const queryText = clean(query).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, ''); + const needle = normalized(queryText); + if (needle.length < 2) return []; + const selectedField = ['all', 'stem', 'options', 'answer'].includes(field) ? field : 'all'; + const terms = unique(queryText.split(/\s+/u).map(normalized).filter(Boolean)); + const queryGrams = bigrams(needle); + const result = []; + for (const record of records) { + const haystack = normalizedStem(record.stem); + // Each option/answer stays a separate searchable segment. Joining option A + // and option B would create phrases that never existed in either option. + const fields = { + stem: [haystack], + options: (record.options || []).map((option) => normalized(option.text)), + answer: record.answerStatus === 'confirmed' ? (record.answerTexts || []).map(normalized) : [], + }; + const allowed = selectedField === 'all' ? ['stem', 'options', 'answer'] : [selectedField]; + let match, score = 0, matchedField; + const prefer = (candidateField, candidateScore, candidateMatch = 'partial') => { + if (candidateScore > score) { matchedField = candidateField; score = candidateScore; match = candidateMatch; } + }; + if (allowed.includes('stem')) { + if (haystack === needle) prefer('stem', 1, 'exact'); + else if (haystack.includes(needle)) prefer('stem', 0.86 + 0.08 * needle.length / haystack.length); + } + for (const candidateField of ['options', 'answer']) { + if (!allowed.includes(candidateField)) continue; + const matching = fields[candidateField].filter((segment) => segment.includes(needle)); + if (matching.length) { + const coverage = Math.max(...matching.map((segment) => needle.length / segment.length)); + prefer(candidateField, (candidateField === 'options' ? 0.72 : 0.60) + 0.06 * coverage); + } + } + if (terms.length > 1) { + const allIn = (names) => terms.every((term) => names.some((name) => fields[name].some((segment) => segment.includes(term)))); + if (allIn(allowed)) { + if (allowed.includes('stem') && allIn(['stem'])) prefer('stem', 0.82); + else if (allowed.includes('options') && allIn(['options'])) prefer('options', 0.70); + else if (allowed.includes('answer') && allIn(['answer'])) prefer('answer', 0.58); + else { + // Cross-field AND is available only with the explicit all-fields scope. + const firstField = allowed.find((name) => terms.some((term) => fields[name].some((segment) => segment.includes(term)))); + prefer(firstField, firstField === 'stem' ? 0.80 : firstField === 'options' ? 0.68 : 0.56); + } + } + } + if (!match && allowed.includes('stem') && needle.length >= 4 && terms.length === 1) { + const grams = bigrams(haystack); + const intersection = [...queryGrams].filter((gram) => grams.has(gram)).length; + const similarity = 2 * intersection / (queryGrams.size + grams.size || 1); + if (similarity >= 0.45) prefer('stem', similarity * 0.50, 'similar'); + } + if (match) result.push({ record, match, score, matchedField }); + } + const stableKey = (record) => JSON.stringify([record.bankId || '', record.bankName || '', record.questionId || '', record.key || '']); + result.sort((a, b) => b.score - a.score || a.record.stem.localeCompare(b.record.stem, 'zh-CN') || stableKey(a.record).localeCompare(stableKey(b.record), 'zh-CN')); + const start = Math.max(0, Math.floor(Number(offset) || 0)); + const count = Math.max(0, Math.min(100, Math.floor(Number(limit) || 0))); + return result.slice(start, start + count); + } + + function mapAnswers(recordInput, currentInput) { + let record, current; + try { record = normalizeRecord(recordInput); current = normalizeRecord(currentInput); } + catch (_) { return { status: 'unmapped', labels: [], answerTexts: [] }; } + const answerTexts = [...record.answerTexts]; + const result = (status, labels = []) => ({ status, labels, answerTexts }); + if (record.answerStatus === 'conflict') return result('ambiguous'); + if (record.answerStatus !== 'confirmed' || !answerTexts.length) return result('unmapped'); + if (normalizedStem(record.stem) !== normalizedStem(current.stem) || + (record.type !== 'unknown' && current.type !== 'unknown' && record.type !== current.type)) return result('unmapped'); + if (record.options.length && !sameArray(optionContents(record), current.options.map((option) => comparableAnswer(option.text, record.type)))) return result('unmapped'); + const labels = []; + for (const value of answerTexts) { + const expected = comparableAnswer(value, record.type); + const candidates = current.options.filter((option) => comparableAnswer(option.text, record.type) === expected); + if (candidates.length > 1) return result('ambiguous'); + if (!candidates.length) return result('unmapped'); + const originalCorrect = record.options.filter((option) => record.correctOptionIds.includes(option.id) && comparableAnswer(option.text, record.type) === expected); + for (const original of originalCorrect) { + const idCandidates = original.id ? current.options.filter((option) => option.id === original.id) : []; + if (idCandidates.length > 1) return result('ambiguous'); + if (idCandidates.length === 1 && comparableAnswer(idCandidates[0].text, record.type) !== expected) return result('unmapped'); + } + labels.push(candidates[0].label); + } + if (unique(labels).length !== labels.length) return result('ambiguous'); + return result('matched', labels); + } + + function csvSafe(value) { + const result = text(value); + // CSV quoting alone does not stop spreadsheet formula evaluation or long-ID rounding. + return /^(?:\s*[=+\-@]|[\t\r]|\d{16,}$)/u.test(result) ? `'${result}` : result; + } + + function csvRestore(value) { + return value.startsWith("'") && csvSafe(value.slice(1)) === value ? value.slice(1) : value; + } + + function csvCell(value) { return `"${csvSafe(value).replace(/"/g, '""')}"`; } + + function toCSV(records) { + const canonical = records.map(normalizeRecord); + const optionCount = Math.max(0, ...canonical.map((record) => record.options.length)); + const optionNames = Array.from({ length: optionCount }, (_, index) => `选项${index < 26 ? String.fromCharCode(65 + index) : index + 1}`); + const headers = ['来源题库', '题库ID', '题目ID', '题型', '题干', ...optionNames, '正确答案内容', '采集时答案字母', '解析', '答案状态', '答案来源', '采集时间', '完整记录JSON']; + const rows = [headers]; + for (const record of canonical) { + const mapped = mapAnswers(record, record); + rows.push([record.bankName, record.bankId, record.questionId, TYPE_NAMES[record.type], record.stem, + ...Array.from({ length: optionCount }, (_, index) => record.options[index]?.text || ''), + record.answerTexts.join('\n'), mapped.status === 'matched' ? mapped.labels.join(',') : '', record.explanation, + STATUS_NAMES[record.answerStatus], record.answerSource?.kind || record.source.kind, record.answerSource?.observedAt || record.source.observedAt, JSON.stringify(record)]); + } + return '\uFEFF' + rows.map((row) => row.map(csvCell).join(',')).join('\r\n') + '\r\n'; + } + + function parseCSV(input) { + if (typeof input !== 'string') throw new Error('CSV内容必须是文字'); + const value = input.replace(/^\uFEFF/, ''); + const rows = []; + let row = [], cell = '', quoted = false, closed = false; + const finishCell = () => { row.push(cell); cell = ''; closed = false; }; + const finishRow = () => { finishCell(); if (row.some((item) => item !== '')) rows.push(row); row = []; }; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (quoted) { + if (char === '"') { + if (value[index + 1] === '"') { cell += '"'; index += 1; } + else { quoted = false; closed = true; } + } else cell += char; + } else if (char === ',') finishCell(); + else if (char === '\n' || char === '\r') { finishRow(); if (char === '\r' && value[index + 1] === '\n') index += 1; } + else if (char === '"') { + if (cell || closed) throw new Error('CSV引号位置错误'); + quoted = true; + } else { + if (closed) throw new Error('CSV引号后包含非法字符'); + cell += char; + } + } + if (quoted) throw new Error('CSV存在未闭合引号'); + if (cell || row.length || closed) finishRow(); + return rows; + } + + function importedArray(value, type, options) { + const trimmed = clean(value); + if (!trimmed) return []; + if (trimmed.startsWith('[')) { + let result; + try { result = JSON.parse(trimmed); } catch (_) { throw new Error('正确答案内容JSON格式错误'); } + return stringArray(result, '正确答案内容'); + } + if (type !== 'multiple' || options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(trimmed, type))) return [trimmed]; + const answers = trimmed.split(/\r?\n/).map(clean).filter(Boolean); + if (answers.some((answer) => !options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(answer, type)))) throw new Error('多选答案请使用JSON数组,或每行填写一个完整选项内容'); + return answers; + } + + function fromCSV(value) { + const rows = parseCSV(value); + if (!rows.length) throw new Error('CSV为空'); + const headers = rows.shift().map((item) => clean(csvRestore(item))); + if (unique(headers).length !== headers.length) throw new Error('CSV存在重复列名'); + if (!headers.includes('完整记录JSON') && (!headers.includes('题干') || !headers.includes('题型'))) throw new Error('CSV需要“题干、题型”列或“完整记录JSON”列'); + const typeAliases = { ...Object.fromEntries(Object.entries(TYPE_NAMES).map(([key, name]) => [name, key])), 单选: 'single', 多选: 'multiple', 判断: 'judge', single: 'single', multiple: 'multiple', judge: 'judge', unknown: 'unknown' }; + const statusAliases = { ...Object.fromEntries(Object.entries(STATUS_NAMES).map(([key, name]) => [name, key])), confirmed: 'confirmed', missing: 'missing', conflict: 'conflict', 无答案: 'missing', 待核验: 'missing' }; + const records = []; + for (let index = 0; index < rows.length; index += 1) { + const cells = rows[index]; + try { + if (cells.length !== headers.length) throw new Error(`列数应为${headers.length},实际为${cells.length}`); + const row = Object.fromEntries(headers.map((header, column) => [header, csvRestore(cells[column])])); + if (clean(row['完整记录JSON'])) { + let raw; + try { raw = JSON.parse(row['完整记录JSON']); } catch (_) { throw new Error('完整记录JSON格式错误'); } + records.push(normalizeRecord(raw)); + continue; + } + const type = typeAliases[clean(row['题型'])]; + if (!type) throw new Error('题型必须为单选题、多选题、判断题或未知'); + const options = headers.filter((header) => /^选项(?:[A-Z]+|\d+)$/u.test(header) && clean(row[header])).map((header) => ({ id: '', label: header.slice(2), text: row[header] })); + const answers = importedArray(row['正确答案内容'], type, options); + const statusText = clean(row['答案状态']); + if (statusText && !own(statusAliases, statusText)) throw new Error('无效答案状态'); + const answerStatus = statusText ? statusAliases[statusText] : answers.length ? 'confirmed' : 'missing'; + const record = normalizeRecord({ bankId: row['题库ID'], bankName: row['来源题库'], questionId: row['题目ID'], type, + stem: row['题干'], options, answerTexts: answers, answerStatus, explanation: row['解析'], + source: { kind: clean(row['答案来源']) || 'csv-import', observedAt: row['采集时间'] } }); + if (record.answerStatus === 'confirmed' && record.options.length && mapAnswers(record, record).status !== 'matched') throw new Error('正确答案内容不能唯一对应选项'); + records.push(record); + } catch (error) { throw new Error(`CSV第${index + 2}行:${error.message}`); } + } + return records; + } + + return { normalizeRecord, mergeRecord, upsert, search, mapAnswers, toCSV, fromCSV }; +})(); + +if (typeof module !== 'undefined' && module.exports) module.exports = QBCore; + + +// ---- src/adapter.js ---- +/* Site adapter: observe existing practice responses; never submit or alter answers. */ +const QBAdapter = (() => { + 'use strict'; + const TYPES = { 0: 'single', 1: 'multiple', 2: 'judge' }; + const ROOT_SELECTOR = '.yxtulcdsdk-pc-marking-main'; + const MAX_RESPONSE = 8 * 1024 * 1024; + const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); + const text = value => String(value == null ? '' : value) + .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '') + .replace(//gi, '\n').replace(/<\/?[a-z][^>]*>/gi, '') + .replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { + const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n); + return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : ''; + }).replace(/&(nbsp|lt|gt|amp|quot|apos);/g, (_, name) => + ({ nbsp: ' ', lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" })[name]) + .replace(/\u00a0/g, ' ').trim(); + const flat = value => text(value).replace(/\s+/g, ''); + const idString = value => typeof value === 'string' || Number.isSafeInteger(value) ? String(value) : ''; + const signature = q => [q.type, flat(q.stem), (q.options || []).map(o => flat(o.text)).sort().join('\u001f')].join('\u001e'); + const labelFor = index => String.fromCharCode(65 + index); + + function standardAnswer(node, type, options, context) { + const result = node.resultInfo && typeof node.resultInfo === 'object' ? node.resultInfo : null; + const answerNode = result || node; + if (answerNode.allowViewSingleQuesAnswer === false || answerNode.allowViewSingleQuesAnswer === 0) { + return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' }; + } + if (type === 'judge') { + const raw = answerNode.judgeAnswer; + if (raw === 0 || raw === '0' || raw === 1 || raw === '1') { + const value = String(raw); + return { correctOptionIds: [value], answerTexts: [value === '1' ? '正确' : '错误'], answerStatus: 'confirmed' }; + } + } else { + let ids = []; + if (Array.isArray(node.correctAnswers)) ids = node.correctAnswers.map(idString).filter(Boolean); + else if (result && Array.isArray(result.answers)) ids = result.answers.map(idString).filter(Boolean); + else if (context.answerResponse && Array.isArray(node.answers)) ids = node.answers.map(idString).filter(Boolean); + else if (Array.isArray(node.choiceItems)) ids = node.choiceItems.filter(o => o.answer === 1).map(o => idString(o.id)); + ids = [...new Set(ids)].filter(Boolean); + const resolved = ids.map(id => options.filter(o => o.id === id)); + if (ids.length && resolved.every(matches => matches.length === 1) && (type !== 'single' || ids.length === 1)) { + return { correctOptionIds: ids, answerTexts: resolved.map(matches => matches[0].text), answerStatus: 'confirmed' }; + } + } + return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' }; + } + + function knownById(context, id) { + if (!id || !context.knownQuestions) return null; + const values = context.knownQuestions instanceof Map ? [...context.knownQuestions.values()] : context.knownQuestions; + if (!Array.isArray(values)) return null; + const matches = values.filter(q => q.questionId === id && (!context.bankId || !q.bankId || q.bankId === context.bankId)); + return matches.length === 1 ? matches[0] : null; + } + + function recordFromNode(node, context) { + const rawType = has(node, 'quesType') ? node.quesType : node.type; + let type = TYPES[rawType]; + const id = idString(node.quesId || node.questionId || node.id || context.questionId); + const previous = knownById(context, id); + if (!type && previous) type = previous.type; + if (!type) return null; + let stem = text(node.content); + if (!stem && previous) stem = previous.stem; + if (!stem || stem.length > 100000) return null; + // Do not treat an arbitrary content/type business object as a question. + if (!previous && !has(node, 'quesType') && !Array.isArray(node.choiceItems) && !has(node, 'judgeAnswer')) return null; + let options; + if (type === 'judge') { + options = [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }]; + } else if (Array.isArray(node.choiceItems)) { + if (node.choiceItems.some(option => !option || typeof option !== 'object')) return null; + options = node.choiceItems.slice(0, 100).map((option, index) => ({ + id: idString(option.id), text: text(option.content), label: labelFor(index) + })); + if (options.length < 2 || options.some(o => !o.text)) return null; + } else if (previous) options = previous.options.map(o => ({ ...o })); + else return null; + const answers = standardAnswer(node, type, options, context); + return { + bankId: idString(node.praId || context.bankId || (previous && previous.bankId)), + bankName: text(context.bankName || (previous && previous.bankName)), questionId: id, + type, stem, options, ...answers, + explanation: text((node.resultInfo && node.resultInfo.explainText) || node.explainText), + source: { kind: context.kind || 'network', url: String(context.url || ''), observedAt: context.observedAt || new Date().toISOString() } + }; + } + + function extractQuestions(payload, context = {}) { + const output = []; + const visited = new WeakSet(); + let count = 0; + const childKeys = ['data', 'result', 'listQues', 'questionList', 'questions', 'subQuesList', 'list', 'items']; + function visit(node, inherited, depth) { + if (!node || typeof node !== 'object' || depth > 12 || ++count > 16000 || visited.has(node)) return; + visited.add(node); + if (Array.isArray(node)) { + const arrayContext = inherited.answerResponse && node.length !== 1 ? { ...inherited, questionId: '' } : inherited; + for (const item of node.slice(0, 10000)) visit(item, arrayContext, depth + 1); + return; + } + const next = { ...inherited, bankId: idString(node.praId || inherited.bankId), bankName: text(node.praName || inherited.bankName) }; + const candidate = recordFromNode(node, next); + if (candidate) output.push(candidate); + for (const key of childKeys) if (has(node, key)) visit(node[key], next, depth + 1); + } + visit(payload, context, 0); + const unique = new Map(); + for (const q of output) { + const key = `${q.bankId}\u001f${q.questionId || signature(q)}`; + const old = unique.get(key); + if (!old || (old.answerStatus === 'missing' && q.answerStatus === 'confirmed')) unique.set(key, q); + } + return [...unique.values()]; + } + + function classifyURL(value, base) { + try { + const url = new URL(value, base); + const host = url.hostname; + const allowed = host === 'www.shhuayi-elearning.cn' || host === 'shhuayi-elearning.cn' || + /(^|\.)(yunxuetang\.cn|yxt\.com)$/.test(host) || url.origin === new URL(base).origin; + if (!allowed) return null; + const path = url.pathname; + const list = /\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/.test(path); + const answerMatch = path.match(/\/upm\/([^/]+)\/users\/[^/]+\/(?:wrong\/)?answer\/?$/); + if (!list && !answerMatch) return null; + const praMatch = path.match(/\/pra\/([^/]+)\/questions/); + return { + // Retain no token-bearing query parameters in saved source URLs. + url: url.origin + url.pathname, + bankId: url.searchParams.get('praId') || (answerMatch && answerMatch[1]) || (praMatch && praMatch[1]) || '', + answerResponse: Boolean(answerMatch) + }; + } catch (_) { return null; } + } + + function requestQuestionId(body) { + try { + if (typeof body !== 'string' || body.length > 1024 * 1024) return ''; + const value = JSON.parse(body); + return Array.isArray(value.answers) && value.answers.length === 1 ? idString(value.answers[0].quesId) : ''; + } catch (_) { return ''; } + } + + function create({ window: win, document: doc, onQuestions = () => {}, onStatus = () => {}, onBankChange = () => {} }) { + let active = false; + let copyEnabled = false; + let observer = null; + let timer = null; + let copyStyle = null; + let fetchOriginal = null; + let fetchWrapped = null; + let xhrOpenOriginal = null; + let xhrSendOriginal = null; + let xhrOpenWrapped = null; + let xhrSendWrapped = null; + let lastDOMSignature = ''; + let networkGeneration = 0; + const known = new Map(); + const xhrContext = new WeakMap(); + const pendingXHR = new Set(); + const banksById = new Map(); + const banksByTask = new Map(); + let currentBank = null; + let pendingSelection = ''; + let blockedForm = null; + let lastFormSnapshot = null; + let bankListeners = false; + const status = message => { try { onStatus(message); } catch (_) { /* UI callbacks must not affect the page. */ } }; + const locationURL = () => String(win.location && win.location.href || ''); + + function formSnapshot() { + const element = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].find(visible); + return element ? { element, text: flat(element.textContent) } : null; + } + + function taskFromURL() { + try { + const url = new URL(locationURL()); + const hashQuery = url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : ''; + return new URLSearchParams(hashQuery).get('taskId') || url.searchParams.get('taskId') || ''; + } catch (_) { return ''; } + } + + function bankDOM() { + const selected = doc.querySelector('.task-content-active .group-title, .task-content-active .task-content-color-active'); + const selectedName = text(selected?.textContent); + if (pendingSelection && flat(pendingSelection) === flat(selectedName)) pendingSelection = ''; + const sidebarName = pendingSelection || selectedName; + const playerName = text(doc.querySelector('header.o2oPlayFrame-right .title .yxtf-tooltip')?.textContent); + const examName = text(doc.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip')?.textContent); + const container = doc.querySelector('.yxtulcdsdk-exam-container[task-id]'); + const containerTask = idString(container?.getAttribute('task-id')); + const routeTask = taskFromURL(); + const selectedTask = idString(selected?.closest('.task-content-active')?.getAttribute('data-task-id')); + const pending = pendingSelection && flat(pendingSelection) !== flat(selectedName); + const taskId = pending ? '' : selectedTask || routeTask || containerTask; + const bankName = text(sidebarName || playerName || examName || String(doc.title || '').replace(/ - 华谊集团$/, '')); + const mismatch = Boolean(pending || (sidebarName && playerName && flat(sidebarName) !== flat(playerName)) || + (sidebarName && examName && flat(sidebarName) !== flat(examName)) || + (playerName && examName && flat(playerName) !== flat(examName)) || + (routeTask && containerTask && routeTask !== containerTask)); + return { bankName, taskId, mismatch }; + } + + function syncBank() { + const state = bankDOM(); + const identity = JSON.stringify([state.taskId, flat(state.bankName)]); + const oldIdentity = currentBank && JSON.stringify([currentBank.taskId, flat(currentBank.bankName)]); + const snapshot = formSnapshot(); + const stillPreviousForm = snapshot && lastFormSnapshot && snapshot.element === lastFormSnapshot.element && snapshot.text === lastFormSnapshot.text; + // During a SPA transition the sidebar changes before the old question is + // replaced. Keep that old form out of the new bank even if titles update first. + if (currentBank && identity !== oldIdentity && flat(currentBank.bankName) !== flat(state.bankName)) { + blockedForm = stillPreviousForm ? snapshot : null; + lastDOMSignature = ''; + } else if (currentBank && identity !== oldIdentity && currentBank.taskId && state.taskId && currentBank.taskId !== state.taskId) { + if (!(currentBank.loading && !blockedForm)) blockedForm = stillPreviousForm ? snapshot : null; + lastDOMSignature = ''; + } + if (blockedForm && (!snapshot || blockedForm.element !== snapshot.element || blockedForm.text !== snapshot.text)) blockedForm = null; + let bankId = ''; + const taskBank = state.taskId && banksByTask.get(state.taskId); + if (taskBank && flat(taskBank.bankName) === flat(state.bankName)) bankId = taskBank.bankId; + if (!bankId && !state.taskId) { + const named = [...banksById.values()].filter(bank => flat(bank.bankName) === flat(state.bankName)); + if (named.length === 1) bankId = named[0].bankId; + } + const next = { bankId, bankName: state.bankName, taskId: state.taskId, loading: state.mismatch || Boolean(blockedForm) }; + const changed = !currentBank || JSON.stringify(next) !== JSON.stringify(currentBank); + currentBank = next; + lastFormSnapshot = snapshot; + if (changed) { + try { onBankChange({ ...next }); } catch (_) { /* Consumers must not affect site navigation. */ } + } + return { ...next }; + } + + function getCurrentBank() { return syncBank(); } + + function ensureBankListeners() { + if (bankListeners) return; + bankListeners = true; + win.addEventListener?.('hashchange', syncBank); + win.addEventListener?.('popstate', syncBank); + win.addEventListener?.('click', event => { + const item = event.target?.closest?.('.task-content, .task-content-active'); + const name = text(item?.querySelector('.group-title')?.textContent); + if (name && flat(name) !== flat(currentBank?.bankName)) { pendingSelection = name; syncBank(); } + }, true); + } + + function requestContext(context) { + const current = syncBank(); + const saved = context.bankId && banksById.get(context.bankId); + const sameTask = saved && saved.taskId && saved.taskId === current.taskId; + const useCurrent = !saved || sameTask || (!context.answerResponse && !current.loading); + context.bankName = useCurrent ? current.bankName : saved.bankName; + context.taskId = useCurrent ? current.taskId : saved.taskId; + return context; + } + + function rememberBanks(context, records) { + const distinct = new Map(); + for (const record of records) if (record.bankId) distinct.set(record.bankId, record); + for (const record of distinct.values()) { + const name = record.bankName || context.bankName; + const taskId = flat(name) === flat(context.bankName) ? context.taskId || '' : ''; + const value = { bankId: record.bankId, bankName: name, taskId }; + banksById.set(record.bankId, value); + if (value.taskId && distinct.size === 1) banksByTask.set(value.taskId, value); + } + } + + function emit(records) { + if (!records.length) return; + for (const q of records) { + if (q.questionId) known.set(`${q.bankId}\u001f${q.questionId}`, q); + } + try { onQuestions(records); } catch (error) { status(`保存题目失败:${error.message || error}`); } + } + + function visible(node) { + if (!node || node.closest('#huayi-qb-root')) return false; + for (let at = node; at && at.nodeType === 1; at = at.parentElement) { + if (at.hidden || at.getAttribute('aria-hidden') === 'true' || at.style?.display === 'none') return false; + } + return true; + } + + function getCurrentQuestion() { + const bank = syncBank(); + if (bankDOM().mismatch) return null; + const forms = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].filter(visible); + if (forms.length !== 1) return null; + const form = forms[0]; + const content = form.querySelector('.yxtulcdsdk-form__content'); + const stemNode = content && [...content.children].find(n => n.matches('[data-rich-text]')); + const stem = stemNode && text(stemNode.textContent); + if (!stem) return null; + const labels = [...form.querySelectorAll('.yxtulcdsdk-ques-radio, .yxtulcdsdk-ques-checkbox')]; + if (labels.length < 2) return null; + const options = labels.map((label, index) => { + const input = label.querySelector('input'); + const labelNode = label.querySelector('.yxtf-radio__label, .yxtf-checkbox__label'); + const rich = labelNode && labelNode.querySelector('[data-rich-text]'); + const full = text((labelNode || label).textContent); + return { id: input ? idString(input.value) : '', text: text(rich ? rich.textContent : full.replace(/^[A-Z][.、.]\s*/, '')), label: labelFor(index) }; + }); + let type = form.querySelector('input[type=checkbox], [role=checkbox]') ? 'multiple' : 'single'; + if (options.length === 2 && options.some(o => o.text === '正确') && options.some(o => o.text === '错误')) { + type = 'judge'; + options.forEach(o => { o.label = o.text; }); + } + const q = { + bankId: bank.bankId, bankName: bank.bankName, questionId: '', type, stem, options, + correctOptionIds: [], answerTexts: [], answerStatus: 'missing', explanation: '', + source: { kind: 'dom', url: locationURL().split('?')[0].split('#')[0], observedAt: new Date().toISOString() } + }; + const matches = [...known.values()].filter(item => signature(item) === signature(q) && + (q.bankId ? item.bankId === q.bankId : flat(item.bankName) === flat(q.bankName))); + if (blockedForm && (!bank.bankId || matches.length !== 1)) return null; + if (matches.length === 1) { + q.questionId = matches[0].questionId; + q.bankId = matches[0].bankId; + if (blockedForm) { blockedForm = null; syncBank(); } + } + // Only a row explicitly labelled standard answer can establish correctness. + const main = form.closest(ROOT_SELECTOR); + const rows = [...main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible); + for (const row of rows) { + const heading = text(row.querySelector('.yxtulcdsdk-examing__result-line-left')?.textContent); + const answer = text(row.querySelector('.yxtulcdsdk-examing__result-line-right')?.textContent); + if (heading === '正确答案' || heading === '标准答案') { + let selected = []; + if (type === 'judge') selected = options.filter(o => o.text === answer); + else if (/^[A-Z](?:[、,,\s]*[A-Z])*$/.test(answer)) { + const requested = [...new Set(answer.match(/[A-Z]/g))]; + selected = requested.map(letter => options.find(o => o.label === letter)).filter(Boolean); + if (selected.length !== requested.length) selected = []; + } + if (selected.length && (type !== 'single' || selected.length === 1)) { + q.correctOptionIds = selected.map(o => o.id).filter(Boolean); + q.answerTexts = selected.map(o => o.text); + q.answerStatus = 'confirmed'; + } + } else if (heading === '解析' || heading === '试题解析' || heading === '答案解析') q.explanation = answer; + } + return q; + } + + function captureCurrent() { + const current = getCurrentQuestion(); + if (current) emit([current]); + else status('当前页面未发现可采集的练习题,请进入题目页面。'); + return current; + } + + function observePayload(payload, context) { + if (!active) return; + try { + const records = extractQuestions(payload, { ...context, knownQuestions: known }); + rememberBanks(context, records); + emit(records); + syncBank(); + if (records.length) { + const names = [...new Set(records.map(record => record.bankName).filter(Boolean))]; + const sourceName = names.length === 1 ? names[0] : context.bankName || '来源题库'; + status(`已读取「${sourceName}」${records.length} 道题,标准答案 ${records.filter(q => q.answerStatus === 'confirmed').length} 道。`); + } + } catch (_) { status('本次响应结构暂未识别,题目仍可从页面采集。'); } + } + + function installNetwork() { + const generation = ++networkGeneration; + if (typeof win.fetch === 'function') { + const originalFetch = win.fetch; + fetchOriginal = originalFetch; + fetchWrapped = function (...args) { + const request = args[0]; + const requestURL = typeof request === 'string' || (request && typeof request.href === 'string') ? String(request) : request && request.url; + const context = classifyURL(requestURL, locationURL()); + if (context) { + requestContext(context); + context.questionId = requestQuestionId(args[1] && args[1].body); + } + const promise = originalFetch.apply(this, args); + if (context) Promise.resolve(promise).then(response => { + if (!active || generation !== networkGeneration || !response.ok) return; + const contentType = response.headers.get('content-type') || ''; + const length = Number(response.headers.get('content-length') || 0); + if (!/\bjson\b/i.test(contentType) || length > MAX_RESPONSE) return; + response.clone().text().then(body => { + if (active && generation === networkGeneration && body.length <= MAX_RESPONSE) observePayload(JSON.parse(body), context); + }).catch(() => {}); + }).catch(() => {}); + return promise; + }; + win.fetch = fetchWrapped; + } + const proto = win.XMLHttpRequest && win.XMLHttpRequest.prototype; + if (!proto) return; + const originalOpen = proto.open; + const originalSend = proto.send; + xhrOpenOriginal = originalOpen; + xhrSendOriginal = originalSend; + xhrOpenWrapped = function (method, url, ...args) { + const result = originalOpen.call(this, method, url, ...args); + const context = classifyURL(url, locationURL()); + if (context) requestContext(context); + xhrContext.set(this, context); + return result; + }; + xhrSendWrapped = function (body) { + const context = xhrContext.get(this); + if (active && generation === networkGeneration && context) { + context.questionId = requestQuestionId(body); + const xhr = this; + const listener = () => { + pendingXHR.delete(entry); + if (!active || generation !== networkGeneration || xhr.status < 200 || xhr.status >= 300) return; + try { + const type = xhr.getResponseHeader('content-type') || ''; + const length = Number(xhr.getResponseHeader('content-length') || 0); + if (!/\bjson\b/i.test(type) || length > MAX_RESPONSE) return; + if (xhr.responseType === 'json') observePayload(xhr.response, context); + else if (!xhr.responseType || xhr.responseType === 'text') { + const bodyText = xhr.responseText; + if (bodyText.length <= MAX_RESPONSE) observePayload(JSON.parse(bodyText), context); + } + } catch (_) { /* Unsupported payloads never interfere with the site's requests. */ } + }; + const entry = { xhr, listener }; + pendingXHR.add(entry); + xhr.addEventListener('load', listener, { once: true }); + } + return originalSend.call(this, body); + }; + proto.open = xhrOpenWrapped; + proto.send = xhrSendWrapped; + } + + function uninstallNetwork() { + networkGeneration++; + if (fetchWrapped && win.fetch === fetchWrapped) win.fetch = fetchOriginal; + const proto = win.XMLHttpRequest && win.XMLHttpRequest.prototype; + if (proto && proto.open === xhrOpenWrapped) proto.open = xhrOpenOriginal; + if (proto && proto.send === xhrSendWrapped) proto.send = xhrSendOriginal; + for (const { xhr, listener } of pendingXHR) xhr.removeEventListener('load', listener); + pendingXHR.clear(); + } + + function copyEvent(event) { + const target = event.target && (event.target.nodeType === 1 ? event.target : event.target.parentElement); + if (!copyEnabled || !target?.closest(ROOT_SELECTOR) || target.closest('#huayi-qb-root')) return; + // Preserve the browser default copy/selection action, suppress only downstream blockers. + event.stopImmediatePropagation(); + } + + function ensureCopyStyle() { + if (!copyEnabled || copyStyle?.isConnected) return; + const parent = doc.head || doc.documentElement; + if (!parent) return; + copyStyle = doc.createElement('style'); + copyStyle.id = 'huayi-qb-copy-style'; + copyStyle.textContent = `${ROOT_SELECTOR},${ROOT_SELECTOR} *{-webkit-user-select:text!important;user-select:text!important}`; + parent.appendChild(copyStyle); + } + + function ensureObserver() { + if (observer || !win.MutationObserver) return; + observer = new win.MutationObserver(mutations => { + const relevant = mutations.some(m => { + const node = m.target.nodeType === 1 ? m.target : m.target.parentElement; + return node && !node.closest('#huayi-qb-root') && node.id !== 'huayi-qb-copy-style'; + }); + if (!relevant) return; + syncBank(); + if (copyEnabled) ensureCopyStyle(); + if (!active) return; + win.clearTimeout(timer); + timer = win.setTimeout(() => { + const q = getCurrentQuestion(); + if (!q) return; + const next = JSON.stringify([q.bankId, q.questionId, signature(q), q.correctOptionIds, q.answerTexts, q.explanation]); + if (next !== lastDOMSignature) { lastDOMSignature = next; emit([q]); } + }, 180); + }); + observer.observe(doc, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'task-id', 'data-task-id'] }); + } + + function setCopyEnabled(value) { + copyEnabled = Boolean(value); + for (const type of ['copy', 'cut', 'contextmenu', 'selectstart']) { + win.removeEventListener(type, copyEvent, true); + if (copyEnabled) win.addEventListener(type, copyEvent, true); + } + if (copyEnabled) { ensureCopyStyle(); ensureObserver(); } + else if (copyStyle) { copyStyle.remove(); copyStyle = null; } + ensureBankListeners(); + ensureObserver(); + syncBank(); + } + + function start() { + if (active) return; + active = true; + ensureBankListeners(); + ensureObserver(); + installNetwork(); + captureCurrent(); + status('采集已开启;切换练习或正常作答时,自动保存收到的题目与标准答案。'); + } + + function stop() { + active = false; + win.clearTimeout(timer); + uninstallNetwork(); + status('采集已暂停,已保存题库和选择复制功能仍可使用。'); + } + + return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled }; + } + + return { create, extractQuestions }; +})(); +if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter; + + +// ---- src/app.js ---- +// UI and persistence stay inside the userscript sandbox. Page data is rendered as text. +(() => { + const CONFIG_KEY = 'huayi-qb.config.v1'; + const RECORDS_KEY = 'huayi-qb.records.v1'; + const DEFAULTS = { mode: 'open', keepLauncher: true, copyEnabled: true, collecting: true, tab: 'search' }; + const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; + const storage = { + get(key, fallback) { + if (typeof GM_getValue === 'function') return GM_getValue(key, fallback); + const raw = localStorage.getItem(key); + return raw === null ? fallback : JSON.parse(raw); + }, + set(key, value) { + if (typeof GM_setValue === 'function') return GM_setValue(key, value); + localStorage.setItem(key, JSON.stringify(value)); + } + }; + let rawConfig; + try { rawConfig = storage.get(CONFIG_KEY, {}); } catch (_) { rawConfig = {}; } + const config = { ...DEFAULTS, ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}) }; + if (!['open', 'collapsed', 'hidden'].includes(config.mode)) config.mode = 'open'; + if (!['search', 'library', 'settings'].includes(config.tab)) config.tab = 'search'; + ['keepLauncher', 'copyEnabled', 'collecting'].forEach(key => { if (typeof config[key] !== 'boolean') config[key] = DEFAULTS[key]; }); + let records = []; + let loadError = ''; + try { + const saved = storage.get(RECORDS_KEY, []); + if (!Array.isArray(saved)) throw new Error('本地题库格式不正确'); + records = QBCore.upsert([], saved); + } catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; } + let root, shadow, panel, launcher, resultBox, statusBox; + let saveTimer, searchTimer, query = '', scope = '__current__', searchScope = '__current__', searchField = 'all'; + let activeBank = null, importing = false; + let status = loadError || '准备就绪,可输入文字搜索,也可选中题干检索。'; + let storageError = false; + let mounted = false; + const adapter = QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged }); + + function bankChanged(bank) { + if (!bank || (!bank.bankName && !bank.bankId)) return; + const previous = activeBank; + const sameName = previous && bankNameKey(previous.bankName) === bankNameKey(bank.bankName); + const identityChanged = previous && (!sameName || (previous.taskId && bank.taskId && previous.taskId !== bank.taskId) || (previous.bankId && bank.bankId && previous.bankId !== bank.bankId)); + const changed = JSON.stringify(previous) !== JSON.stringify(bank); + activeBank = { ...bank }; + if (identityChanged) { scope = '__current__'; searchScope = '__current__'; } + if (mounted && changed) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } + } + + function adapterStatus(value) { + const message = typeof value === 'string' ? value : value && value.message; + if (message && !storageError) { status = message; renderStatus(); } + } + function persistConfig() { + try { storage.set(CONFIG_KEY, { ...config }); } + catch (error) { tell(`设置保存失败:${error.message}`, true); } + } + function persistRecords() { + clearTimeout(saveTimer); + if (loadError) return; + try { + // Merge other tabs before writing; GM change events also refresh an open panel. + const stored = storage.get(RECORDS_KEY, []); + if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖'); + records = QBCore.upsert(stored, records); + storage.set(RECORDS_KEY, records); + storageError = false; + } catch (error) { + storageError = true; + tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true); + } + } + function receive(incoming) { + if (!Array.isArray(incoming) || !incoming.length || loadError) return; + try { + const namesById = new Map(); + incoming.forEach(record => { + if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return; + if (!namesById.has(record.bankId)) namesById.set(record.bankId, new Set()); + namesById.get(record.bankId).add(record.bankName); + }); + // A newly observed, explicit bank ID/name pair can repair an old stale + // title for that same ID. Never infer a rename from question type or text. + records = records.map(record => { + const names = record.bankId && namesById.get(record.bankId); + return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record; + }); + records = QBCore.upsert(records, incoming); + clearTimeout(saveTimer); + saveTimer = setTimeout(persistRecords, 250); + if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); } + } catch (error) { tell(`这批题目未保存:${error.message}`, true); } + } + function tell(message, error = false) { + status = message; + if (statusBox) statusBox.dataset.error = String(error); + renderStatus(); + } + function renderStatus() { + if (statusBox) statusBox.textContent = status; + } + function node(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; + } + function setMode(mode) { + config.mode = mode; + persistConfig(); + renderMode(); + if (mode === 'open' && config.tab === 'search') shadow?.getElementById('qb-search').focus(); + } + function renderMode() { + if (!panel) return; + panel.hidden = config.mode === 'hidden'; + panel.classList.toggle('collapsed', config.mode === 'collapsed'); + launcher.hidden = config.mode !== 'hidden' || !config.keepLauncher; + const collapse = shadow.querySelector('[data-action="collapse"]'); + collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起'; + collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed')); + } + function selectTab(tab) { + config.tab = tab; + persistConfig(); + shadow.querySelectorAll('[data-tab]').forEach(button => { + const selected = button.dataset.tab === tab; + button.setAttribute('aria-selected', String(selected)); + button.classList.toggle('active', selected); + }); + shadow.querySelectorAll('[data-view]').forEach(view => { view.hidden = view.dataset.view !== tab; }); + } + function bankKey(record) { return String(record.bankId || record.bankName || ''); } + function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); } + function bankCatalog() { + const namedIds = new Map(); + records.forEach(record => { + if (!record.bankId) return; + const name = bankNameKey(record.bankName); + if (!namedIds.has(name)) namedIds.set(name, new Set()); + namedIds.get(name).add(record.bankId); + }); + const catalog = new Map(); + records.forEach(record => { + const ids = namedIds.get(bankNameKey(record.bankName)); + const key = !record.bankId && ids?.size === 1 ? [...ids][0] : bankKey(record); + if (!catalog.has(key)) catalog.set(key, { key, name: record.bankName || record.bankId || '未命名题库', count: 0 }); + catalog.get(key).count++; + }); + return [...catalog.values()]; + } + function currentQuestion() { + try { return adapter.getCurrentQuestion(); } catch (_) { return null; } + } + function currentBank() { + try { return adapter.getCurrentBank?.() || activeBank || currentQuestion(); } + catch (_) { return activeBank; } + } + function sameBankScope(record, anchor, knownIds) { + if (record.bankId && anchor.bankId) return record.bankId === anchor.bankId; + const name = String(anchor.bankName || '').replace(/\s+/g, ''); + if (!name || name === '未命名题库' || String(record.bankName || '').replace(/\s+/g, '') !== name) return false; + const namedIds = knownIds || new Set(records.filter(item => item.bankId && bankNameKey(item.bankName) === name).map(item => item.bankId)); + if (anchor.bankId) namedIds.add(anchor.bankId); + // A human CSV often has a bank name only. Include it only when that name + // identifies one known bank; identical names across bank IDs stay separate. + return namedIds.size <= 1; + } + function scopedRecords(selectedScope = scope) { + if (!selectedScope) return records; + const isCurrent = selectedScope === '__current__'; + const anchor = isCurrent ? currentBank() : records.find(record => bankKey(record) === selectedScope); + if (!anchor) return []; + const namedIds = new Set(records.filter(item => item.bankId && bankNameKey(item.bankName) === bankNameKey(anchor.bankName)).map(item => item.bankId)); + if (anchor.bankId) namedIds.add(anchor.bankId); + return records.filter(record => (!isCurrent && bankKey(record) === selectedScope) || sameBankScope(record, anchor, namedIds)); + } + function scopeName(selectedScope = scope) { + if (selectedScope === '') return '全部题库'; + if (selectedScope === '__current__') return currentBank()?.bankName || '未识别当前题库'; + const anchor = records.find(record => bankKey(record) === selectedScope); + return anchor?.bankName || '未命名题库'; + } + function refreshScope() { + if (!shadow) return; + const banks = new Map(bankCatalog().map(bank => [bank.key, bank.name])); + if (scope && scope !== '__current__' && !banks.has(scope)) scope = '__current__'; + if (searchScope && searchScope !== '__current__' && !banks.has(searchScope)) searchScope = '__current__'; + const names = [...banks.values()]; + const options = [['__current__', `当前题库:${scopeName('__current__')}`], ['', `全部题库(${records.length}题)`], ...[...banks].map(([key, name]) => [key, names.filter(value => value === name).length > 1 ? `${name}(${key === name ? '无ID' : key})` : name])]; + ['qb-scope', 'qb-search-scope'].forEach(id => { + const select = shadow.getElementById(id); + const signature = JSON.stringify(options); + if (select.dataset.signature !== signature) { + select.dataset.signature = signature; + select.replaceChildren(); + options.forEach(([value, label]) => { + const option = node('option', '', label); option.value = value; option.title = label; select.append(option); + }); + } + select.value = id === 'qb-scope' ? scope : searchScope; + }); + } + function renderStats() { + if (!shadow) return; + const selected = scopedRecords(); + const confirmed = selected.filter(record => record.answerStatus === 'confirmed').length; + const conflicts = selected.filter(record => record.answerStatus === 'conflict').length; + [['total', selected.length], ['confirmed', confirmed], ['missing', selected.length - confirmed - conflicts], ['conflicts', conflicts]].forEach(([name, count]) => { + shadow.getElementById(`qb-stat-${name}`).textContent = count.toLocaleString('zh-CN'); + }); + const exportName = scopeName(); + shadow.getElementById('qb-export-name').textContent = exportName; + shadow.getElementById('qb-export-name').title = exportName; + const catalog = bankCatalog(); + shadow.getElementById('qb-library-summary').textContent = `本地共保存 ${catalog.length} 个题库、${records.length} 道题。上方统计与导出均按所选范围。`; + const bankList = shadow.getElementById('qb-bank-list'); + bankList.replaceChildren(); + catalog.forEach(bank => { + const item = node('div', 'bank-row'); + item.append(node('span', '', bank.name), node('strong', '', `${bank.count} 题`)); + bankList.append(item); + }); + shadow.getElementById('qb-export-preview').textContent = selected.length ? `文件名:${exportFilename(exportName, selected.length, 'csv')}` : '该范围暂无题目;不会导出其他题库。'; + const button = shadow.querySelector('[data-action="toggle-capture"]'); + button.textContent = config.collecting ? '暂停采集' : '继续采集'; + const indicator = shadow.getElementById('qb-capture-state'); + indicator.textContent = config.collecting ? '采集中' : '已暂停'; + indicator.classList.toggle('paused', !config.collecting); + } + function renderCurrent() { + if (!shadow) return; + const bank = currentBank(); + const current = currentQuestion(); + const name = bank?.bankName || '尚未识别当前题库'; + shadow.getElementById('qb-context-name').textContent = name; + shadow.getElementById('qb-context-name').title = name; + const pool = scopedRecords('__current__'); + shadow.getElementById('qb-context-count').textContent = `${bank?.loading ? '切换中 · ' : ''}本库已采集 ${pool.length} 题 · 已确认 ${pool.filter(record => record.answerStatus === 'confirmed').length} 题`; + shadow.getElementById('qb-current-title').textContent = bank?.loading ? '题库切换中,等待当前题目加载。' : current?.stem || '打开练习题后,这里显示当前题目。'; + shadow.getElementById('qb-current-bank').textContent = name; + } + function renderResults() { + if (!resultBox) return; + resultBox.replaceChildren(); + const pool = scopedRecords(searchScope); + const matches = query.trim() ? QBCore.search(pool, query, { limit: 50, field: searchField }) : []; + const searchName = scopeName(searchScope); + shadow.getElementById('qb-query-state').textContent = query.trim() ? `${searchName} · ${pool.length} 题 · 显示 ${matches.length} 个结果${matches.length === 50 ? '(最多50条,请细化关键词)' : ''}` : `${searchName} · 可输入题干、选项或标准答案关键词`; + if (!matches.length) { + const empty = node('div', 'empty'); + empty.append(node('strong', '', query.trim() ? '没有找到匹配题目' : '输入文字,查找正确答案'), node('p', '', query.trim() ? '可切换到全部题库、更换关键词,或导入其他题库。' : '支持输入、粘贴或划选文字;按回车或点搜索查询。多个关键词可用空格分隔。')); + resultBox.append(empty); + return; + } + const current = currentQuestion(); + matches.forEach(({ record, match, matchedField }) => { + const card = node('article', 'result'); + const meta = node('div', 'meta'); + const statusText = record.answerStatus === 'confirmed' ? '已确认' : record.answerStatus === 'conflict' ? '答案冲突' : '待补答案'; + const matchLabel = matchedField === 'options' ? '选项匹配' : matchedField === 'answer' ? '答案匹配' : ({ exact: '题干一致', partial: '文字匹配', similar: '相似候选' })[match] || '候选'; + meta.append(node('span', `badge ${record.answerStatus}`, statusText), node('span', '', `${({ single: '单选', multiple: '多选', judge: '判断' })[record.type] || '题目'} · ${matchLabel}`)); + card.append(meta, node('p', 'stem', record.stem)); + const answer = node('div', `answer ${record.answerStatus !== 'confirmed' ? 'pending' : ''}`); + answer.append(node('span', 'answer-label', '正确答案')); + if (record.answerStatus === 'confirmed') { + const texts = record.answerTexts || []; + answer.append(node('strong', '', texts.length ? texts.join(';') : '标准答案缺少文字,需核验选项')); + const mapped = current ? QBCore.mapAnswers(record, current) : null; + if (mapped?.status === 'matched' && mapped.labels?.length) answer.append(node('span', 'mapping', `当前页面对应:${mapped.labels.join('、')}`)); + else answer.append(node('span', 'mapping', '当前页面选项未确认,请按答案内容核对。')); + } else { + answer.append(node('strong', '', record.answerStatus === 'conflict' ? '存在不同标准答案,需要核验' : '尚未取得标准答案')); + if (record.answerStatus === 'conflict' && record.conflicts?.length) answer.append(node('span', 'mapping', '冲突记录已保留在 JSON / CSV 备份中。')); + } + card.append(answer); + if (matchedField === 'options') { + const details = node('details', 'explanation'); + details.open = true; + details.append(node('summary', '', '题目选项')); + record.options.forEach(option => details.append(node('p', '', `${option.label}. ${option.text}`))); + card.append(details); + } + if (record.explanation) { + const details = node('details', 'explanation'); + details.append(node('summary', '', '查看解析'), node('p', '', record.explanation)); + card.append(details); + } + card.append(node('div', 'source', `${record.bankName || '未命名题库'}${record.questionId ? ` · ID ${record.questionId}` : ''}`)); + resultBox.append(card); + }); + } + function runSearch(text) { + query = String(text || '').trim().slice(0, 4000); + if (shadow) shadow.getElementById('qb-search').value = query; + renderResults(); + } + function download(contents, filename, mime) { + const blob = new Blob([contents], { type: mime }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; anchor.download = filename; + (document.body || document.documentElement).append(anchor); + anchor.click(); anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10000); + } + function safeFilename(value) { + let name = String(value || '未命名题库').replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().replace(/[. ]+$/, ''); + name = Array.from(name).slice(0, 110).join(''); + if (!name || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)) name = `题库_${name || '未命名'}`; + return name; + } + function exportFilename(name, count, format, date = new Date()) { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date).map(part => [part.type, part.value])); + return `${safeFilename(name)}_${count}题_${parts.year}${parts.month}${parts.day}.${format}`; + } + function exportData(format) { + const selected = scopedRecords(); + if (!selected.length) return tell('所选范围还没有题目,请先采集或导入。'); + const date = new Date(); + const bankName = scopeName(); + const filename = exportFilename(bankName, selected.length, format, date); + if (format === 'csv') download(QBCore.toCSV(selected), filename, 'text/csv;charset=utf-8'); + else download(JSON.stringify({ schemaVersion: 1, exportedAt: date.toISOString(), bankName, records: selected }, null, 2), filename, 'application/json;charset=utf-8'); + tell(`已导出「${bankName}」${selected.length} 道题,其中 ${selected.filter(record => record.answerStatus === 'confirmed').length} 道有已确认答案。`); + } + async function parseImportFile(file) { + if (file.size > 20 * 1024 * 1024) throw new Error('文件超过 20 MB,请拆分后导入'); + const text = (await file.text()).replace(/^\uFEFF/, ''); + let incoming; + if (/\.json$/i.test(file.name) || /^[\s]*[\[{]/.test(text)) { + const payload = JSON.parse(text); + if (!Array.isArray(payload) && payload?.schemaVersion !== 1) throw new Error('不支持这个 JSON 备份版本'); + const source = Array.isArray(payload) ? payload : payload.records; + if (!Array.isArray(source) || source.length > 50000) throw new Error('JSON 应包含不超过 50000 道题的 records 数组'); + incoming = source.map(record => { + if (!record || typeof record !== 'object' || typeof record.stem !== 'string' || !record.stem.trim()) throw new Error('JSON 题目必须包含非空 stem 题干'); + if (!['single', 'multiple', 'judge', 'unknown'].includes(record.type)) throw new Error('JSON 中存在不支持的题型'); + if (record.answerStatus && !['confirmed', 'missing', 'conflict'].includes(record.answerStatus)) throw new Error('JSON 中存在不支持的答案状态'); + return QBCore.normalizeRecord(record); + }); + } else incoming = QBCore.fromCSV(text); + if (!incoming.length) throw new Error('文件中没有可导入的题目'); + const fallbackName = String(file.name).replace(/\.(csv|json)$/i, '').trim() || '导入题库'; + return incoming.map(record => QBCore.normalizeRecord({ ...record, bankName: !record.bankName || record.bankName === '未命名题库' ? fallbackName : record.bankName })); + } + async function importFiles(files) { + if (!files.length) return; + if (loadError) throw new Error('现有本地题库异常,为保留原数据已停止写入。请先从油猴存储备份原数据'); + if (files.reduce((sum, file) => sum + file.size, 0) > 50 * 1024 * 1024) throw new Error('本次文件合计超过 50 MB,请分批导入'); + const incoming = []; + // Parse every file before changing the library. A bad file cancels this batch. + for (const file of files) { + try { incoming.push(...await parseImportFile(file)); } + catch (error) { throw new Error(`「${file.name}」:${error.message}`); } + if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入'); + } + const before = records.length; + const merged = QBCore.upsert(records, incoming); + records = merged; + persistRecords(); + refreshScope(); renderStats(); renderCurrent(); renderResults(); + const bankCount = new Set(incoming.map(bankKey)).size; + if (!storageError) tell(`已导入 ${files.length} 个文件、${bankCount} 个题库,共 ${incoming.length} 条记录,新增 ${Math.max(0, records.length - before)} 道题。各题库名称已分别保留。`); + } + + const css = ` + :host{all:initial;position:fixed;right:18px;bottom:18px;z-index:2147483646;color:#17283d;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;pointer-events:none;color-scheme:light} + *,*:before,*:after{box-sizing:border-box} [hidden]{display:none!important} button,input,select{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid #63d3c7;outline-offset:2px} + #qb-panel{pointer-events:auto;width:390px;max-width:calc(100vw - 24px);max-height:calc(100dvh - 36px);display:flex;flex-direction:column;border:1px solid #dbe4ed;border-radius:18px;background:#f7f9fc;box-shadow:0 16px 60px #0d24362c;overflow:hidden} + .header{background:#152a40;color:#fff;padding:15px 16px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-shrink:0}.brand{font-size:16px;font-weight:700;letter-spacing:.5px}.subtitle{font-size:11px;color:#bed0df;margin-top:2px}.tools{display:flex;gap:5px}.tools button{border:1px solid #526578;color:#e8f4ff;background:transparent;padding:5px 8px;border-radius:7px;font-size:12px;white-space:nowrap}.tools button:hover{background:#294359} + .body{overflow:auto;overscroll-behavior:contain;min-height:0}.collapsed .body{display:none}.tabs{display:flex;gap:4px;padding:10px 12px 0;background:#fff;border-bottom:1px solid #e7edf3}.tabs button{flex:1;padding:9px 4px;background:none;border:0;border-bottom:3px solid transparent;color:#6b7b8f;font-weight:600}.tabs button.active{border-bottom-color:#008d82;color:#00786e} + .view{padding:14px}.search-wrap{display:flex;gap:7px}.search-wrap input{width:100%;min-width:0;border:1px solid #cdd9e4;border-radius:9px;padding:10px 11px;background:#fff;color:#17283d}.btn{border:1px solid #ccd8e4;border-radius:8px;padding:8px 11px;background:#fff;color:#284059;white-space:nowrap}.btn:hover{background:#edf5f8}.btn.primary{background:#00877d;border-color:#00877d;color:white}.btn.primary:hover{background:#006f66}.btn.small{padding:5px 9px;font-size:12px} + .context{padding:10px 15px;background:#eaf2f6;border-bottom:1px solid #dce6ee}.context-label{color:#718599;font-size:10px}.context strong{display:block;color:#25445b;font-size:12px;line-height:1.6;overflow-wrap:anywhere}.context-count{font-size:10px;color:#587487;margin-top:3px}.search-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:#3e5d74;margin-bottom:8px}.text-button{border:0;background:none;color:#6a7f92;font-size:11px;padding:3px}.text-button:hover{color:#007b70}.search-filters{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:8px;margin-top:10px}.search-filters label{min-width:0;color:#72869a;font-size:10px}.search-filters select{display:block;width:100%;min-width:0;border:1px solid #d1dce6;background:#fff;padding:7px 4px;margin-top:3px;border-radius:7px;color:#37556c;font-size:11px}.export-name{font-size:13px!important;color:#244e65!important;font-weight:600;overflow-wrap:anywhere;margin:4px 0 10px!important}.filename{font-size:10px!important;word-break:break-all;color:#758c9d!important}.bank-list{max-height:130px;overflow:auto;margin-top:8px}.bank-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;border-top:1px solid #edf1f5;padding:7px 0;font-size:11px;color:#657f94}.bank-row span{overflow-wrap:anywhere}.bank-row strong{flex-shrink:0;color:#486b80;font-weight:500} + .hint{font-size:11px;color:#738298;margin:8px 0 12px}.empty{padding:24px 14px;text-align:center;color:#597087;border:1px dashed #cedce5;border-radius:12px;background:#fff}.empty strong{color:#29475f;font-size:15px}.empty p{font-size:12px;line-height:1.8;margin:8px 0 0}.result{border:1px solid #e0e7ee;border-radius:12px;background:#fff;padding:13px;margin-bottom:10px}.meta{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:#8090a2}.badge{padding:2px 7px;border-radius:5px;background:#edf1f6;color:#536b82}.badge.confirmed{background:#e3f6ef;color:#137e55}.badge.missing{background:#fff4d8;color:#966819}.badge.conflict{background:#ffe9e7;color:#b74740}.stem{font-size:13px;line-height:1.75;margin:10px 0;white-space:pre-wrap;overflow-wrap:anywhere}.answer{border-radius:9px;background:#eaf7f3;padding:10px 11px;display:flex;flex-direction:column;gap:4px;overflow-wrap:anywhere}.answer.pending{background:#fff6e7}.answer-label{font-size:10px;letter-spacing:.5px;color:#517467}.answer strong{color:#086550;font-size:16px;line-height:1.6;white-space:pre-wrap}.answer.pending strong{color:#936426;font-size:14px}.mapping{font-size:11px;color:#567367}.source{font-size:10px;color:#8291a1;margin-top:10px;overflow-wrap:anywhere}.explanation{margin-top:9px;color:#617387;font-size:12px}.explanation summary{cursor:pointer}.explanation p{white-space:pre-wrap;overflow-wrap:anywhere} + .stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-bottom:12px}.stat{background:#fff;border:1px solid #e3eaf1;border-radius:9px;padding:9px 3px;text-align:center}.stat strong{display:block;font-size:21px;color:#234257}.stat span{font-size:10px;color:#7a8ca1}.section{padding:13px;border:1px solid #e0e7ee;background:#fff;border-radius:12px;margin-bottom:12px}.section h3{font-size:13px;margin:0 0 9px}.section p{font-size:12px;color:#718297;margin:5px 0;line-height:1.8;overflow-wrap:anywhere}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.spread{justify-content:space-between}.state{font-size:11px;color:#088574;background:#e7f6ee;border-radius:9px;padding:2px 8px}.state.paused{color:#8c672e;background:#fff3dd}#qb-current-title{font-size:12px;color:#344f66;margin:8px 0;max-height:80px;overflow:auto;white-space:pre-wrap}.scope{display:block;font-size:12px;color:#62778b;margin-bottom:10px}.scope select{margin-top:5px;width:100%;padding:8px;border:1px solid #cfdae5;border-radius:8px;background:#fff;color:#263f57}.check{display:flex;gap:9px;align-items:flex-start;margin:12px 0;color:#304e67;font-size:13px}.check input{accent-color:#00877d;margin-top:4px}.check small{display:block;font-size:11px;color:#7b8da0;margin-top:3px}.kbd{background:#edf1f5;border:1px solid #dce3eb;border-radius:4px;padding:2px 6px;font-family:monospace;font-size:12px;color:#374e62}.footer{padding:9px 14px 12px;font-size:11px;color:#718297;background:#eff4f8;overflow-wrap:anywhere;line-height:1.6;border-top:1px solid #e1e8ef}#qb-status[data-error="true"]{color:#b0473c}.footline{display:flex;gap:6px;align-items:center;margin-bottom:5px}.dot{width:5px;height:5px;background:#079a87;border-radius:50%}#qb-launcher{pointer-events:auto;border:1px solid #1b776f;background:#153f42;color:#fff;box-shadow:0 6px 22px #173b3c33;border-radius:50%;width:54px;height:54px;font-size:13px;font-weight:600}#qb-launcher:hover{background:#086f65} + @media(max-width:480px){:host{right:12px;bottom:12px}#qb-panel{max-height:calc(100dvh - 24px)}.view{padding:12px}.header{padding:12px}.brand{font-size:15px}} + `; + + function mount() { + if (mounted || document.getElementById('huayi-qb-root')) return; + root = node('div'); root.id = 'huayi-qb-root'; + shadow = root.attachShadow({ mode: 'open' }); + const style = node('style'); style.textContent = css; + shadow.append(style); + const ui = node('div'); + ui.innerHTML = ` +
+
题库助手
HUAYI · 本地练习资料
+
+
当前题库正在识别题库…
+ + + + +
本地题库 · 标准答案优先
+
+
`; + shadow.append(...ui.childNodes); + (document.body || document.documentElement).append(root); + panel = shadow.getElementById('qb-panel'); launcher = shadow.getElementById('qb-launcher'); + resultBox = shadow.getElementById('qb-results'); statusBox = shadow.getElementById('qb-status'); + mounted = true; + shadow.getElementById('qb-keep-launcher').checked = Boolean(config.keepLauncher); + shadow.getElementById('qb-copy-enabled').checked = Boolean(config.copyEnabled); + shadow.addEventListener('click', event => { + const button = event.target.closest('button'); + if (!button) return; + if (button.dataset.tab) return selectTab(button.dataset.tab); + try { + switch (button.dataset.action) { + case 'hide': setMode('hidden'); break; + case 'restore': setMode('open'); break; + case 'collapse': setMode(config.mode === 'collapsed' ? 'open' : 'collapsed'); break; + case 'search': runSearch(shadow.getElementById('qb-search').value); break; + case 'clear-search': clearTimeout(searchTimer); runSearch(''); shadow.getElementById('qb-search').focus(); break; + case 'capture-current': { + const captured = adapter.captureCurrent(); + renderCurrent(); + if (captured) tell(`已检查「${captured.bankName || '当前题库'}」的当前题目;尚未取得的标准答案会标为待补。`); + break; + } + case 'toggle-capture': + config.collecting = !config.collecting; + config.collecting ? adapter.start() : adapter.stop(); + adapter.setCopyEnabled(Boolean(config.copyEnabled)); + persistConfig(); renderStats(); + tell(config.collecting ? '已继续采集。此前未观察到的响应,可在重新进入练习后采集。' : '已暂停自动采集,已保存的题库仍可检索和导出。'); + break; + case 'export-csv': exportData('csv'); break; + case 'export-json': exportData('json'); break; + case 'import': shadow.getElementById('qb-import-file').click(); break; + } + } catch (error) { tell(`操作失败:${error.message}`, true); } + }); + shadow.getElementById('qb-search').addEventListener('input', event => { + clearTimeout(searchTimer); const value = event.target.value; + searchTimer = setTimeout(() => runSearch(value), 180); + }); + shadow.getElementById('qb-search').addEventListener('keydown', event => { + if (event.key === 'Enter') { clearTimeout(searchTimer); runSearch(event.target.value); } + }); + shadow.getElementById('qb-scope').addEventListener('change', event => { scope = event.target.value; renderStats(); }); + shadow.getElementById('qb-search-scope').addEventListener('change', event => { searchScope = event.target.value; renderResults(); }); + shadow.getElementById('qb-search-field').addEventListener('change', event => { searchField = event.target.value; renderResults(); }); + shadow.getElementById('qb-keep-launcher').addEventListener('change', event => { config.keepLauncher = event.target.checked; persistConfig(); renderMode(); }); + shadow.getElementById('qb-copy-enabled').addEventListener('change', event => { config.copyEnabled = event.target.checked; adapter.setCopyEnabled(config.copyEnabled); persistConfig(); }); + shadow.getElementById('qb-import-file').addEventListener('change', async event => { + const input = event.currentTarget; + if (importing) return; + const files = Array.from(input.files || []); + importing = true; + const button = shadow.querySelector('[data-action="import"]'); + button.disabled = true; + try { await importFiles(files); } + catch (error) { tell(`导入失败,题库未替换:${error.message}`, true); } + finally { input.value = ''; importing = false; button.disabled = false; } + }); + refreshScope(); renderStats(); renderCurrent(); renderResults(); renderStatus(); selectTab(config.tab); renderMode(); + setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000); + } + + document.addEventListener('mouseup', event => { + if (event.composedPath().includes(root)) return; + const active = document.activeElement; + if (active && (active.matches('input,textarea,[contenteditable="true"]'))) return; + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + const selected = window.getSelection()?.toString().trim(); + if (selected && selected.length >= 2 && selected.length <= 4000) { + runSearch(selected); + if (shadow && config.tab !== 'search') selectTab('search'); + } + }, 120); + }); + document.addEventListener('keydown', event => { + if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyQ' && !event.repeat) { + event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden'); + } + }, true); + window.addEventListener('pagehide', persistRecords); + if (typeof GM_registerMenuCommand === 'function') { + GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open')); + GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden')); + GM_registerMenuCommand('完全隐藏题库助手(快捷键或菜单恢复)', () => { config.keepLauncher = false; if (shadow) shadow.getElementById('qb-keep-launcher').checked = false; setMode('hidden'); }); + } + if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => { + if (!remote || !Array.isArray(value)) return; + try { records = QBCore.upsert(records, value); if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } } + catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ } + }); + if (config.collecting) adapter.start(); + adapter.setCopyEnabled(Boolean(config.copyEnabled)); + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true }); + else mount(); +})(); + +})(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..476c019 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "huayi-question-bank", + "version": "1.1.0", + "private": true, + "description": "华谊练习题库本地采集、CSV 导出与划词检索油猴脚本", + "scripts": { + "build": "node build.cjs", + "test": "node --test tests/core.test.cjs tests/adapter.test.cjs", + "test:browser": "node --test tests/integration.test.cjs", + "check": "node build.cjs && node --check dist/huayi-question-bank.user.js" + } +} diff --git a/src/adapter.js b/src/adapter.js new file mode 100644 index 0000000..08cc490 --- /dev/null +++ b/src/adapter.js @@ -0,0 +1,529 @@ +/* Site adapter: observe existing practice responses; never submit or alter answers. */ +const QBAdapter = (() => { + 'use strict'; + const TYPES = { 0: 'single', 1: 'multiple', 2: 'judge' }; + const ROOT_SELECTOR = '.yxtulcdsdk-pc-marking-main'; + const MAX_RESPONSE = 8 * 1024 * 1024; + const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); + const text = value => String(value == null ? '' : value) + .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '') + .replace(//gi, '\n').replace(/<\/?[a-z][^>]*>/gi, '') + .replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { + const code = n[0].toLowerCase() === 'x' ? parseInt(n.slice(1), 16) : Number(n); + return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : ''; + }).replace(/&(nbsp|lt|gt|amp|quot|apos);/g, (_, name) => + ({ nbsp: ' ', lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" })[name]) + .replace(/\u00a0/g, ' ').trim(); + const flat = value => text(value).replace(/\s+/g, ''); + const idString = value => typeof value === 'string' || Number.isSafeInteger(value) ? String(value) : ''; + const signature = q => [q.type, flat(q.stem), (q.options || []).map(o => flat(o.text)).sort().join('\u001f')].join('\u001e'); + const labelFor = index => String.fromCharCode(65 + index); + + function standardAnswer(node, type, options, context) { + const result = node.resultInfo && typeof node.resultInfo === 'object' ? node.resultInfo : null; + const answerNode = result || node; + if (answerNode.allowViewSingleQuesAnswer === false || answerNode.allowViewSingleQuesAnswer === 0) { + return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' }; + } + if (type === 'judge') { + const raw = answerNode.judgeAnswer; + if (raw === 0 || raw === '0' || raw === 1 || raw === '1') { + const value = String(raw); + return { correctOptionIds: [value], answerTexts: [value === '1' ? '正确' : '错误'], answerStatus: 'confirmed' }; + } + } else { + let ids = []; + if (Array.isArray(node.correctAnswers)) ids = node.correctAnswers.map(idString).filter(Boolean); + else if (result && Array.isArray(result.answers)) ids = result.answers.map(idString).filter(Boolean); + else if (context.answerResponse && Array.isArray(node.answers)) ids = node.answers.map(idString).filter(Boolean); + else if (Array.isArray(node.choiceItems)) ids = node.choiceItems.filter(o => o.answer === 1).map(o => idString(o.id)); + ids = [...new Set(ids)].filter(Boolean); + const resolved = ids.map(id => options.filter(o => o.id === id)); + if (ids.length && resolved.every(matches => matches.length === 1) && (type !== 'single' || ids.length === 1)) { + return { correctOptionIds: ids, answerTexts: resolved.map(matches => matches[0].text), answerStatus: 'confirmed' }; + } + } + return { correctOptionIds: [], answerTexts: [], answerStatus: 'missing' }; + } + + function knownById(context, id) { + if (!id || !context.knownQuestions) return null; + const values = context.knownQuestions instanceof Map ? [...context.knownQuestions.values()] : context.knownQuestions; + if (!Array.isArray(values)) return null; + const matches = values.filter(q => q.questionId === id && (!context.bankId || !q.bankId || q.bankId === context.bankId)); + return matches.length === 1 ? matches[0] : null; + } + + function recordFromNode(node, context) { + const rawType = has(node, 'quesType') ? node.quesType : node.type; + let type = TYPES[rawType]; + const id = idString(node.quesId || node.questionId || node.id || context.questionId); + const previous = knownById(context, id); + if (!type && previous) type = previous.type; + if (!type) return null; + let stem = text(node.content); + if (!stem && previous) stem = previous.stem; + if (!stem || stem.length > 100000) return null; + // Do not treat an arbitrary content/type business object as a question. + if (!previous && !has(node, 'quesType') && !Array.isArray(node.choiceItems) && !has(node, 'judgeAnswer')) return null; + let options; + if (type === 'judge') { + options = [{ id: '1', text: '正确', label: '正确' }, { id: '0', text: '错误', label: '错误' }]; + } else if (Array.isArray(node.choiceItems)) { + if (node.choiceItems.some(option => !option || typeof option !== 'object')) return null; + options = node.choiceItems.slice(0, 100).map((option, index) => ({ + id: idString(option.id), text: text(option.content), label: labelFor(index) + })); + if (options.length < 2 || options.some(o => !o.text)) return null; + } else if (previous) options = previous.options.map(o => ({ ...o })); + else return null; + const answers = standardAnswer(node, type, options, context); + return { + bankId: idString(node.praId || context.bankId || (previous && previous.bankId)), + bankName: text(context.bankName || (previous && previous.bankName)), questionId: id, + type, stem, options, ...answers, + explanation: text((node.resultInfo && node.resultInfo.explainText) || node.explainText), + source: { kind: context.kind || 'network', url: String(context.url || ''), observedAt: context.observedAt || new Date().toISOString() } + }; + } + + function extractQuestions(payload, context = {}) { + const output = []; + const visited = new WeakSet(); + let count = 0; + const childKeys = ['data', 'result', 'listQues', 'questionList', 'questions', 'subQuesList', 'list', 'items']; + function visit(node, inherited, depth) { + if (!node || typeof node !== 'object' || depth > 12 || ++count > 16000 || visited.has(node)) return; + visited.add(node); + if (Array.isArray(node)) { + const arrayContext = inherited.answerResponse && node.length !== 1 ? { ...inherited, questionId: '' } : inherited; + for (const item of node.slice(0, 10000)) visit(item, arrayContext, depth + 1); + return; + } + const next = { ...inherited, bankId: idString(node.praId || inherited.bankId), bankName: text(node.praName || inherited.bankName) }; + const candidate = recordFromNode(node, next); + if (candidate) output.push(candidate); + for (const key of childKeys) if (has(node, key)) visit(node[key], next, depth + 1); + } + visit(payload, context, 0); + const unique = new Map(); + for (const q of output) { + const key = `${q.bankId}\u001f${q.questionId || signature(q)}`; + const old = unique.get(key); + if (!old || (old.answerStatus === 'missing' && q.answerStatus === 'confirmed')) unique.set(key, q); + } + return [...unique.values()]; + } + + function classifyURL(value, base) { + try { + const url = new URL(value, base); + const host = url.hostname; + const allowed = host === 'www.shhuayi-elearning.cn' || host === 'shhuayi-elearning.cn' || + /(^|\.)(yunxuetang\.cn|yxt\.com)$/.test(host) || url.origin === new URL(base).origin; + if (!allowed) return null; + const path = url.pathname; + const list = /\/(?:upm\/(?:wrong\/)?start|pra\/[^/]+\/questions)\/?$/.test(path); + const answerMatch = path.match(/\/upm\/([^/]+)\/users\/[^/]+\/(?:wrong\/)?answer\/?$/); + if (!list && !answerMatch) return null; + const praMatch = path.match(/\/pra\/([^/]+)\/questions/); + return { + // Retain no token-bearing query parameters in saved source URLs. + url: url.origin + url.pathname, + bankId: url.searchParams.get('praId') || (answerMatch && answerMatch[1]) || (praMatch && praMatch[1]) || '', + answerResponse: Boolean(answerMatch) + }; + } catch (_) { return null; } + } + + function requestQuestionId(body) { + try { + if (typeof body !== 'string' || body.length > 1024 * 1024) return ''; + const value = JSON.parse(body); + return Array.isArray(value.answers) && value.answers.length === 1 ? idString(value.answers[0].quesId) : ''; + } catch (_) { return ''; } + } + + function create({ window: win, document: doc, onQuestions = () => {}, onStatus = () => {}, onBankChange = () => {} }) { + let active = false; + let copyEnabled = false; + let observer = null; + let timer = null; + let copyStyle = null; + let fetchOriginal = null; + let fetchWrapped = null; + let xhrOpenOriginal = null; + let xhrSendOriginal = null; + let xhrOpenWrapped = null; + let xhrSendWrapped = null; + let lastDOMSignature = ''; + let networkGeneration = 0; + const known = new Map(); + const xhrContext = new WeakMap(); + const pendingXHR = new Set(); + const banksById = new Map(); + const banksByTask = new Map(); + let currentBank = null; + let pendingSelection = ''; + let blockedForm = null; + let lastFormSnapshot = null; + let bankListeners = false; + const status = message => { try { onStatus(message); } catch (_) { /* UI callbacks must not affect the page. */ } }; + const locationURL = () => String(win.location && win.location.href || ''); + + function formSnapshot() { + const element = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].find(visible); + return element ? { element, text: flat(element.textContent) } : null; + } + + function taskFromURL() { + try { + const url = new URL(locationURL()); + const hashQuery = url.hash.includes('?') ? url.hash.slice(url.hash.indexOf('?') + 1) : ''; + return new URLSearchParams(hashQuery).get('taskId') || url.searchParams.get('taskId') || ''; + } catch (_) { return ''; } + } + + function bankDOM() { + const selected = doc.querySelector('.task-content-active .group-title, .task-content-active .task-content-color-active'); + const selectedName = text(selected?.textContent); + if (pendingSelection && flat(pendingSelection) === flat(selectedName)) pendingSelection = ''; + const sidebarName = pendingSelection || selectedName; + const playerName = text(doc.querySelector('header.o2oPlayFrame-right .title .yxtf-tooltip')?.textContent); + const examName = text(doc.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip')?.textContent); + const container = doc.querySelector('.yxtulcdsdk-exam-container[task-id]'); + const containerTask = idString(container?.getAttribute('task-id')); + const routeTask = taskFromURL(); + const selectedTask = idString(selected?.closest('.task-content-active')?.getAttribute('data-task-id')); + const pending = pendingSelection && flat(pendingSelection) !== flat(selectedName); + const taskId = pending ? '' : selectedTask || routeTask || containerTask; + const bankName = text(sidebarName || playerName || examName || String(doc.title || '').replace(/ - 华谊集团$/, '')); + const mismatch = Boolean(pending || (sidebarName && playerName && flat(sidebarName) !== flat(playerName)) || + (sidebarName && examName && flat(sidebarName) !== flat(examName)) || + (playerName && examName && flat(playerName) !== flat(examName)) || + (routeTask && containerTask && routeTask !== containerTask)); + return { bankName, taskId, mismatch }; + } + + function syncBank() { + const state = bankDOM(); + const identity = JSON.stringify([state.taskId, flat(state.bankName)]); + const oldIdentity = currentBank && JSON.stringify([currentBank.taskId, flat(currentBank.bankName)]); + const snapshot = formSnapshot(); + const stillPreviousForm = snapshot && lastFormSnapshot && snapshot.element === lastFormSnapshot.element && snapshot.text === lastFormSnapshot.text; + // During a SPA transition the sidebar changes before the old question is + // replaced. Keep that old form out of the new bank even if titles update first. + if (currentBank && identity !== oldIdentity && flat(currentBank.bankName) !== flat(state.bankName)) { + blockedForm = stillPreviousForm ? snapshot : null; + lastDOMSignature = ''; + } else if (currentBank && identity !== oldIdentity && currentBank.taskId && state.taskId && currentBank.taskId !== state.taskId) { + if (!(currentBank.loading && !blockedForm)) blockedForm = stillPreviousForm ? snapshot : null; + lastDOMSignature = ''; + } + if (blockedForm && (!snapshot || blockedForm.element !== snapshot.element || blockedForm.text !== snapshot.text)) blockedForm = null; + let bankId = ''; + const taskBank = state.taskId && banksByTask.get(state.taskId); + if (taskBank && flat(taskBank.bankName) === flat(state.bankName)) bankId = taskBank.bankId; + if (!bankId && !state.taskId) { + const named = [...banksById.values()].filter(bank => flat(bank.bankName) === flat(state.bankName)); + if (named.length === 1) bankId = named[0].bankId; + } + const next = { bankId, bankName: state.bankName, taskId: state.taskId, loading: state.mismatch || Boolean(blockedForm) }; + const changed = !currentBank || JSON.stringify(next) !== JSON.stringify(currentBank); + currentBank = next; + lastFormSnapshot = snapshot; + if (changed) { + try { onBankChange({ ...next }); } catch (_) { /* Consumers must not affect site navigation. */ } + } + return { ...next }; + } + + function getCurrentBank() { return syncBank(); } + + function ensureBankListeners() { + if (bankListeners) return; + bankListeners = true; + win.addEventListener?.('hashchange', syncBank); + win.addEventListener?.('popstate', syncBank); + win.addEventListener?.('click', event => { + const item = event.target?.closest?.('.task-content, .task-content-active'); + const name = text(item?.querySelector('.group-title')?.textContent); + if (name && flat(name) !== flat(currentBank?.bankName)) { pendingSelection = name; syncBank(); } + }, true); + } + + function requestContext(context) { + const current = syncBank(); + const saved = context.bankId && banksById.get(context.bankId); + const sameTask = saved && saved.taskId && saved.taskId === current.taskId; + const useCurrent = !saved || sameTask || (!context.answerResponse && !current.loading); + context.bankName = useCurrent ? current.bankName : saved.bankName; + context.taskId = useCurrent ? current.taskId : saved.taskId; + return context; + } + + function rememberBanks(context, records) { + const distinct = new Map(); + for (const record of records) if (record.bankId) distinct.set(record.bankId, record); + for (const record of distinct.values()) { + const name = record.bankName || context.bankName; + const taskId = flat(name) === flat(context.bankName) ? context.taskId || '' : ''; + const value = { bankId: record.bankId, bankName: name, taskId }; + banksById.set(record.bankId, value); + if (value.taskId && distinct.size === 1) banksByTask.set(value.taskId, value); + } + } + + function emit(records) { + if (!records.length) return; + for (const q of records) { + if (q.questionId) known.set(`${q.bankId}\u001f${q.questionId}`, q); + } + try { onQuestions(records); } catch (error) { status(`保存题目失败:${error.message || error}`); } + } + + function visible(node) { + if (!node || node.closest('#huayi-qb-root')) return false; + for (let at = node; at && at.nodeType === 1; at = at.parentElement) { + if (at.hidden || at.getAttribute('aria-hidden') === 'true' || at.style?.display === 'none') return false; + } + return true; + } + + function getCurrentQuestion() { + const bank = syncBank(); + if (bankDOM().mismatch) return null; + const forms = [...doc.querySelectorAll(`${ROOT_SELECTOR} .yxtulcdsdk-form`)].filter(visible); + if (forms.length !== 1) return null; + const form = forms[0]; + const content = form.querySelector('.yxtulcdsdk-form__content'); + const stemNode = content && [...content.children].find(n => n.matches('[data-rich-text]')); + const stem = stemNode && text(stemNode.textContent); + if (!stem) return null; + const labels = [...form.querySelectorAll('.yxtulcdsdk-ques-radio, .yxtulcdsdk-ques-checkbox')]; + if (labels.length < 2) return null; + const options = labels.map((label, index) => { + const input = label.querySelector('input'); + const labelNode = label.querySelector('.yxtf-radio__label, .yxtf-checkbox__label'); + const rich = labelNode && labelNode.querySelector('[data-rich-text]'); + const full = text((labelNode || label).textContent); + return { id: input ? idString(input.value) : '', text: text(rich ? rich.textContent : full.replace(/^[A-Z][.、.]\s*/, '')), label: labelFor(index) }; + }); + let type = form.querySelector('input[type=checkbox], [role=checkbox]') ? 'multiple' : 'single'; + if (options.length === 2 && options.some(o => o.text === '正确') && options.some(o => o.text === '错误')) { + type = 'judge'; + options.forEach(o => { o.label = o.text; }); + } + const q = { + bankId: bank.bankId, bankName: bank.bankName, questionId: '', type, stem, options, + correctOptionIds: [], answerTexts: [], answerStatus: 'missing', explanation: '', + source: { kind: 'dom', url: locationURL().split('?')[0].split('#')[0], observedAt: new Date().toISOString() } + }; + const matches = [...known.values()].filter(item => signature(item) === signature(q) && + (q.bankId ? item.bankId === q.bankId : flat(item.bankName) === flat(q.bankName))); + if (blockedForm && (!bank.bankId || matches.length !== 1)) return null; + if (matches.length === 1) { + q.questionId = matches[0].questionId; + q.bankId = matches[0].bankId; + if (blockedForm) { blockedForm = null; syncBank(); } + } + // Only a row explicitly labelled standard answer can establish correctness. + const main = form.closest(ROOT_SELECTOR); + const rows = [...main.querySelectorAll('.yxtulcdsdk-examing__result-line')].filter(visible); + for (const row of rows) { + const heading = text(row.querySelector('.yxtulcdsdk-examing__result-line-left')?.textContent); + const answer = text(row.querySelector('.yxtulcdsdk-examing__result-line-right')?.textContent); + if (heading === '正确答案' || heading === '标准答案') { + let selected = []; + if (type === 'judge') selected = options.filter(o => o.text === answer); + else if (/^[A-Z](?:[、,,\s]*[A-Z])*$/.test(answer)) { + const requested = [...new Set(answer.match(/[A-Z]/g))]; + selected = requested.map(letter => options.find(o => o.label === letter)).filter(Boolean); + if (selected.length !== requested.length) selected = []; + } + if (selected.length && (type !== 'single' || selected.length === 1)) { + q.correctOptionIds = selected.map(o => o.id).filter(Boolean); + q.answerTexts = selected.map(o => o.text); + q.answerStatus = 'confirmed'; + } + } else if (heading === '解析' || heading === '试题解析' || heading === '答案解析') q.explanation = answer; + } + return q; + } + + function captureCurrent() { + const current = getCurrentQuestion(); + if (current) emit([current]); + else status('当前页面未发现可采集的练习题,请进入题目页面。'); + return current; + } + + function observePayload(payload, context) { + if (!active) return; + try { + const records = extractQuestions(payload, { ...context, knownQuestions: known }); + rememberBanks(context, records); + emit(records); + syncBank(); + if (records.length) { + const names = [...new Set(records.map(record => record.bankName).filter(Boolean))]; + const sourceName = names.length === 1 ? names[0] : context.bankName || '来源题库'; + status(`已读取「${sourceName}」${records.length} 道题,标准答案 ${records.filter(q => q.answerStatus === 'confirmed').length} 道。`); + } + } catch (_) { status('本次响应结构暂未识别,题目仍可从页面采集。'); } + } + + function installNetwork() { + const generation = ++networkGeneration; + if (typeof win.fetch === 'function') { + const originalFetch = win.fetch; + fetchOriginal = originalFetch; + fetchWrapped = function (...args) { + const request = args[0]; + const requestURL = typeof request === 'string' || (request && typeof request.href === 'string') ? String(request) : request && request.url; + const context = classifyURL(requestURL, locationURL()); + if (context) { + requestContext(context); + context.questionId = requestQuestionId(args[1] && args[1].body); + } + const promise = originalFetch.apply(this, args); + if (context) Promise.resolve(promise).then(response => { + if (!active || generation !== networkGeneration || !response.ok) return; + const contentType = response.headers.get('content-type') || ''; + const length = Number(response.headers.get('content-length') || 0); + if (!/\bjson\b/i.test(contentType) || length > MAX_RESPONSE) return; + response.clone().text().then(body => { + if (active && generation === networkGeneration && body.length <= MAX_RESPONSE) observePayload(JSON.parse(body), context); + }).catch(() => {}); + }).catch(() => {}); + return promise; + }; + win.fetch = fetchWrapped; + } + const proto = win.XMLHttpRequest && win.XMLHttpRequest.prototype; + if (!proto) return; + const originalOpen = proto.open; + const originalSend = proto.send; + xhrOpenOriginal = originalOpen; + xhrSendOriginal = originalSend; + xhrOpenWrapped = function (method, url, ...args) { + const result = originalOpen.call(this, method, url, ...args); + const context = classifyURL(url, locationURL()); + if (context) requestContext(context); + xhrContext.set(this, context); + return result; + }; + xhrSendWrapped = function (body) { + const context = xhrContext.get(this); + if (active && generation === networkGeneration && context) { + context.questionId = requestQuestionId(body); + const xhr = this; + const listener = () => { + pendingXHR.delete(entry); + if (!active || generation !== networkGeneration || xhr.status < 200 || xhr.status >= 300) return; + try { + const type = xhr.getResponseHeader('content-type') || ''; + const length = Number(xhr.getResponseHeader('content-length') || 0); + if (!/\bjson\b/i.test(type) || length > MAX_RESPONSE) return; + if (xhr.responseType === 'json') observePayload(xhr.response, context); + else if (!xhr.responseType || xhr.responseType === 'text') { + const bodyText = xhr.responseText; + if (bodyText.length <= MAX_RESPONSE) observePayload(JSON.parse(bodyText), context); + } + } catch (_) { /* Unsupported payloads never interfere with the site's requests. */ } + }; + const entry = { xhr, listener }; + pendingXHR.add(entry); + xhr.addEventListener('load', listener, { once: true }); + } + return originalSend.call(this, body); + }; + proto.open = xhrOpenWrapped; + proto.send = xhrSendWrapped; + } + + function uninstallNetwork() { + networkGeneration++; + if (fetchWrapped && win.fetch === fetchWrapped) win.fetch = fetchOriginal; + const proto = win.XMLHttpRequest && win.XMLHttpRequest.prototype; + if (proto && proto.open === xhrOpenWrapped) proto.open = xhrOpenOriginal; + if (proto && proto.send === xhrSendWrapped) proto.send = xhrSendOriginal; + for (const { xhr, listener } of pendingXHR) xhr.removeEventListener('load', listener); + pendingXHR.clear(); + } + + function copyEvent(event) { + const target = event.target && (event.target.nodeType === 1 ? event.target : event.target.parentElement); + if (!copyEnabled || !target?.closest(ROOT_SELECTOR) || target.closest('#huayi-qb-root')) return; + // Preserve the browser default copy/selection action, suppress only downstream blockers. + event.stopImmediatePropagation(); + } + + function ensureCopyStyle() { + if (!copyEnabled || copyStyle?.isConnected) return; + const parent = doc.head || doc.documentElement; + if (!parent) return; + copyStyle = doc.createElement('style'); + copyStyle.id = 'huayi-qb-copy-style'; + copyStyle.textContent = `${ROOT_SELECTOR},${ROOT_SELECTOR} *{-webkit-user-select:text!important;user-select:text!important}`; + parent.appendChild(copyStyle); + } + + function ensureObserver() { + if (observer || !win.MutationObserver) return; + observer = new win.MutationObserver(mutations => { + const relevant = mutations.some(m => { + const node = m.target.nodeType === 1 ? m.target : m.target.parentElement; + return node && !node.closest('#huayi-qb-root') && node.id !== 'huayi-qb-copy-style'; + }); + if (!relevant) return; + syncBank(); + if (copyEnabled) ensureCopyStyle(); + if (!active) return; + win.clearTimeout(timer); + timer = win.setTimeout(() => { + const q = getCurrentQuestion(); + if (!q) return; + const next = JSON.stringify([q.bankId, q.questionId, signature(q), q.correctOptionIds, q.answerTexts, q.explanation]); + if (next !== lastDOMSignature) { lastDOMSignature = next; emit([q]); } + }, 180); + }); + observer.observe(doc, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'style', 'hidden', 'task-id', 'data-task-id'] }); + } + + function setCopyEnabled(value) { + copyEnabled = Boolean(value); + for (const type of ['copy', 'cut', 'contextmenu', 'selectstart']) { + win.removeEventListener(type, copyEvent, true); + if (copyEnabled) win.addEventListener(type, copyEvent, true); + } + if (copyEnabled) { ensureCopyStyle(); ensureObserver(); } + else if (copyStyle) { copyStyle.remove(); copyStyle = null; } + ensureBankListeners(); + ensureObserver(); + syncBank(); + } + + function start() { + if (active) return; + active = true; + ensureBankListeners(); + ensureObserver(); + installNetwork(); + captureCurrent(); + status('采集已开启;切换练习或正常作答时,自动保存收到的题目与标准答案。'); + } + + function stop() { + active = false; + win.clearTimeout(timer); + uninstallNetwork(); + status('采集已暂停,已保存题库和选择复制功能仍可使用。'); + } + + return { start, stop, captureCurrent, getCurrentQuestion, getCurrentBank, setCopyEnabled }; + } + + return { create, extractQuestions }; +})(); +if (typeof module !== 'undefined' && module.exports) module.exports = QBAdapter; diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..6ac115b --- /dev/null +++ b/src/app.js @@ -0,0 +1,504 @@ +// UI and persistence stay inside the userscript sandbox. Page data is rendered as text. +(() => { + const CONFIG_KEY = 'huayi-qb.config.v1'; + const RECORDS_KEY = 'huayi-qb.records.v1'; + const DEFAULTS = { mode: 'open', keepLauncher: true, copyEnabled: true, collecting: true, tab: 'search' }; + const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; + const storage = { + get(key, fallback) { + if (typeof GM_getValue === 'function') return GM_getValue(key, fallback); + const raw = localStorage.getItem(key); + return raw === null ? fallback : JSON.parse(raw); + }, + set(key, value) { + if (typeof GM_setValue === 'function') return GM_setValue(key, value); + localStorage.setItem(key, JSON.stringify(value)); + } + }; + let rawConfig; + try { rawConfig = storage.get(CONFIG_KEY, {}); } catch (_) { rawConfig = {}; } + const config = { ...DEFAULTS, ...(rawConfig && typeof rawConfig === 'object' ? rawConfig : {}) }; + if (!['open', 'collapsed', 'hidden'].includes(config.mode)) config.mode = 'open'; + if (!['search', 'library', 'settings'].includes(config.tab)) config.tab = 'search'; + ['keepLauncher', 'copyEnabled', 'collecting'].forEach(key => { if (typeof config[key] !== 'boolean') config[key] = DEFAULTS[key]; }); + let records = []; + let loadError = ''; + try { + const saved = storage.get(RECORDS_KEY, []); + if (!Array.isArray(saved)) throw new Error('本地题库格式不正确'); + records = QBCore.upsert([], saved); + } catch (error) { loadError = `本地题库读取失败:${error.message}。请先保留已有备份。`; } + let root, shadow, panel, launcher, resultBox, statusBox; + let saveTimer, searchTimer, query = '', scope = '__current__', searchScope = '__current__', searchField = 'all'; + let activeBank = null, importing = false; + let status = loadError || '准备就绪,可输入文字搜索,也可选中题干检索。'; + let storageError = false; + let mounted = false; + const adapter = QBAdapter.create({ window: pageWindow, document, onQuestions: receive, onStatus: adapterStatus, onBankChange: bankChanged }); + + function bankChanged(bank) { + if (!bank || (!bank.bankName && !bank.bankId)) return; + const previous = activeBank; + const sameName = previous && bankNameKey(previous.bankName) === bankNameKey(bank.bankName); + const identityChanged = previous && (!sameName || (previous.taskId && bank.taskId && previous.taskId !== bank.taskId) || (previous.bankId && bank.bankId && previous.bankId !== bank.bankId)); + const changed = JSON.stringify(previous) !== JSON.stringify(bank); + activeBank = { ...bank }; + if (identityChanged) { scope = '__current__'; searchScope = '__current__'; } + if (mounted && changed) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } + } + + function adapterStatus(value) { + const message = typeof value === 'string' ? value : value && value.message; + if (message && !storageError) { status = message; renderStatus(); } + } + function persistConfig() { + try { storage.set(CONFIG_KEY, { ...config }); } + catch (error) { tell(`设置保存失败:${error.message}`, true); } + } + function persistRecords() { + clearTimeout(saveTimer); + if (loadError) return; + try { + // Merge other tabs before writing; GM change events also refresh an open panel. + const stored = storage.get(RECORDS_KEY, []); + if (!Array.isArray(stored)) throw new Error('现有题库结构异常,已停止覆盖'); + records = QBCore.upsert(stored, records); + storage.set(RECORDS_KEY, records); + storageError = false; + } catch (error) { + storageError = true; + tell(`保存失败:${error.message}。当前内存数据仍可导出,请及时备份。`, true); + } + } + function receive(incoming) { + if (!Array.isArray(incoming) || !incoming.length || loadError) return; + try { + const namesById = new Map(); + incoming.forEach(record => { + if (!record.bankId || record.source?.kind !== 'network' || !record.bankName || record.bankName === '未命名题库') return; + if (!namesById.has(record.bankId)) namesById.set(record.bankId, new Set()); + namesById.get(record.bankId).add(record.bankName); + }); + // A newly observed, explicit bank ID/name pair can repair an old stale + // title for that same ID. Never infer a rename from question type or text. + records = records.map(record => { + const names = record.bankId && namesById.get(record.bankId); + return names?.size === 1 ? QBCore.normalizeRecord({ ...record, bankName: [...names][0] }) : record; + }); + records = QBCore.upsert(records, incoming); + clearTimeout(saveTimer); + saveTimer = setTimeout(persistRecords, 250); + if (mounted) { refreshScope(); renderStats(); renderResults(); renderCurrent(); } + } catch (error) { tell(`这批题目未保存:${error.message}`, true); } + } + function tell(message, error = false) { + status = message; + if (statusBox) statusBox.dataset.error = String(error); + renderStatus(); + } + function renderStatus() { + if (statusBox) statusBox.textContent = status; + } + function node(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; + } + function setMode(mode) { + config.mode = mode; + persistConfig(); + renderMode(); + if (mode === 'open' && config.tab === 'search') shadow?.getElementById('qb-search').focus(); + } + function renderMode() { + if (!panel) return; + panel.hidden = config.mode === 'hidden'; + panel.classList.toggle('collapsed', config.mode === 'collapsed'); + launcher.hidden = config.mode !== 'hidden' || !config.keepLauncher; + const collapse = shadow.querySelector('[data-action="collapse"]'); + collapse.textContent = config.mode === 'collapsed' ? '展开' : '收起'; + collapse.setAttribute('aria-expanded', String(config.mode !== 'collapsed')); + } + function selectTab(tab) { + config.tab = tab; + persistConfig(); + shadow.querySelectorAll('[data-tab]').forEach(button => { + const selected = button.dataset.tab === tab; + button.setAttribute('aria-selected', String(selected)); + button.classList.toggle('active', selected); + }); + shadow.querySelectorAll('[data-view]').forEach(view => { view.hidden = view.dataset.view !== tab; }); + } + function bankKey(record) { return String(record.bankId || record.bankName || ''); } + function bankNameKey(name) { return String(name || '').replace(/\s+/g, ''); } + function bankCatalog() { + const namedIds = new Map(); + records.forEach(record => { + if (!record.bankId) return; + const name = bankNameKey(record.bankName); + if (!namedIds.has(name)) namedIds.set(name, new Set()); + namedIds.get(name).add(record.bankId); + }); + const catalog = new Map(); + records.forEach(record => { + const ids = namedIds.get(bankNameKey(record.bankName)); + const key = !record.bankId && ids?.size === 1 ? [...ids][0] : bankKey(record); + if (!catalog.has(key)) catalog.set(key, { key, name: record.bankName || record.bankId || '未命名题库', count: 0 }); + catalog.get(key).count++; + }); + return [...catalog.values()]; + } + function currentQuestion() { + try { return adapter.getCurrentQuestion(); } catch (_) { return null; } + } + function currentBank() { + try { return adapter.getCurrentBank?.() || activeBank || currentQuestion(); } + catch (_) { return activeBank; } + } + function sameBankScope(record, anchor, knownIds) { + if (record.bankId && anchor.bankId) return record.bankId === anchor.bankId; + const name = String(anchor.bankName || '').replace(/\s+/g, ''); + if (!name || name === '未命名题库' || String(record.bankName || '').replace(/\s+/g, '') !== name) return false; + const namedIds = knownIds || new Set(records.filter(item => item.bankId && bankNameKey(item.bankName) === name).map(item => item.bankId)); + if (anchor.bankId) namedIds.add(anchor.bankId); + // A human CSV often has a bank name only. Include it only when that name + // identifies one known bank; identical names across bank IDs stay separate. + return namedIds.size <= 1; + } + function scopedRecords(selectedScope = scope) { + if (!selectedScope) return records; + const isCurrent = selectedScope === '__current__'; + const anchor = isCurrent ? currentBank() : records.find(record => bankKey(record) === selectedScope); + if (!anchor) return []; + const namedIds = new Set(records.filter(item => item.bankId && bankNameKey(item.bankName) === bankNameKey(anchor.bankName)).map(item => item.bankId)); + if (anchor.bankId) namedIds.add(anchor.bankId); + return records.filter(record => (!isCurrent && bankKey(record) === selectedScope) || sameBankScope(record, anchor, namedIds)); + } + function scopeName(selectedScope = scope) { + if (selectedScope === '') return '全部题库'; + if (selectedScope === '__current__') return currentBank()?.bankName || '未识别当前题库'; + const anchor = records.find(record => bankKey(record) === selectedScope); + return anchor?.bankName || '未命名题库'; + } + function refreshScope() { + if (!shadow) return; + const banks = new Map(bankCatalog().map(bank => [bank.key, bank.name])); + if (scope && scope !== '__current__' && !banks.has(scope)) scope = '__current__'; + if (searchScope && searchScope !== '__current__' && !banks.has(searchScope)) searchScope = '__current__'; + const names = [...banks.values()]; + const options = [['__current__', `当前题库:${scopeName('__current__')}`], ['', `全部题库(${records.length}题)`], ...[...banks].map(([key, name]) => [key, names.filter(value => value === name).length > 1 ? `${name}(${key === name ? '无ID' : key})` : name])]; + ['qb-scope', 'qb-search-scope'].forEach(id => { + const select = shadow.getElementById(id); + const signature = JSON.stringify(options); + if (select.dataset.signature !== signature) { + select.dataset.signature = signature; + select.replaceChildren(); + options.forEach(([value, label]) => { + const option = node('option', '', label); option.value = value; option.title = label; select.append(option); + }); + } + select.value = id === 'qb-scope' ? scope : searchScope; + }); + } + function renderStats() { + if (!shadow) return; + const selected = scopedRecords(); + const confirmed = selected.filter(record => record.answerStatus === 'confirmed').length; + const conflicts = selected.filter(record => record.answerStatus === 'conflict').length; + [['total', selected.length], ['confirmed', confirmed], ['missing', selected.length - confirmed - conflicts], ['conflicts', conflicts]].forEach(([name, count]) => { + shadow.getElementById(`qb-stat-${name}`).textContent = count.toLocaleString('zh-CN'); + }); + const exportName = scopeName(); + shadow.getElementById('qb-export-name').textContent = exportName; + shadow.getElementById('qb-export-name').title = exportName; + const catalog = bankCatalog(); + shadow.getElementById('qb-library-summary').textContent = `本地共保存 ${catalog.length} 个题库、${records.length} 道题。上方统计与导出均按所选范围。`; + const bankList = shadow.getElementById('qb-bank-list'); + bankList.replaceChildren(); + catalog.forEach(bank => { + const item = node('div', 'bank-row'); + item.append(node('span', '', bank.name), node('strong', '', `${bank.count} 题`)); + bankList.append(item); + }); + shadow.getElementById('qb-export-preview').textContent = selected.length ? `文件名:${exportFilename(exportName, selected.length, 'csv')}` : '该范围暂无题目;不会导出其他题库。'; + const button = shadow.querySelector('[data-action="toggle-capture"]'); + button.textContent = config.collecting ? '暂停采集' : '继续采集'; + const indicator = shadow.getElementById('qb-capture-state'); + indicator.textContent = config.collecting ? '采集中' : '已暂停'; + indicator.classList.toggle('paused', !config.collecting); + } + function renderCurrent() { + if (!shadow) return; + const bank = currentBank(); + const current = currentQuestion(); + const name = bank?.bankName || '尚未识别当前题库'; + shadow.getElementById('qb-context-name').textContent = name; + shadow.getElementById('qb-context-name').title = name; + const pool = scopedRecords('__current__'); + shadow.getElementById('qb-context-count').textContent = `${bank?.loading ? '切换中 · ' : ''}本库已采集 ${pool.length} 题 · 已确认 ${pool.filter(record => record.answerStatus === 'confirmed').length} 题`; + shadow.getElementById('qb-current-title').textContent = bank?.loading ? '题库切换中,等待当前题目加载。' : current?.stem || '打开练习题后,这里显示当前题目。'; + shadow.getElementById('qb-current-bank').textContent = name; + } + function renderResults() { + if (!resultBox) return; + resultBox.replaceChildren(); + const pool = scopedRecords(searchScope); + const matches = query.trim() ? QBCore.search(pool, query, { limit: 50, field: searchField }) : []; + const searchName = scopeName(searchScope); + shadow.getElementById('qb-query-state').textContent = query.trim() ? `${searchName} · ${pool.length} 题 · 显示 ${matches.length} 个结果${matches.length === 50 ? '(最多50条,请细化关键词)' : ''}` : `${searchName} · 可输入题干、选项或标准答案关键词`; + if (!matches.length) { + const empty = node('div', 'empty'); + empty.append(node('strong', '', query.trim() ? '没有找到匹配题目' : '输入文字,查找正确答案'), node('p', '', query.trim() ? '可切换到全部题库、更换关键词,或导入其他题库。' : '支持输入、粘贴或划选文字;按回车或点搜索查询。多个关键词可用空格分隔。')); + resultBox.append(empty); + return; + } + const current = currentQuestion(); + matches.forEach(({ record, match, matchedField }) => { + const card = node('article', 'result'); + const meta = node('div', 'meta'); + const statusText = record.answerStatus === 'confirmed' ? '已确认' : record.answerStatus === 'conflict' ? '答案冲突' : '待补答案'; + const matchLabel = matchedField === 'options' ? '选项匹配' : matchedField === 'answer' ? '答案匹配' : ({ exact: '题干一致', partial: '文字匹配', similar: '相似候选' })[match] || '候选'; + meta.append(node('span', `badge ${record.answerStatus}`, statusText), node('span', '', `${({ single: '单选', multiple: '多选', judge: '判断' })[record.type] || '题目'} · ${matchLabel}`)); + card.append(meta, node('p', 'stem', record.stem)); + const answer = node('div', `answer ${record.answerStatus !== 'confirmed' ? 'pending' : ''}`); + answer.append(node('span', 'answer-label', '正确答案')); + if (record.answerStatus === 'confirmed') { + const texts = record.answerTexts || []; + answer.append(node('strong', '', texts.length ? texts.join(';') : '标准答案缺少文字,需核验选项')); + const mapped = current ? QBCore.mapAnswers(record, current) : null; + if (mapped?.status === 'matched' && mapped.labels?.length) answer.append(node('span', 'mapping', `当前页面对应:${mapped.labels.join('、')}`)); + else answer.append(node('span', 'mapping', '当前页面选项未确认,请按答案内容核对。')); + } else { + answer.append(node('strong', '', record.answerStatus === 'conflict' ? '存在不同标准答案,需要核验' : '尚未取得标准答案')); + if (record.answerStatus === 'conflict' && record.conflicts?.length) answer.append(node('span', 'mapping', '冲突记录已保留在 JSON / CSV 备份中。')); + } + card.append(answer); + if (matchedField === 'options') { + const details = node('details', 'explanation'); + details.open = true; + details.append(node('summary', '', '题目选项')); + record.options.forEach(option => details.append(node('p', '', `${option.label}. ${option.text}`))); + card.append(details); + } + if (record.explanation) { + const details = node('details', 'explanation'); + details.append(node('summary', '', '查看解析'), node('p', '', record.explanation)); + card.append(details); + } + card.append(node('div', 'source', `${record.bankName || '未命名题库'}${record.questionId ? ` · ID ${record.questionId}` : ''}`)); + resultBox.append(card); + }); + } + function runSearch(text) { + query = String(text || '').trim().slice(0, 4000); + if (shadow) shadow.getElementById('qb-search').value = query; + renderResults(); + } + function download(contents, filename, mime) { + const blob = new Blob([contents], { type: mime }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; anchor.download = filename; + (document.body || document.documentElement).append(anchor); + anchor.click(); anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 10000); + } + function safeFilename(value) { + let name = String(value || '未命名题库').replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().replace(/[. ]+$/, ''); + name = Array.from(name).slice(0, 110).join(''); + if (!name || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)) name = `题库_${name || '未命名'}`; + return name; + } + function exportFilename(name, count, format, date = new Date()) { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date).map(part => [part.type, part.value])); + return `${safeFilename(name)}_${count}题_${parts.year}${parts.month}${parts.day}.${format}`; + } + function exportData(format) { + const selected = scopedRecords(); + if (!selected.length) return tell('所选范围还没有题目,请先采集或导入。'); + const date = new Date(); + const bankName = scopeName(); + const filename = exportFilename(bankName, selected.length, format, date); + if (format === 'csv') download(QBCore.toCSV(selected), filename, 'text/csv;charset=utf-8'); + else download(JSON.stringify({ schemaVersion: 1, exportedAt: date.toISOString(), bankName, records: selected }, null, 2), filename, 'application/json;charset=utf-8'); + tell(`已导出「${bankName}」${selected.length} 道题,其中 ${selected.filter(record => record.answerStatus === 'confirmed').length} 道有已确认答案。`); + } + async function parseImportFile(file) { + if (file.size > 20 * 1024 * 1024) throw new Error('文件超过 20 MB,请拆分后导入'); + const text = (await file.text()).replace(/^\uFEFF/, ''); + let incoming; + if (/\.json$/i.test(file.name) || /^[\s]*[\[{]/.test(text)) { + const payload = JSON.parse(text); + if (!Array.isArray(payload) && payload?.schemaVersion !== 1) throw new Error('不支持这个 JSON 备份版本'); + const source = Array.isArray(payload) ? payload : payload.records; + if (!Array.isArray(source) || source.length > 50000) throw new Error('JSON 应包含不超过 50000 道题的 records 数组'); + incoming = source.map(record => { + if (!record || typeof record !== 'object' || typeof record.stem !== 'string' || !record.stem.trim()) throw new Error('JSON 题目必须包含非空 stem 题干'); + if (!['single', 'multiple', 'judge', 'unknown'].includes(record.type)) throw new Error('JSON 中存在不支持的题型'); + if (record.answerStatus && !['confirmed', 'missing', 'conflict'].includes(record.answerStatus)) throw new Error('JSON 中存在不支持的答案状态'); + return QBCore.normalizeRecord(record); + }); + } else incoming = QBCore.fromCSV(text); + if (!incoming.length) throw new Error('文件中没有可导入的题目'); + const fallbackName = String(file.name).replace(/\.(csv|json)$/i, '').trim() || '导入题库'; + return incoming.map(record => QBCore.normalizeRecord({ ...record, bankName: !record.bankName || record.bankName === '未命名题库' ? fallbackName : record.bankName })); + } + async function importFiles(files) { + if (!files.length) return; + if (loadError) throw new Error('现有本地题库异常,为保留原数据已停止写入。请先从油猴存储备份原数据'); + if (files.reduce((sum, file) => sum + file.size, 0) > 50 * 1024 * 1024) throw new Error('本次文件合计超过 50 MB,请分批导入'); + const incoming = []; + // Parse every file before changing the library. A bad file cancels this batch. + for (const file of files) { + try { incoming.push(...await parseImportFile(file)); } + catch (error) { throw new Error(`「${file.name}」:${error.message}`); } + if (incoming.length > 50000) throw new Error('本次导入超过 50000 条记录,请分批导入'); + } + const before = records.length; + const merged = QBCore.upsert(records, incoming); + records = merged; + persistRecords(); + refreshScope(); renderStats(); renderCurrent(); renderResults(); + const bankCount = new Set(incoming.map(bankKey)).size; + if (!storageError) tell(`已导入 ${files.length} 个文件、${bankCount} 个题库,共 ${incoming.length} 条记录,新增 ${Math.max(0, records.length - before)} 道题。各题库名称已分别保留。`); + } + + const css = ` + :host{all:initial;position:fixed;right:18px;bottom:18px;z-index:2147483646;color:#17283d;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;pointer-events:none;color-scheme:light} + *,*:before,*:after{box-sizing:border-box} [hidden]{display:none!important} button,input,select{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:3px solid #63d3c7;outline-offset:2px} + #qb-panel{pointer-events:auto;width:390px;max-width:calc(100vw - 24px);max-height:calc(100dvh - 36px);display:flex;flex-direction:column;border:1px solid #dbe4ed;border-radius:18px;background:#f7f9fc;box-shadow:0 16px 60px #0d24362c;overflow:hidden} + .header{background:#152a40;color:#fff;padding:15px 16px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-shrink:0}.brand{font-size:16px;font-weight:700;letter-spacing:.5px}.subtitle{font-size:11px;color:#bed0df;margin-top:2px}.tools{display:flex;gap:5px}.tools button{border:1px solid #526578;color:#e8f4ff;background:transparent;padding:5px 8px;border-radius:7px;font-size:12px;white-space:nowrap}.tools button:hover{background:#294359} + .body{overflow:auto;overscroll-behavior:contain;min-height:0}.collapsed .body{display:none}.tabs{display:flex;gap:4px;padding:10px 12px 0;background:#fff;border-bottom:1px solid #e7edf3}.tabs button{flex:1;padding:9px 4px;background:none;border:0;border-bottom:3px solid transparent;color:#6b7b8f;font-weight:600}.tabs button.active{border-bottom-color:#008d82;color:#00786e} + .view{padding:14px}.search-wrap{display:flex;gap:7px}.search-wrap input{width:100%;min-width:0;border:1px solid #cdd9e4;border-radius:9px;padding:10px 11px;background:#fff;color:#17283d}.btn{border:1px solid #ccd8e4;border-radius:8px;padding:8px 11px;background:#fff;color:#284059;white-space:nowrap}.btn:hover{background:#edf5f8}.btn.primary{background:#00877d;border-color:#00877d;color:white}.btn.primary:hover{background:#006f66}.btn.small{padding:5px 9px;font-size:12px} + .context{padding:10px 15px;background:#eaf2f6;border-bottom:1px solid #dce6ee}.context-label{color:#718599;font-size:10px}.context strong{display:block;color:#25445b;font-size:12px;line-height:1.6;overflow-wrap:anywhere}.context-count{font-size:10px;color:#587487;margin-top:3px}.search-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:#3e5d74;margin-bottom:8px}.text-button{border:0;background:none;color:#6a7f92;font-size:11px;padding:3px}.text-button:hover{color:#007b70}.search-filters{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:8px;margin-top:10px}.search-filters label{min-width:0;color:#72869a;font-size:10px}.search-filters select{display:block;width:100%;min-width:0;border:1px solid #d1dce6;background:#fff;padding:7px 4px;margin-top:3px;border-radius:7px;color:#37556c;font-size:11px}.export-name{font-size:13px!important;color:#244e65!important;font-weight:600;overflow-wrap:anywhere;margin:4px 0 10px!important}.filename{font-size:10px!important;word-break:break-all;color:#758c9d!important}.bank-list{max-height:130px;overflow:auto;margin-top:8px}.bank-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;border-top:1px solid #edf1f5;padding:7px 0;font-size:11px;color:#657f94}.bank-row span{overflow-wrap:anywhere}.bank-row strong{flex-shrink:0;color:#486b80;font-weight:500} + .hint{font-size:11px;color:#738298;margin:8px 0 12px}.empty{padding:24px 14px;text-align:center;color:#597087;border:1px dashed #cedce5;border-radius:12px;background:#fff}.empty strong{color:#29475f;font-size:15px}.empty p{font-size:12px;line-height:1.8;margin:8px 0 0}.result{border:1px solid #e0e7ee;border-radius:12px;background:#fff;padding:13px;margin-bottom:10px}.meta{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;color:#8090a2}.badge{padding:2px 7px;border-radius:5px;background:#edf1f6;color:#536b82}.badge.confirmed{background:#e3f6ef;color:#137e55}.badge.missing{background:#fff4d8;color:#966819}.badge.conflict{background:#ffe9e7;color:#b74740}.stem{font-size:13px;line-height:1.75;margin:10px 0;white-space:pre-wrap;overflow-wrap:anywhere}.answer{border-radius:9px;background:#eaf7f3;padding:10px 11px;display:flex;flex-direction:column;gap:4px;overflow-wrap:anywhere}.answer.pending{background:#fff6e7}.answer-label{font-size:10px;letter-spacing:.5px;color:#517467}.answer strong{color:#086550;font-size:16px;line-height:1.6;white-space:pre-wrap}.answer.pending strong{color:#936426;font-size:14px}.mapping{font-size:11px;color:#567367}.source{font-size:10px;color:#8291a1;margin-top:10px;overflow-wrap:anywhere}.explanation{margin-top:9px;color:#617387;font-size:12px}.explanation summary{cursor:pointer}.explanation p{white-space:pre-wrap;overflow-wrap:anywhere} + .stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin-bottom:12px}.stat{background:#fff;border:1px solid #e3eaf1;border-radius:9px;padding:9px 3px;text-align:center}.stat strong{display:block;font-size:21px;color:#234257}.stat span{font-size:10px;color:#7a8ca1}.section{padding:13px;border:1px solid #e0e7ee;background:#fff;border-radius:12px;margin-bottom:12px}.section h3{font-size:13px;margin:0 0 9px}.section p{font-size:12px;color:#718297;margin:5px 0;line-height:1.8;overflow-wrap:anywhere}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.row.spread{justify-content:space-between}.state{font-size:11px;color:#088574;background:#e7f6ee;border-radius:9px;padding:2px 8px}.state.paused{color:#8c672e;background:#fff3dd}#qb-current-title{font-size:12px;color:#344f66;margin:8px 0;max-height:80px;overflow:auto;white-space:pre-wrap}.scope{display:block;font-size:12px;color:#62778b;margin-bottom:10px}.scope select{margin-top:5px;width:100%;padding:8px;border:1px solid #cfdae5;border-radius:8px;background:#fff;color:#263f57}.check{display:flex;gap:9px;align-items:flex-start;margin:12px 0;color:#304e67;font-size:13px}.check input{accent-color:#00877d;margin-top:4px}.check small{display:block;font-size:11px;color:#7b8da0;margin-top:3px}.kbd{background:#edf1f5;border:1px solid #dce3eb;border-radius:4px;padding:2px 6px;font-family:monospace;font-size:12px;color:#374e62}.footer{padding:9px 14px 12px;font-size:11px;color:#718297;background:#eff4f8;overflow-wrap:anywhere;line-height:1.6;border-top:1px solid #e1e8ef}#qb-status[data-error="true"]{color:#b0473c}.footline{display:flex;gap:6px;align-items:center;margin-bottom:5px}.dot{width:5px;height:5px;background:#079a87;border-radius:50%}#qb-launcher{pointer-events:auto;border:1px solid #1b776f;background:#153f42;color:#fff;box-shadow:0 6px 22px #173b3c33;border-radius:50%;width:54px;height:54px;font-size:13px;font-weight:600}#qb-launcher:hover{background:#086f65} + @media(max-width:480px){:host{right:12px;bottom:12px}#qb-panel{max-height:calc(100dvh - 24px)}.view{padding:12px}.header{padding:12px}.brand{font-size:15px}} + `; + + function mount() { + if (mounted || document.getElementById('huayi-qb-root')) return; + root = node('div'); root.id = 'huayi-qb-root'; + shadow = root.attachShadow({ mode: 'open' }); + const style = node('style'); style.textContent = css; + shadow.append(style); + const ui = node('div'); + ui.innerHTML = ` +
+
题库助手
HUAYI · 本地练习资料
+
+
当前题库正在识别题库…
+ + + + +
本地题库 · 标准答案优先
+
+
`; + shadow.append(...ui.childNodes); + (document.body || document.documentElement).append(root); + panel = shadow.getElementById('qb-panel'); launcher = shadow.getElementById('qb-launcher'); + resultBox = shadow.getElementById('qb-results'); statusBox = shadow.getElementById('qb-status'); + mounted = true; + shadow.getElementById('qb-keep-launcher').checked = Boolean(config.keepLauncher); + shadow.getElementById('qb-copy-enabled').checked = Boolean(config.copyEnabled); + shadow.addEventListener('click', event => { + const button = event.target.closest('button'); + if (!button) return; + if (button.dataset.tab) return selectTab(button.dataset.tab); + try { + switch (button.dataset.action) { + case 'hide': setMode('hidden'); break; + case 'restore': setMode('open'); break; + case 'collapse': setMode(config.mode === 'collapsed' ? 'open' : 'collapsed'); break; + case 'search': runSearch(shadow.getElementById('qb-search').value); break; + case 'clear-search': clearTimeout(searchTimer); runSearch(''); shadow.getElementById('qb-search').focus(); break; + case 'capture-current': { + const captured = adapter.captureCurrent(); + renderCurrent(); + if (captured) tell(`已检查「${captured.bankName || '当前题库'}」的当前题目;尚未取得的标准答案会标为待补。`); + break; + } + case 'toggle-capture': + config.collecting = !config.collecting; + config.collecting ? adapter.start() : adapter.stop(); + adapter.setCopyEnabled(Boolean(config.copyEnabled)); + persistConfig(); renderStats(); + tell(config.collecting ? '已继续采集。此前未观察到的响应,可在重新进入练习后采集。' : '已暂停自动采集,已保存的题库仍可检索和导出。'); + break; + case 'export-csv': exportData('csv'); break; + case 'export-json': exportData('json'); break; + case 'import': shadow.getElementById('qb-import-file').click(); break; + } + } catch (error) { tell(`操作失败:${error.message}`, true); } + }); + shadow.getElementById('qb-search').addEventListener('input', event => { + clearTimeout(searchTimer); const value = event.target.value; + searchTimer = setTimeout(() => runSearch(value), 180); + }); + shadow.getElementById('qb-search').addEventListener('keydown', event => { + if (event.key === 'Enter') { clearTimeout(searchTimer); runSearch(event.target.value); } + }); + shadow.getElementById('qb-scope').addEventListener('change', event => { scope = event.target.value; renderStats(); }); + shadow.getElementById('qb-search-scope').addEventListener('change', event => { searchScope = event.target.value; renderResults(); }); + shadow.getElementById('qb-search-field').addEventListener('change', event => { searchField = event.target.value; renderResults(); }); + shadow.getElementById('qb-keep-launcher').addEventListener('change', event => { config.keepLauncher = event.target.checked; persistConfig(); renderMode(); }); + shadow.getElementById('qb-copy-enabled').addEventListener('change', event => { config.copyEnabled = event.target.checked; adapter.setCopyEnabled(config.copyEnabled); persistConfig(); }); + shadow.getElementById('qb-import-file').addEventListener('change', async event => { + const input = event.currentTarget; + if (importing) return; + const files = Array.from(input.files || []); + importing = true; + const button = shadow.querySelector('[data-action="import"]'); + button.disabled = true; + try { await importFiles(files); } + catch (error) { tell(`导入失败,题库未替换:${error.message}`, true); } + finally { input.value = ''; importing = false; button.disabled = false; } + }); + refreshScope(); renderStats(); renderCurrent(); renderResults(); renderStatus(); selectTab(config.tab); renderMode(); + setInterval(() => { bankChanged(currentBank()); if (config.mode === 'open') renderCurrent(); }, 1000); + } + + document.addEventListener('mouseup', event => { + if (event.composedPath().includes(root)) return; + const active = document.activeElement; + if (active && (active.matches('input,textarea,[contenteditable="true"]'))) return; + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + const selected = window.getSelection()?.toString().trim(); + if (selected && selected.length >= 2 && selected.length <= 4000) { + runSearch(selected); + if (shadow && config.tab !== 'search') selectTab('search'); + } + }, 120); + }); + document.addEventListener('keydown', event => { + if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyQ' && !event.repeat) { + event.preventDefault(); setMode(config.mode === 'hidden' ? 'open' : 'hidden'); + } + }, true); + window.addEventListener('pagehide', persistRecords); + if (typeof GM_registerMenuCommand === 'function') { + GM_registerMenuCommand('显示题库助手(Alt+Shift+Q)', () => setMode('open')); + GM_registerMenuCommand('隐藏题库助手', () => setMode('hidden')); + GM_registerMenuCommand('完全隐藏题库助手(快捷键或菜单恢复)', () => { config.keepLauncher = false; if (shadow) shadow.getElementById('qb-keep-launcher').checked = false; setMode('hidden'); }); + } + if (typeof GM_addValueChangeListener === 'function') GM_addValueChangeListener(RECORDS_KEY, (_key, _old, value, remote) => { + if (!remote || !Array.isArray(value)) return; + try { records = QBCore.upsert(records, value); if (mounted) { refreshScope(); renderStats(); renderCurrent(); renderResults(); } } + catch (_) { /* Keep the current usable in-memory data if another tab has bad data. */ } + }); + if (config.collecting) adapter.start(); + adapter.setCopyEnabled(Boolean(config.copyEnabled)); + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true }); + else mount(); +})(); diff --git a/src/core.js b/src/core.js new file mode 100644 index 0000000..48d3382 --- /dev/null +++ b/src/core.js @@ -0,0 +1,399 @@ +/* Pure question-bank operations. No page access or network requests. */ +const QBCore = (() => { + 'use strict'; + + const TYPES = new Set(['single', 'multiple', 'judge', 'unknown']); + const STATUSES = new Set(['confirmed', 'missing', 'conflict']); + const TYPE_NAMES = { single: '单选题', multiple: '多选题', judge: '判断题', unknown: '未知' }; + const STATUS_NAMES = { confirmed: '已确认', missing: '待补', conflict: '冲突' }; + const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key); + const text = (value) => value == null ? '' : String(value); + const unique = (values) => [...new Set(values)]; + const clean = (value) => text(value).replace(/\u00a0/g, ' ').trim(); + // Do not use NFKC: it changes superscripts, units and some mathematical symbols. + const normalized = (value) => clean(value).replace(/\s+/gu, ''); + const normalizedStem = (value) => normalized(clean(value).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, '')); + const sorted = (values) => [...values].sort(); + const sameArray = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b)); + + function idText(value, name) { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error(`${name} 是不安全的数字,请以字符串提供`); + if (value != null && !['string', 'number'].includes(typeof value)) throw new Error(`${name} 必须是字符串`); + return clean(value); + } + + function stringArray(value, name) { + if (value == null) return []; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' && typeof item !== 'number')) { + throw new Error(`${name} 必须是字符串数组`); + } + return unique(value.map((item) => idText(item, name)).filter(Boolean)); + } + + function judgeText(value) { + const result = clean(value); + if (/^(正确|对|是|√|true)$/i.test(result)) return '正确'; + if (/^(错误|错|否|×|false)$/i.test(result)) return '错误'; + return result; + } + + const answerText = (value, type) => type === 'judge' ? judgeText(value) : clean(value); + const comparableAnswer = (value, type) => normalized(answerText(value, type)); + const optionContents = (record) => record.options.map((option) => comparableAnswer(option.text, record.type)); + const sameContent = (a, b) => normalizedStem(a.stem) === normalizedStem(b.stem) && + (a.type === b.type || a.type === 'unknown' || b.type === 'unknown') && sameArray(optionContents(a), optionContents(b)); + + function sourceInfo(value) { + const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + return { kind: clean(source.kind) || 'unknown', url: clean(source.url), observedAt: clean(source.observedAt) || new Date().toISOString() }; + } + + function recordKey(record) { + const bank = record.bankId || record.bankName; + if (record.questionId) return `id:${JSON.stringify([bank, record.questionId])}`; + // Keep the complete signature, rather than a short hash whose collisions could merge answers. + return `fp:${JSON.stringify([bank, record.type, normalizedStem(record.stem), sorted(optionContents(record))])}`; + } + + function normalizeRecord(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('题目记录必须是对象'); + if (typeof input.stem !== 'string' || !input.stem.trim()) throw new Error('题干不能为空'); + const type = input.type == null ? 'unknown' : input.type; + if (!TYPES.has(type)) throw new Error(`不支持的题型:${text(type)}`); + if (input.answerStatus != null && !STATUSES.has(input.answerStatus)) throw new Error(`无效答案状态:${text(input.answerStatus)}`); + if (input.options != null && !Array.isArray(input.options)) throw new Error('选项必须是数组'); + const options = (input.options || []).map((option, index) => { + if (typeof option === 'string') option = { text: option }; + if (!option || typeof option !== 'object' || typeof option.text !== 'string' || !option.text.trim()) throw new Error('选项内容不能为空'); + return { id: idText(option.id, '选项ID'), text: clean(option.text), label: clean(option.label) || (index < 26 ? String.fromCharCode(65 + index) : String(index + 1)) }; + }); + const correctOptionIds = stringArray(input.correctOptionIds, '正确选项ID'); + let answerTexts = stringArray(input.answerTexts, '正确答案内容').map((value) => answerText(value, type)); + answerTexts = unique(answerTexts); + let answerStatus = input.answerStatus || (correctOptionIds.length || answerTexts.length ? 'confirmed' : 'missing'); + const conflicts = Array.isArray(input.conflicts) ? input.conflicts.filter((item) => item && typeof item === 'object').map((item) => JSON.parse(JSON.stringify(item))) : []; + let inconsistency = ''; + const derivedTexts = []; + if (correctOptionIds.length && options.length) { + for (const id of correctOptionIds) { + const matches = options.filter((option) => option.id === id); + if (matches.length !== 1) inconsistency = '正确选项ID无法唯一对应已保存选项'; + else derivedTexts.push(answerText(matches[0].text, type)); + } + if (!inconsistency && answerTexts.length && !sameArray(answerTexts.map((value) => comparableAnswer(value, type)), derivedTexts.map((value) => comparableAnswer(value, type)))) { + inconsistency = '正确选项ID与正确答案内容不一致'; + } + if (!answerTexts.length && !inconsistency) answerTexts = unique(derivedTexts); + } + if (['single', 'judge'].includes(type) && (answerTexts.length > 1 || correctOptionIds.length > 1)) inconsistency = '单选或判断题存在多个正确答案'; + if (options.length && ['single', 'multiple', 'judge'].includes(type) && answerTexts.some((answer) => !options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(answer, type)))) { + inconsistency = '正确答案内容不属于已保存选项'; + } + if (answerStatus === 'confirmed' && !answerTexts.length && !correctOptionIds.length) answerStatus = 'missing'; + if (answerStatus !== 'missing' && inconsistency) { + answerStatus = 'conflict'; + if (!conflicts.some((item) => item.reason === inconsistency)) conflicts.push({ reason: inconsistency, answerTexts: [...answerTexts], correctOptionIds: [...correctOptionIds] }); + } + // Explicitly missing data must never carry an apparently confirmed answer forward. + if (answerStatus === 'missing') answerTexts = []; + const source = sourceInfo(input.source); + const record = { + bankId: idText(input.bankId, '题库ID'), bankName: clean(input.bankName) || '未命名题库', + questionId: idText(input.questionId, '题目ID'), type, stem: clean(input.stem), options, + correctOptionIds: answerStatus === 'missing' ? [] : correctOptionIds, answerTexts, answerStatus, + explanation: clean(input.explanation), source, updatedAt: clean(input.updatedAt) || source.observedAt, conflicts, + }; + if (input.answerSource || answerStatus === 'confirmed') record.answerSource = sourceInfo(input.answerSource || source); + record.key = recordKey(record); + return record; + } + + function answerSignature(record) { + if (record.answerTexts.length) return JSON.stringify(sorted(record.answerTexts.map((value) => comparableAnswer(value, record.type)))); + return JSON.stringify(sorted(record.correctOptionIds.map((value) => `id:${value}`))); + } + + function conflictSnapshot(record, reason) { + return { reason, stem: record.stem, options: record.options.map((option) => ({ ...option })), answerTexts: [...record.answerTexts], correctOptionIds: [...record.correctOptionIds], source: record.answerSource || record.source }; + } + + function distinctConflicts(conflicts) { + const byContent = new Map(); + for (const item of conflicts) { + const key = JSON.stringify([item.reason, item.stem, item.options, item.answerTexts, item.correctOptionIds]); + if (!byContent.has(key)) byContent.set(key, item); + } + return [...byContent.values()].slice(-40); + } + + function mergeRecord(existing, incoming) { + const oldRecord = normalizeRecord(existing); + const newRecord = normalizeRecord(incoming); + if (oldRecord.key !== newRecord.key) throw new Error('不能合并不同题目的记录'); + const contentMatches = sameContent(oldRecord, newRecord); + const explanation = newRecord.explanation || oldRecord.explanation; + const base = { ...newRecord, explanation }; + const conflicts = [...oldRecord.conflicts, ...newRecord.conflicts]; + const conflictAlready = oldRecord.answerStatus === 'conflict' || newRecord.answerStatus === 'conflict'; + const answersDiffer = oldRecord.answerStatus === 'confirmed' && newRecord.answerStatus === 'confirmed' && answerSignature(oldRecord) !== answerSignature(newRecord); + const contentChanged = !contentMatches && oldRecord.answerStatus !== 'missing'; + if (conflictAlready || answersDiffer || contentChanged) { + const reason = contentChanged ? '同一题目ID的题干或选项发生变化' : '同一题目出现不同或不一致的标准答案'; + if (oldRecord.answerStatus !== 'missing') conflicts.push(conflictSnapshot(oldRecord, reason)); + if (newRecord.answerStatus !== 'missing') conflicts.push(conflictSnapshot(newRecord, reason)); + const answerRecord = newRecord.answerStatus !== 'missing' ? newRecord : oldRecord; + return normalizeRecord({ ...base, answerTexts: answerRecord.answerTexts, correctOptionIds: answerRecord.correctOptionIds, + answerSource: answerRecord.answerSource || answerRecord.source, answerStatus: 'conflict', + conflicts: distinctConflicts(conflicts) }); + } + if (newRecord.answerStatus === 'missing' && oldRecord.answerStatus === 'confirmed') { + // The incoming DOM may use different IDs/order. Keep the original answer's own options and IDs together. + return normalizeRecord({ ...oldRecord, bankName: newRecord.bankName || oldRecord.bankName, explanation, + updatedAt: newRecord.updatedAt, source: newRecord.source, answerSource: oldRecord.answerSource || oldRecord.source }); + } + return normalizeRecord({ ...base, conflicts }); + } + + function upsert(recordsArray, incomingRecords) { + if (!Array.isArray(recordsArray) || !Array.isArray(incomingRecords)) throw new Error('题库必须是数组'); + const records = new Map(); + const lastSeen = new Map(); + for (const [index, item] of [...recordsArray, ...incomingRecords].entries()) { + const record = normalizeRecord(item); + records.set(record.key, records.has(record.key) ? mergeRecord(records.get(record.key), record) : record); + lastSeen.set(record.key, index); + } + // The saved DOM has no stable question ID. Upgrade it only when the complete + // content and bank identify one unique server record in both directions. + const anonymous = [...records.values()].filter((record) => !record.questionId); + const identified = [...records.values()].filter((record) => record.questionId); + const sameBank = (a, b) => { + if (a.bankId && b.bankId) return a.bankId === b.bankId; + if (!a.bankName || !b.bankName || a.bankName === '未命名题库' || b.bankName === '未命名题库') return false; + return normalized(a.bankName) === normalized(b.bankName); + }; + const possiblePair = (a, b) => a.options.length > 0 && b.options.length > 0 && a.type === b.type && sameBank(a, b) && sameContent(a, b); + const candidates = new Map(anonymous.map((record) => [record.key, identified.filter((other) => possiblePair(record, other))])); + for (const record of anonymous) { + const matches = candidates.get(record.key); + if (matches.length !== 1) continue; + const target = matches[0]; + if (anonymous.filter((other) => candidates.get(other.key).some((candidate) => candidate.key === target.key)).length !== 1) continue; + const promoted = normalizeRecord({ ...record, bankId: target.bankId, bankName: target.bankName, questionId: target.questionId }); + const currentTarget = records.get(target.key); + const merged = lastSeen.get(record.key) < lastSeen.get(target.key) ? mergeRecord(promoted, currentTarget) : mergeRecord(currentTarget, promoted); + records.set(target.key, merged); + records.delete(record.key); + } + return [...records.values()]; + } + + function bigrams(value) { + const chars = Array.from(value); + if (chars.length < 2) return new Set(chars); + return new Set(chars.slice(0, -1).map((char, index) => char + chars[index + 1])); + } + + function search(records, query, { limit = 8, field = 'all', offset = 0 } = {}) { + const queryText = clean(query).replace(/^(?:第\s*\d+\s*题[\s、..::]*|\d+\s*[、.]\s*|\d+\.\s+)/u, ''); + const needle = normalized(queryText); + if (needle.length < 2) return []; + const selectedField = ['all', 'stem', 'options', 'answer'].includes(field) ? field : 'all'; + const terms = unique(queryText.split(/\s+/u).map(normalized).filter(Boolean)); + const queryGrams = bigrams(needle); + const result = []; + for (const record of records) { + const haystack = normalizedStem(record.stem); + // Each option/answer stays a separate searchable segment. Joining option A + // and option B would create phrases that never existed in either option. + const fields = { + stem: [haystack], + options: (record.options || []).map((option) => normalized(option.text)), + answer: record.answerStatus === 'confirmed' ? (record.answerTexts || []).map(normalized) : [], + }; + const allowed = selectedField === 'all' ? ['stem', 'options', 'answer'] : [selectedField]; + let match, score = 0, matchedField; + const prefer = (candidateField, candidateScore, candidateMatch = 'partial') => { + if (candidateScore > score) { matchedField = candidateField; score = candidateScore; match = candidateMatch; } + }; + if (allowed.includes('stem')) { + if (haystack === needle) prefer('stem', 1, 'exact'); + else if (haystack.includes(needle)) prefer('stem', 0.86 + 0.08 * needle.length / haystack.length); + } + for (const candidateField of ['options', 'answer']) { + if (!allowed.includes(candidateField)) continue; + const matching = fields[candidateField].filter((segment) => segment.includes(needle)); + if (matching.length) { + const coverage = Math.max(...matching.map((segment) => needle.length / segment.length)); + prefer(candidateField, (candidateField === 'options' ? 0.72 : 0.60) + 0.06 * coverage); + } + } + if (terms.length > 1) { + const allIn = (names) => terms.every((term) => names.some((name) => fields[name].some((segment) => segment.includes(term)))); + if (allIn(allowed)) { + if (allowed.includes('stem') && allIn(['stem'])) prefer('stem', 0.82); + else if (allowed.includes('options') && allIn(['options'])) prefer('options', 0.70); + else if (allowed.includes('answer') && allIn(['answer'])) prefer('answer', 0.58); + else { + // Cross-field AND is available only with the explicit all-fields scope. + const firstField = allowed.find((name) => terms.some((term) => fields[name].some((segment) => segment.includes(term)))); + prefer(firstField, firstField === 'stem' ? 0.80 : firstField === 'options' ? 0.68 : 0.56); + } + } + } + if (!match && allowed.includes('stem') && needle.length >= 4 && terms.length === 1) { + const grams = bigrams(haystack); + const intersection = [...queryGrams].filter((gram) => grams.has(gram)).length; + const similarity = 2 * intersection / (queryGrams.size + grams.size || 1); + if (similarity >= 0.45) prefer('stem', similarity * 0.50, 'similar'); + } + if (match) result.push({ record, match, score, matchedField }); + } + const stableKey = (record) => JSON.stringify([record.bankId || '', record.bankName || '', record.questionId || '', record.key || '']); + result.sort((a, b) => b.score - a.score || a.record.stem.localeCompare(b.record.stem, 'zh-CN') || stableKey(a.record).localeCompare(stableKey(b.record), 'zh-CN')); + const start = Math.max(0, Math.floor(Number(offset) || 0)); + const count = Math.max(0, Math.min(100, Math.floor(Number(limit) || 0))); + return result.slice(start, start + count); + } + + function mapAnswers(recordInput, currentInput) { + let record, current; + try { record = normalizeRecord(recordInput); current = normalizeRecord(currentInput); } + catch (_) { return { status: 'unmapped', labels: [], answerTexts: [] }; } + const answerTexts = [...record.answerTexts]; + const result = (status, labels = []) => ({ status, labels, answerTexts }); + if (record.answerStatus === 'conflict') return result('ambiguous'); + if (record.answerStatus !== 'confirmed' || !answerTexts.length) return result('unmapped'); + if (normalizedStem(record.stem) !== normalizedStem(current.stem) || + (record.type !== 'unknown' && current.type !== 'unknown' && record.type !== current.type)) return result('unmapped'); + if (record.options.length && !sameArray(optionContents(record), current.options.map((option) => comparableAnswer(option.text, record.type)))) return result('unmapped'); + const labels = []; + for (const value of answerTexts) { + const expected = comparableAnswer(value, record.type); + const candidates = current.options.filter((option) => comparableAnswer(option.text, record.type) === expected); + if (candidates.length > 1) return result('ambiguous'); + if (!candidates.length) return result('unmapped'); + const originalCorrect = record.options.filter((option) => record.correctOptionIds.includes(option.id) && comparableAnswer(option.text, record.type) === expected); + for (const original of originalCorrect) { + const idCandidates = original.id ? current.options.filter((option) => option.id === original.id) : []; + if (idCandidates.length > 1) return result('ambiguous'); + if (idCandidates.length === 1 && comparableAnswer(idCandidates[0].text, record.type) !== expected) return result('unmapped'); + } + labels.push(candidates[0].label); + } + if (unique(labels).length !== labels.length) return result('ambiguous'); + return result('matched', labels); + } + + function csvSafe(value) { + const result = text(value); + // CSV quoting alone does not stop spreadsheet formula evaluation or long-ID rounding. + return /^(?:\s*[=+\-@]|[\t\r]|\d{16,}$)/u.test(result) ? `'${result}` : result; + } + + function csvRestore(value) { + return value.startsWith("'") && csvSafe(value.slice(1)) === value ? value.slice(1) : value; + } + + function csvCell(value) { return `"${csvSafe(value).replace(/"/g, '""')}"`; } + + function toCSV(records) { + const canonical = records.map(normalizeRecord); + const optionCount = Math.max(0, ...canonical.map((record) => record.options.length)); + const optionNames = Array.from({ length: optionCount }, (_, index) => `选项${index < 26 ? String.fromCharCode(65 + index) : index + 1}`); + const headers = ['来源题库', '题库ID', '题目ID', '题型', '题干', ...optionNames, '正确答案内容', '采集时答案字母', '解析', '答案状态', '答案来源', '采集时间', '完整记录JSON']; + const rows = [headers]; + for (const record of canonical) { + const mapped = mapAnswers(record, record); + rows.push([record.bankName, record.bankId, record.questionId, TYPE_NAMES[record.type], record.stem, + ...Array.from({ length: optionCount }, (_, index) => record.options[index]?.text || ''), + record.answerTexts.join('\n'), mapped.status === 'matched' ? mapped.labels.join(',') : '', record.explanation, + STATUS_NAMES[record.answerStatus], record.answerSource?.kind || record.source.kind, record.answerSource?.observedAt || record.source.observedAt, JSON.stringify(record)]); + } + return '\uFEFF' + rows.map((row) => row.map(csvCell).join(',')).join('\r\n') + '\r\n'; + } + + function parseCSV(input) { + if (typeof input !== 'string') throw new Error('CSV内容必须是文字'); + const value = input.replace(/^\uFEFF/, ''); + const rows = []; + let row = [], cell = '', quoted = false, closed = false; + const finishCell = () => { row.push(cell); cell = ''; closed = false; }; + const finishRow = () => { finishCell(); if (row.some((item) => item !== '')) rows.push(row); row = []; }; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (quoted) { + if (char === '"') { + if (value[index + 1] === '"') { cell += '"'; index += 1; } + else { quoted = false; closed = true; } + } else cell += char; + } else if (char === ',') finishCell(); + else if (char === '\n' || char === '\r') { finishRow(); if (char === '\r' && value[index + 1] === '\n') index += 1; } + else if (char === '"') { + if (cell || closed) throw new Error('CSV引号位置错误'); + quoted = true; + } else { + if (closed) throw new Error('CSV引号后包含非法字符'); + cell += char; + } + } + if (quoted) throw new Error('CSV存在未闭合引号'); + if (cell || row.length || closed) finishRow(); + return rows; + } + + function importedArray(value, type, options) { + const trimmed = clean(value); + if (!trimmed) return []; + if (trimmed.startsWith('[')) { + let result; + try { result = JSON.parse(trimmed); } catch (_) { throw new Error('正确答案内容JSON格式错误'); } + return stringArray(result, '正确答案内容'); + } + if (type !== 'multiple' || options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(trimmed, type))) return [trimmed]; + const answers = trimmed.split(/\r?\n/).map(clean).filter(Boolean); + if (answers.some((answer) => !options.some((option) => comparableAnswer(option.text, type) === comparableAnswer(answer, type)))) throw new Error('多选答案请使用JSON数组,或每行填写一个完整选项内容'); + return answers; + } + + function fromCSV(value) { + const rows = parseCSV(value); + if (!rows.length) throw new Error('CSV为空'); + const headers = rows.shift().map((item) => clean(csvRestore(item))); + if (unique(headers).length !== headers.length) throw new Error('CSV存在重复列名'); + if (!headers.includes('完整记录JSON') && (!headers.includes('题干') || !headers.includes('题型'))) throw new Error('CSV需要“题干、题型”列或“完整记录JSON”列'); + const typeAliases = { ...Object.fromEntries(Object.entries(TYPE_NAMES).map(([key, name]) => [name, key])), 单选: 'single', 多选: 'multiple', 判断: 'judge', single: 'single', multiple: 'multiple', judge: 'judge', unknown: 'unknown' }; + const statusAliases = { ...Object.fromEntries(Object.entries(STATUS_NAMES).map(([key, name]) => [name, key])), confirmed: 'confirmed', missing: 'missing', conflict: 'conflict', 无答案: 'missing', 待核验: 'missing' }; + const records = []; + for (let index = 0; index < rows.length; index += 1) { + const cells = rows[index]; + try { + if (cells.length !== headers.length) throw new Error(`列数应为${headers.length},实际为${cells.length}`); + const row = Object.fromEntries(headers.map((header, column) => [header, csvRestore(cells[column])])); + if (clean(row['完整记录JSON'])) { + let raw; + try { raw = JSON.parse(row['完整记录JSON']); } catch (_) { throw new Error('完整记录JSON格式错误'); } + records.push(normalizeRecord(raw)); + continue; + } + const type = typeAliases[clean(row['题型'])]; + if (!type) throw new Error('题型必须为单选题、多选题、判断题或未知'); + const options = headers.filter((header) => /^选项(?:[A-Z]+|\d+)$/u.test(header) && clean(row[header])).map((header) => ({ id: '', label: header.slice(2), text: row[header] })); + const answers = importedArray(row['正确答案内容'], type, options); + const statusText = clean(row['答案状态']); + if (statusText && !own(statusAliases, statusText)) throw new Error('无效答案状态'); + const answerStatus = statusText ? statusAliases[statusText] : answers.length ? 'confirmed' : 'missing'; + const record = normalizeRecord({ bankId: row['题库ID'], bankName: row['来源题库'], questionId: row['题目ID'], type, + stem: row['题干'], options, answerTexts: answers, answerStatus, explanation: row['解析'], + source: { kind: clean(row['答案来源']) || 'csv-import', observedAt: row['采集时间'] } }); + if (record.answerStatus === 'confirmed' && record.options.length && mapAnswers(record, record).status !== 'matched') throw new Error('正确答案内容不能唯一对应选项'); + records.push(record); + } catch (error) { throw new Error(`CSV第${index + 2}行:${error.message}`); } + } + return records; + } + + return { normalizeRecord, mergeRecord, upsert, search, mapAnswers, toCSV, fromCSV }; +})(); + +if (typeof module !== 'undefined' && module.exports) module.exports = QBCore; diff --git a/tests/adapter.test.cjs b/tests/adapter.test.cjs new file mode 100644 index 0000000..44a06b2 --- /dev/null +++ b/tests/adapter.test.cjs @@ -0,0 +1,320 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { extractQuestions, create } = require('../src/adapter.js'); + +const single = () => ({ id: 'q1', quesType: 0, content: '

升温时应选择?

', choiceItems: [ + { id: 'a1', content: '降低温度', selected: 1, answer: 0 }, + { id: 'a2', content: '提高温度', selected: 0, answer: 1 } +] }); + +test('extracts confirmed choices atomically and ignores student selection', () => { + const [q] = extractQuestions({ data: { praId: 'p1', praName: '单选题库', listQues: [single()] } }); + assert.equal(q.bankId, 'p1'); + assert.equal(q.bankName, '单选题库'); + assert.equal(q.stem, '升温时应选择?'); + assert.deepEqual(q.correctOptionIds, ['a2']); + assert.deepEqual(q.answerTexts, ['提高温度']); +}); + +test('selected, submitContent, userAnswer and top-level answer/answers are not standard answers', () => { + const raw = single(); + raw.choiceItems.forEach(o => delete o.answer); + Object.assign(raw, { answer: 'a1', answers: ['a1'], userAnswer: 'a1', submitQuesAnswers: ['a1'], corrected: true }); + const [q] = extractQuestions({ questionList: [raw] }); + assert.equal(q.answerStatus, 'missing'); + assert.deepEqual(q.answerTexts, []); +}); + +test('judge zero and string zero mean incorrect, never truthy correct', () => { + for (const judgeAnswer of [0, '0', 1, '1', null, '-1']) { + const [q] = extractQuestions({ listQues: [{ id: 'j1', quesType: 2, content: '压力越高越安全。', judgeAnswer }] }); + const valid = judgeAnswer === 0 || judgeAnswer === '0' || judgeAnswer === 1 || judgeAnswer === '1'; + assert.equal(q.answerStatus, valid ? 'confirmed' : 'missing'); + assert.deepEqual(q.answerTexts, valid ? [String(judgeAnswer) === '1' ? '正确' : '错误'] : []); + } +}); + +test('multiple answers use ids and follow each response option order without letter assumptions', () => { + const raw = single(); raw.quesType = 1; + raw.choiceItems.push({ id: 'a3', content: '保持压力', answer: 1 }); + raw.choiceItems.reverse(); + const [q] = extractQuestions([raw]); + assert.deepEqual(q.correctOptionIds, ['a3', 'a2']); + assert.deepEqual(q.answerTexts, ['保持压力', '提高温度']); + assert.equal(q.options.find(o => o.id === 'a2').label, 'B'); +}); + +test('answer-only responses require an identified known question', () => { + const raw = single(); raw.choiceItems.forEach(o => delete o.answer); + const [base] = extractQuestions(raw, { bankId: 'p1' }); + const response = [{ answers: ['a2'], corrected: false, explainText: '标准解释' }]; + assert.deepEqual(extractQuestions(response, { answerResponse: true, questionId: 'other', knownQuestions: [base], bankId: 'p1' }), []); + const [q] = extractQuestions(response, { answerResponse: true, questionId: 'q1', knownQuestions: [base], bankId: 'p1' }); + assert.equal(q.questionId, 'q1'); + assert.deepEqual(q.answerTexts, ['提高温度']); + assert.equal(q.explanation, '标准解释'); + assert.deepEqual(extractQuestions([response[0], response[0]], { answerResponse: true, questionId: 'q1', knownQuestions: [base], bankId: 'p1' }), []); +}); + +test('cross-bank id collisions cannot attach an answer to another bank', () => { + const [base] = extractQuestions(single(), { bankId: 'p1' }); + assert.deepEqual(extractQuestions([{ answers: ['a2'] }], { answerResponse: true, questionId: 'q1', knownQuestions: [base], bankId: 'p2' }), []); +}); + +test('known questions support explicit correctAnswers but not invalid option ids', () => { + const raw = single(); raw.choiceItems.forEach(o => delete o.answer); + raw.correctAnswers = ['a2']; + assert.deepEqual(extractQuestions(raw)[0].answerTexts, ['提高温度']); + raw.correctAnswers = ['a1', 'a2']; + assert.equal(extractQuestions(raw)[0].answerStatus, 'missing'); + raw.correctAnswers = ['unknown']; + assert.equal(extractQuestions(raw)[0].answerStatus, 'missing'); +}); + +test('hidden standard answers and unrelated business objects stay unconfirmed', () => { + const raw = single(); raw.allowViewSingleQuesAnswer = 0; + assert.equal(extractQuestions(raw)[0].answerStatus, 'missing'); + assert.deepEqual(extractQuestions({ id: 'x', type: 0, content: '普通通知', selected: true }), []); +}); + +test('resultInfo.answers is explicit standard-answer context on a full question', () => { + const raw = single(); raw.choiceItems.forEach(o => delete o.answer); + raw.answers = ['a1']; raw.resultInfo = { answers: ['a2'], explainText: '说明' }; + assert.deepEqual(extractQuestions(raw)[0].answerTexts, ['提高温度']); +}); + +test('markup and entities preserve comparisons, units and negations', () => { + const raw = single(); raw.content = '

温度<100℃,压力>2MPa时不正确的是?

'; + assert.equal(extractQuestions(raw)[0].stem, '温度<100℃,压力>2MPa时不正确的是?'); +}); + +test('malformed/unsupported types, circular and deeply nested payloads are bounded', () => { + const circular = { data: null }; circular.data = circular; + assert.deepEqual(extractQuestions(circular), []); + assert.deepEqual(extractQuestions({ quesType: 3, id: 'x', content: '填空' }), []); + assert.deepEqual(extractQuestions(null), []); + assert.deepEqual(extractQuestions('not json'), []); + assert.deepEqual(extractQuestions({ ...single(), choiceItems: [null, {}] }), []); + const deeply = {}; let at = deeply; for (let i = 0; i < 30; i++) at = at.data = {}; + at.questionList = [single()]; + assert.deepEqual(extractQuestions(deeply), []); +}); + +function fakePage(fetchImpl = async () => new Response('{}', { headers: { 'content-type': 'application/json' } })) { + class XHR extends EventTarget { + open(method, url) { this.method = method; this.url = url; } + send(body) { this.sentBody = body; } + getResponseHeader(name) { return name === 'content-type' ? 'application/json' : null; } + respond(value) { this.status = 200; this.responseType = ''; this.responseText = JSON.stringify(value); this.dispatchEvent(new Event('load')); } + } + const doc = { title: '题库一', querySelector: () => null, querySelectorAll: () => [] }; + const win = { fetch: fetchImpl, XMLHttpRequest: XHR, location: { href: 'https://www.shhuayi-elearning.cn/o2o/' }, setTimeout, clearTimeout }; + const received = []; + const adapter = create({ window: win, document: doc, onQuestions: values => received.push(...values) }); + return { win, doc, adapter, received }; +} +const flush = () => new Promise(resolve => setImmediate(resolve)); + +test('fetch observation keeps original response, snapshots bank name and restores original fetch', async () => { + let release; + const original = () => new Promise(resolve => { release = resolve; }); + const { win, doc, adapter, received } = fakePage(original); + adapter.start(); + const promise = win.fetch('https://api-ulcd.yunxuetang.cn/v2/upm/start?praId=p1&token=secret'); + doc.title = '题库二'; + const response = new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }); + release(response); + assert.equal(await promise, response); + await flush(); await flush(); + assert.equal(received.length, 1); + assert.equal(received[0].bankName, '题库一'); + assert.equal(received[0].bankId, 'p1'); + assert.equal(received[0].source.url, 'https://api-ulcd.yunxuetang.cn/v2/upm/start'); + assert.equal(response.bodyUsed, false); + adapter.stop(); + assert.equal(win.fetch, original); +}); + +test('responses from a previous capture session are ignored after pause and resume', async () => { + let release; + const { win, adapter, received } = fakePage(() => new Promise(resolve => { release = resolve; })); + adapter.start(); + const promise = win.fetch('/upm/start?praId=p1'); + adapter.stop(); adapter.start(); + release(new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } })); + await promise; await flush(); await flush(); + assert.equal(received.length, 0); + adapter.stop(); +}); + +test('XHR observes standard result by request question id even after another question loads', () => { + const { win, adapter, received } = fakePage(); + const originalOpen = win.XMLHttpRequest.prototype.open; + adapter.start(); + const initial = new win.XMLHttpRequest(); + initial.open('GET', '/upm/start?praId=p1'); initial.send(); + const raw = single(); raw.choiceItems.forEach(o => delete o.answer); + initial.respond({ listQues: [raw, { id: 'q2', quesType: 2, content: '另一题' }] }); + const answer = new win.XMLHttpRequest(); + answer.open('PUT', '/upm/p1/users/u1/answer'); + answer.send(JSON.stringify({ answers: [{ quesId: 'q1', answer: ['a1'] }] })); + answer.respond([{ answers: ['a2'], corrected: false }]); + const result = received.at(-1); + assert.equal(result.questionId, 'q1'); + assert.equal(result.type, 'single'); + assert.deepEqual(result.answerTexts, ['提高温度']); + adapter.stop(); + assert.equal(win.XMLHttpRequest.prototype.open, originalOpen); +}); + +test('unrelated API paths and unrelated hosts do not get captured', async () => { + const { win, adapter, received } = fakePage(async () => new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } })); + adapter.start(); + await win.fetch('https://unknown.example/upm/start?praId=p1'); + await win.fetch('/ordinary/notifications'); + await flush(); + assert.equal(received.length, 0); + adapter.stop(); +}); + +function bankPage(fetchImpl) { + const page = fakePage(fetchImpl); + const events = new EventTarget(); + page.win.addEventListener = events.addEventListener.bind(events); + page.win.removeEventListener = events.removeEventListener.bind(events); + page.win.dispatchEvent = events.dispatchEvent.bind(events); + const state = { sidebar: '判断题库1', player: '判断题库1', exam: '判断题库1', taskId: 'task-a', containerTask: 'task-a', stem: '升温时应选择?', form: true }; + page.win.location.href = 'https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=task-a'; + page.doc.title = '不会随切换更新的旧标题'; + const item = { getAttribute: key => key === 'data-task-id' ? state.taskId : null }; + const sidebar = { get textContent() { return state.sidebar; }, closest: () => item }; + const player = { get textContent() { return state.player; } }; + const exam = { get textContent() { return state.exam; } }; + const container = { getAttribute: key => key === 'task-id' ? state.containerTask : null }; + const main = { querySelectorAll: () => [] }; + const stem = { get textContent() { return state.stem; }, matches: selector => selector === '[data-rich-text]' }; + const options = ['降低温度', '提高温度'].map((value, index) => ({ + querySelector: selector => selector === 'input' ? { value: `a${index + 1}` } : selector === '.yxtf-radio__label, .yxtf-checkbox__label' ? { textContent: `${String.fromCharCode(65 + index)}. ${value}`, querySelector: () => null } : null, + textContent: value + })); + const form = { + get textContent() { return state.stem + options.map(option => option.textContent).join(''); }, + closest: selector => selector === ROOT_SELECTOR_FOR_TEST ? main : null, + getAttribute: () => null, style: {}, + querySelector: selector => selector === '.yxtulcdsdk-form__content' ? { children: [stem] } : null, + querySelectorAll: () => options + }; + page.doc.querySelector = selector => { + if (selector === '.task-content-active .group-title, .task-content-active .task-content-color-active') return state.sidebar ? sidebar : null; + if (selector === 'header.o2oPlayFrame-right .title .yxtf-tooltip') return state.player ? player : null; + if (selector === '.yxtulcdsdk-exam-header .yxtf-tooltip') return state.exam ? exam : null; + if (selector === '.yxtulcdsdk-exam-container[task-id]') return container; + return null; + }; + page.doc.querySelectorAll = selector => selector === '.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form' && state.form ? [form] : []; + const changes = []; + page.adapter = create({ window: page.win, document: page.doc, onQuestions: values => page.received.push(...values), onBankChange: bank => changes.push(bank) }); + return { ...page, state, changes, navigate(name, taskId) { + state.sidebar = name; state.taskId = taskId; + page.win.location.href = `https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=${taskId}`; + page.win.dispatchEvent(new Event('hashchange')); + } }; +} +const ROOT_SELECTOR_FOR_TEST = '.yxtulcdsdk-pc-marking-main'; + +test('current bank follows actual active sidebar instead of stale document title, even without questions', () => { + const { adapter, state, navigate, doc, changes } = bankPage(); + adapter.start(); + assert.equal(adapter.getCurrentBank().bankName, '判断题库1'); + navigate('单选题库1', 'task-b'); + assert.equal(doc.title, '不会随切换更新的旧标题'); + assert.equal(adapter.getCurrentBank().bankName, '单选题库1'); + assert.equal(adapter.getCurrentBank().loading, true); + assert.equal(adapter.getCurrentQuestion(), null, '旧判断题DOM仍在时不能采到新的单选库'); + state.player = state.exam = '单选题库1'; state.containerTask = 'task-b'; + assert.equal(adapter.getCurrentQuestion(), null, '只更新标题和task-id也不能把旧正文移入新库'); + state.stem = '新的单选题目?'; + const question = adapter.getCurrentQuestion(); + assert.equal(question.bankName, '单选题库1'); + assert.equal(question.stem, '新的单选题目?'); + assert.equal(adapter.getCurrentBank().loading, false); + state.form = false; + assert.equal(adapter.getCurrentBank().bankName, '单选题库1', '未出现题目时仍能识别库名用于导出'); + assert.ok(changes.some(bank => bank.bankName === '单选题库1')); + adapter.stop(); +}); + +test('late old-bank response retains source name and cannot change the new active bank', async () => { + const pending = new Map(); + const page = bankPage(url => new Promise(resolve => pending.set(url, resolve))); + page.adapter.start(); + const first = page.win.fetch('/upm/start?praId=bank-a'); + page.navigate('单选题库1', 'task-b'); + page.state.player = page.state.exam = '单选题库1'; page.state.containerTask = 'task-b'; page.state.stem = '新的单选题目?'; + const second = page.win.fetch('/upm/start?praId=bank-b'); + const response = raw => new Response(JSON.stringify({ listQues: [raw] }), { headers: { 'content-type': 'application/json' } }); + pending.get('/upm/start?praId=bank-b')(response({ ...single(), id: 'q-b', content: '新的单选题目?' })); + await second; await flush(); await flush(); + assert.equal(page.adapter.getCurrentBank().bankId, 'bank-b'); + pending.get('/upm/start?praId=bank-a')(response(single())); + await first; await flush(); await flush(); + assert.equal(page.adapter.getCurrentBank().bankName, '单选题库1'); + assert.equal(page.adapter.getCurrentBank().bankId, 'bank-b'); + assert.equal(page.received.find(record => record.bankId === 'bank-a').bankName, '判断题库1'); + page.adapter.stop(); +}); + +test('returning A to B to A does not adopt late B results or stale document title', async () => { + let releaseB; + const page = bankPage(url => String(url).includes('bank-b') ? new Promise(resolve => { releaseB = resolve; }) : Promise.resolve(new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }))); + page.adapter.start(); + await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush(); + page.navigate('单选题库1', 'task-b'); + page.state.player = page.state.exam = '单选题库1'; page.state.containerTask = 'task-b'; page.state.stem = '新的单选题目?'; + const pending = page.win.fetch('/upm/start?praId=bank-b'); + page.navigate('判断题库1', 'task-a'); + page.state.player = page.state.exam = '判断题库1'; page.state.containerTask = 'task-a'; page.state.stem = '升温时应选择?'; + releaseB(new Response(JSON.stringify({ listQues: [{ ...single(), content: '新的单选题目?' }] }), { headers: { 'content-type': 'application/json' } })); + await pending; await flush(); await flush(); + assert.equal(page.adapter.getCurrentBank().bankName, '判断题库1'); + assert.equal(page.adapter.getCurrentBank().bankId, 'bank-a'); + assert.equal(page.received.find(record => record.bankId === 'bank-b').bankName, '单选题库1'); + page.adapter.stop(); +}); + +test('different named response in same task never changes selected bank name or id', async () => { + let payload = { praName: '判断题库1', listQues: [single()] }; + const page = bankPage(async () => new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } })); + page.adapter.start(); + await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush(); + assert.equal(page.adapter.getCurrentBank().bankId, 'bank-a'); + payload = { praName: '接口验收库', listQues: [single()] }; + await page.win.fetch('/upm/start?praId=unrelated-bank'); await flush(); await flush(); + assert.equal(page.adapter.getCurrentBank().bankName, '判断题库1'); + assert.equal(page.adapter.getCurrentBank().bankId, 'bank-a'); + page.adapter.stop(); +}); + +test('bank identity keeps updating while capture and copy are both paused', () => { + const page = bankPage(); + page.adapter.start(); page.adapter.stop(); page.adapter.setCopyEnabled(false); + const before = page.received.length; + page.navigate('多选题库', 'task-c'); + assert.equal(page.changes.at(-1).bankName, '多选题库'); + assert.equal(page.received.length, before); +}); + +test('initial HTML parsing and atomic full SPA render do not block a new form', () => { + const page = bankPage(); + page.state.sidebar = page.state.player = page.state.exam = ''; page.state.form = false; + page.adapter.start(); + page.state.sidebar = page.state.player = page.state.exam = '判断题库1'; page.state.form = true; + assert.ok(page.adapter.getCurrentQuestion(), '首次解析出正文不能当成上一题残留'); + page.state.sidebar = page.state.player = page.state.exam = '单选题库1'; + page.state.taskId = page.state.containerTask = 'task-b'; page.state.stem = '同步渲染的新题目?'; + page.win.location.href = 'https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=task-b'; + const question = page.adapter.getCurrentQuestion(); + assert.equal(question.bankName, '单选题库1'); + assert.equal(question.stem, '同步渲染的新题目?'); + page.adapter.stop(); +}); diff --git a/tests/core.test.cjs b/tests/core.test.cjs new file mode 100644 index 0000000..6ef053f --- /dev/null +++ b/tests/core.test.cjs @@ -0,0 +1,300 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const core = require('../src/core.js'); + +const fixture = (overrides = {}) => ({ + bankId: 'bank-1', bankName: '单选题库', questionId: '2114891149151932417', type: 'single', stem: '温度过低,应如何操作?', + options: [{ id: 'x', text: '降低温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }, { id: 'z', text: '保持不变', label: 'C' }], + correctOptionIds: ['y'], answerStatus: 'confirmed', explanation: '保持合适温度。', source: { kind: 'answer-response', url: 'https://example.test/practice', observedAt: '2026-10-29T00:00:00Z' }, ...overrides, +}); +const reorder = (record) => ({ ...record, options: [record.options[2], record.options[0], record.options[1]].map((option, index) => ({ ...option, label: 'ABC'[index] })) }); + +test('single answers map to current labels after options reorder', () => { + const record = core.normalizeRecord(fixture()); + assert.deepEqual(record.answerTexts, ['提高温度']); + assert.deepEqual(core.mapAnswers(record, reorder(record)), { status: 'matched', labels: ['C'], answerTexts: ['提高温度'] }); +}); + +test('multiple answers map all correct options after reorder', () => { + const record = core.normalizeRecord(fixture({ type: 'multiple', correctOptionIds: ['x', 'z'] })); + assert.deepEqual(core.mapAnswers(record, reorder(record)).labels, ['B', 'A']); +}); + +test('different option IDs can map by unique answer content', () => { + const record = core.normalizeRecord(fixture()); + const current = reorder(record); + current.options = current.options.map((option, index) => ({ ...option, id: `new-${index}` })); + assert.equal(core.mapAnswers(record, current).status, 'matched'); +}); + +test('same ID with different content is not treated as the answer', () => { + const record = core.normalizeRecord(fixture()); + const current = reorder(record); + current.options[0].id = 'y'; current.options[2].id = 'new-y'; + assert.equal(core.mapAnswers(record, current).status, 'unmapped'); +}); + +test('duplicate option content is ambiguous even when ID exists', () => { + const record = core.normalizeRecord(fixture({ options: [{ id: 'x', text: '提高温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }] })); + assert.equal(core.mapAnswers(record, record).status, 'ambiguous'); +}); + +test('same stem but different option sets never auto-map', () => { + const record = core.normalizeRecord(fixture()); + const current = reorder(record); + current.options[0].text = '无法判断'; + assert.equal(core.mapAnswers(record, current).status, 'unmapped'); +}); + +test('missing answer is not inferred from student selection or truthiness', () => { + const record = core.normalizeRecord(fixture({ correctOptionIds: [], answerStatus: undefined, selectedOptionIds: ['y'], userAnswer: 'B' })); + assert.equal(record.answerStatus, 'missing'); + assert.deepEqual(record.answerTexts, []); + const falseRecord = core.normalizeRecord(fixture({ type: 'judge', options: [{ id: 'a', text: '正确' }, { id: 'b', text: '错误' }], correctOptionIds: [], answerTexts: ['false'] })); + assert.deepEqual(falseRecord.answerTexts, ['错误']); + const zeroRecord = core.normalizeRecord(fixture({ type: 'judge', options: [], correctOptionIds: [], answerTexts: ['0'] })); + assert.deepEqual(zeroRecord.answerTexts, ['0']); +}); + +test('explicit missing status suppresses accidentally supplied answers', () => { + const record = core.normalizeRecord(fixture({ answerStatus: 'missing', answerTexts: ['提高温度'] })); + assert.equal(record.answerStatus, 'missing'); + assert.deepEqual(record.correctOptionIds, []); + assert.deepEqual(record.answerTexts, []); +}); + +test('inconsistent correct ID and answer content becomes conflict', () => { + const record = core.normalizeRecord(fixture({ answerTexts: ['降低温度'] })); + assert.equal(record.answerStatus, 'conflict'); + assert.equal(core.mapAnswers(record, record).status, 'ambiguous'); +}); + +test('missing answer is filled and later DOM-only observation preserves confirmed answer', () => { + const missing = fixture({ answerStatus: 'missing', correctOptionIds: [] }); + const filled = core.mergeRecord(missing, fixture()); + assert.equal(filled.answerStatus, 'confirmed'); + const later = { ...reorder(missing), source: { kind: 'dom', observedAt: '2026-10-29T01:00:00Z' } }; + const merged = core.mergeRecord(filled, later); + assert.equal(merged.answerStatus, 'confirmed'); + assert.deepEqual(merged.answerTexts, ['提高温度']); + assert.equal(merged.answerSource.kind, 'answer-response'); +}); + +test('conflicting answers are retained without silently overwriting', () => { + const merged = core.mergeRecord(fixture(), fixture({ correctOptionIds: ['x'] })); + assert.equal(merged.answerStatus, 'conflict'); + assert.equal(merged.conflicts.length, 2); + assert.equal(core.mapAnswers(merged, fixture()).status, 'ambiguous'); + assert.equal(core.mergeRecord(merged, fixture()).answerStatus, 'conflict'); +}); + +test('changed content under an existing ID marks stale answer as conflict', () => { + const changed = fixture({ answerStatus: 'missing', correctOptionIds: [], stem: '温度过高,应如何操作?' }); + const merged = core.mergeRecord(fixture(), changed); + assert.equal(merged.answerStatus, 'conflict'); + assert.equal(core.mapAnswers(merged, changed).status, 'ambiguous'); +}); + +test('fingerprints are order independent, bank aware and keep full option content', () => { + const first = fixture({ questionId: '' }); + const second = reorder(first); + assert.equal(core.upsert([], [first, second]).length, 1); + assert.equal(core.upsert([], [first, { ...second, bankId: 'other' }]).length, 2); + assert.equal(core.upsert([], [first, { ...second, stem: '不同题目' }]).length, 2); +}); + +test('text comparison preserves negation, inequality and superscripts', () => { + const records = ['P水 > P气', 'P水 < P气', 'P水 ≥ P气', '正确的操作', '不正确的操作', '面积为2m²', '面积为2m2'].map((stem, index) => core.normalizeRecord(fixture({ questionId: String(index), stem }))); + assert.equal(core.search(records, 'P水 > P气')[0].record.stem, 'P水 > P气'); + assert.equal(core.search(records, '不正确的操作')[0].record.stem, '不正确的操作'); + assert.equal(core.search(records, '面积为2m²')[0].record.stem, '面积为2m²'); + assert.equal(core.search(records, '温').length, 0); + assert.equal(core.search(records, '62. P水 > P气')[0].match, 'exact'); +}); + +test('CSV roundtrip preserves Chinese, quotes, comma, newline, IDs and conflicts', () => { + const record = core.normalizeRecord(fixture({ stem: '题干含有,逗号和"双引号"\n第二行', explanation: '解析\n仍有,逗号' })); + const csv = core.toCSV([record]); + assert.ok(csv.startsWith('\uFEFF')); + assert.match(csv, /'2114891149151932417/); + assert.deepEqual(core.fromCSV(csv), [record]); + const conflict = core.mergeRecord(fixture(), fixture({ correctOptionIds: ['z'] })); + assert.deepEqual(core.fromCSV(core.toCSV([conflict])), [conflict]); +}); + +test('CSV spreadsheet formula protection is reversible without changing canonical text', () => { + const record = core.normalizeRecord(fixture({ stem: '=SUM(1,2)', explanation: ' @危险公式', bankName: '+题库' })); + const csv = core.toCSV([record]); + assert.match(csv, /"'=SUM\(1,2\)"/); + assert.match(csv, /"'@危险公式"/); + assert.deepEqual(core.fromCSV(csv), [record]); +}); + +test('CSV rejects malformed quotes, inconsistent columns, invalid JSON/types/statuses', () => { + assert.throws(() => core.fromCSV('题干,题型\n"未闭合,单选题'), /未闭合/); + assert.throws(() => core.fromCSV('题干,题型\n题目,单选题,多出一列'), /列数/); + assert.throws(() => core.fromCSV('完整记录JSON\n"{bad}"'), /JSON/); + assert.throws(() => core.fromCSV('题干,题型\n题目,其他题型'), /题型/); + assert.throws(() => core.fromCSV('题干,题型,答案状态\n题目,单选题,猜测'), /状态/); + assert.throws(() => core.fromCSV('题干,题干\n一题,二题'), /重复/); + assert.throws(() => core.fromCSV('题干,题型\n"题目"非法,单选题'), /引号后/); +}); + +test('standard human CSV imports explicit standard answers, never user answers', () => { + const rows = core.fromCSV('来源题库,题干,题型,选项A,选项B,正确答案内容,我的答案\n练习,应提高温度吗,判断题,正确,错误,错误,正确\n练习,如何操作,单选题,提高温度,降低温度,,提高温度'); + assert.deepEqual(rows[0].answerTexts, ['错误']); + assert.equal(rows[0].answerStatus, 'confirmed'); + assert.equal(rows[1].answerStatus, 'missing'); +}); + +test('unsafe numeric IDs are refused rather than rounding identifiers', () => { + assert.throws(() => core.normalizeRecord(fixture({ questionId: 2114891149151932417 })), /不安全/); + assert.equal(core.normalizeRecord(fixture()).questionId, '2114891149151932417'); +}); + +test('DOM-first anonymous record upgrades to unique server identity', () => { + const dom = fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [] }); + const result = core.upsert([dom], [fixture()]); + assert.equal(result.length, 1); + assert.equal(result[0].questionId, fixture().questionId); + assert.equal(result[0].bankId, 'bank-1'); + assert.equal(result[0].answerStatus, 'confirmed'); +}); + +test('server-first record absorbs later anonymous DOM and persistence merges', () => { + const dom = reorder(fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [] })); + const result = core.upsert([fixture()], [dom]); + assert.equal(result.length, 1); + assert.equal(result[0].questionId, fixture().questionId); + assert.deepEqual(result[0].answerTexts, ['提高温度']); + const persisted = core.upsert([dom], result); + assert.equal(persisted.length, 1); + assert.equal(persisted[0].answerStatus, 'confirmed'); +}); + +test('upgrading confirmed anonymous data keeps answer IDs and option IDs atomic', () => { + const old = fixture({ bankId: '', questionId: '' }); + const network = reorder(fixture({ answerStatus: 'missing', correctOptionIds: [] })); + network.options = network.options.map((option, index) => ({ ...option, id: `server-${index}` })); + const result = core.upsert([old], [network]); + assert.equal(result.length, 1); + assert.deepEqual(result[0].correctOptionIds, ['y']); + assert.equal(result[0].options.find((option) => option.id === 'y').text, '提高温度'); + assert.equal(core.mapAnswers(result[0], network).status, 'matched'); +}); + +test('different known bank IDs and same names never merge anonymous records', () => { + const anonymous = fixture({ questionId: '', bankId: 'other-bank' }); + assert.equal(core.upsert([anonymous], [fixture()]).length, 2); +}); + +test('different known question IDs are never merged by content', () => { + assert.equal(core.upsert([fixture()], [fixture({ questionId: 'other-question' })]).length, 2); +}); + +test('anonymous data matching multiple IDs or same-name banks remains separate', () => { + const anonymous = fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [] }); + const anotherId = fixture({ questionId: 'other-id' }); + assert.equal(core.upsert([anonymous], [fixture(), anotherId]).length, 3); + const otherBank = fixture({ bankId: 'other-bank' }); + assert.equal(core.upsert([anonymous], [fixture(), otherBank]).length, 3); +}); + +test('anonymous upgrade requires complete options and exact type/content/bank', () => { + const network = fixture(); + for (const change of [ + { type: 'unknown' }, { bankName: '其他题库' }, { options: [] }, { stem: '温度过高,应如何操作?' }, + { options: [{ id: '', text: '不同选项' }] }, + ]) { + const anonymous = fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [], ...change }); + assert.equal(core.upsert([anonymous], [network]).length, 2); + } +}); + +test('JSON and CSV JSON cannot label an answer outside saved options as confirmed', () => { + const invalid = fixture({ correctOptionIds: [], answerTexts: ['不存在的选项'] }); + const record = core.normalizeRecord(invalid); + assert.equal(record.answerStatus, 'conflict'); + const csv = '完整记录JSON\n"' + JSON.stringify(invalid).replace(/"/g, '""') + '"'; + assert.equal(core.fromCSV(csv)[0].answerStatus, 'conflict'); +}); + +test('text search supports stem, options and confirmed answer fields', () => { + const record = core.normalizeRecord(fixture({ stem: '操作措施的选择' })); + const options = core.search([record], '降低温度', { field: 'options' }); + assert.equal(options[0].matchedField, 'options'); + assert.equal(options[0].match, 'partial'); + assert.equal(core.search([record], '降低温度', { field: 'stem' }).length, 0); + const answer = core.search([record], '提高温度', { field: 'answer' }); + assert.equal(answer[0].matchedField, 'answer'); + assert.equal(core.search([record], '降低温度', { field: 'answer' }).length, 0); + assert.equal(core.search([record], '操作措施')[0].matchedField, 'stem'); + assert.equal(core.search([record], '降低温度')[0].matchedField, 'options'); +}); + +test('text search ranks stem exact and partial before options, answers and similar stems', () => { + const make = (id, stem, options = [{ id: 'x', text: '无关操作' }], answerTexts = []) => core.normalizeRecord(fixture({ + questionId: id, type: 'unknown', stem, options, correctOptionIds: [], answerTexts, + answerStatus: answerTexts.length ? 'confirmed' : 'missing', + })); + const exact = make('exact', '提高温度'); + const partial = make('partial', '如何提高温度以完成干燥'); + const option = make('option', '措施选择', [{ id: 'x', text: '提高温度' }]); + const answer = make('answer', '操作说明', [], ['提高温度']); + const similar = make('similar', '提高温度吗'); + // Query has no punctuation so similar is made genuinely non-contiguous. + similar.stem = '提高操作温度'; + const found = core.search([similar, answer, option, partial, exact], '提高温度'); + assert.deepEqual(found.map((item) => item.record.questionId), ['exact', 'partial', 'option', 'answer', 'similar']); + assert.deepEqual(found.map((item) => item.match), ['exact', 'partial', 'partial', 'partial', 'similar']); +}); + +test('space-separated keywords require every term and can cross fields only in all scope', () => { + const records = [ + core.normalizeRecord(fixture({ questionId: 'one', stem: '温度过低如何处理' })), + core.normalizeRecord(fixture({ questionId: 'two', stem: '温度过低是否保持不变', correctOptionIds: ['z'] })), + ]; + const found = core.search(records, '温度过低 提高温度'); + assert.deepEqual(found.map((item) => item.record.questionId), ['one', 'two']); + assert.equal(core.search(records, '温度过低 提高温度', { field: 'stem' }).length, 0); + assert.equal(core.search(records, '温度过低 不存在关键词').length, 0); + assert.deepEqual(core.search(records, '温度过低 处理', { field: 'stem' }).map((item) => item.record.questionId), ['one']); + assert.equal(core.search(records, '提高温度 保持不变', { field: 'options' }).length, 2); + assert.equal(core.search(records, '提高温度 保持不变', { field: 'answer' }).length, 0); +}); + +test('separate option contents never create a phrase that is absent in all segments', () => { + const record = core.normalizeRecord(fixture({ + stem: '措施选择', options: [{ id: 'x', text: '甲' }, { id: 'y', text: '乙' }], correctOptionIds: ['x'], + })); + assert.equal(core.search([record], '甲乙', { field: 'options' }).length, 0); + assert.equal(core.search([record], '甲 乙', { field: 'options' }).length, 1); +}); + +test('missing and conflicting records are never searched as confirmed answer content', () => { + const missing = core.normalizeRecord(fixture({ questionId: 'missing', answerStatus: 'missing', answerTexts: ['提高温度'] })); + const conflict = core.mergeRecord(fixture(), fixture({ correctOptionIds: ['x'] })); + const records = [missing, conflict]; + assert.equal(core.search(records, '提高温度', { field: 'answer' }).length, 0); + assert.ok(core.search(records, '提高温度', { field: 'options' }).every((item) => item.matchedField === 'options')); +}); + +test('keyword search keeps negation, inequality, case-sensitive units and superscripts', () => { + const records = ['P水 > P气', 'P水 < P气', '面积2m²', '面积2m2', '不正确操作', '正确操作', '压力2MPa', '压力2mPa'].map((stem, index) => core.normalizeRecord(fixture({ questionId: String(index), stem }))); + assert.deepEqual(core.search(records, 'P水 > P气', { field: 'stem' }).map((item) => item.record.stem), ['P水 > P气']); + assert.equal(core.search(records, '不正确操作', { field: 'stem' })[0].record.stem, '不正确操作'); + assert.equal(core.search(records, '面积2m²', { field: 'stem' })[0].record.stem, '面积2m²'); + assert.equal(core.search(records, '压力2MPa', { field: 'stem' })[0].record.stem, '压力2MPa'); +}); + +test('search limit fifty and offset give stable pages independent of input order', () => { + const records = Array.from({ length: 60 }, (_, index) => core.normalizeRecord(fixture({ questionId: `id-${String(index).padStart(2, '0')}`, stem: '完全一致的检索题干' }))); + const first = core.search(records, '完全一致的检索题干', { limit: 50 }); + const second = core.search([...records].reverse(), '完全一致的检索题干', { limit: 50, offset: 50 }); + assert.equal(first.length, 50); + assert.equal(second.length, 10); + assert.equal(first[0].record.questionId, 'id-00'); + assert.equal(second[0].record.questionId, 'id-50'); + assert.equal(new Set([...first, ...second].map((item) => item.record.questionId)).size, 60); + assert.equal(core.search(records, '完全一致的检索题干', { limit: 0 }).length, 0); +}); diff --git a/tests/fixtures/question.html b/tests/fixtures/question.html new file mode 100644 index 0000000..cb310e3 --- /dev/null +++ b/tests/fixtures/question.html @@ -0,0 +1,26 @@ + +
+
判断题
+
+
1.
+
+
干燥过程传质推动力:物料表面水分压P水>热空气中的水分压P水
+
+
+ + +
+
+
+
+
+ + +
+
diff --git a/tests/integration.test.cjs b/tests/integration.test.cjs new file mode 100644 index 0000000..2415b96 --- /dev/null +++ b/tests/integration.test.cjs @@ -0,0 +1,629 @@ +'use strict'; + +// Browser plugin not available. Exercise a local fixture with cached Playwright; +// never load the production site's scripts, APIs, or account session. +const test = require('node:test'); +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 ROOT = path.resolve(__dirname, '..'); +const RECORDS_KEY = 'huayi-qb.records.v1'; +const CONFIG_KEY = 'huayi-qb.config.v1'; +const QA_DIR = path.join(os.tmpdir(), 'huayi-qb-qa-v110'); +const BUNDLED = path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'); + +function loadPlaywright() { + if (process.env.QB_PLAYWRIGHT_MODULE) return require(process.env.QB_PLAYWRIGHT_MODULE); + try { return require('playwright'); } + catch { return require(path.join(BUNDLED, 'playwright')); } +} + +function browserOptions() { + const executablePath = process.env.QB_CHROMIUM_EXECUTABLE || process.env.QB_CHROMIUM_PATH; + if (executablePath) return { headless: true, executablePath }; + const cached = path.join(os.homedir(), 'AppData/Local/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-win64/chrome-headless-shell.exe'); + return fs.existsSync(cached) ? { headless: true, executablePath: cached } : { headless: true }; +} + +function questionMarkup() { + // Keep the selector structure needed by the adapter without depending on an + // account-specific browser save or any production page resources. + return fs.readFileSync(path.join(__dirname, 'fixtures/question.html'), 'utf8'); +} + +function fixture() { + return ` + + 华谊集团化工总控工(高级)-判断题库1 · 本地验收 + +
本地练习页 · 未连接在线服务
+
华谊集团化工总控工(高级)-判断题库1
+
${questionMarkup()}
`; +} + +const BANK_FIXTURES = [ + { id: 'judge-1', name: '华谊集团化工总控工(高级)-判断题库1', taskId: 'qa-task-judge-1', type: 'judge', stem: '判断库一的设备检查结论正确吗?', answer: '正确' }, + { id: 'judge-2', name: '华谊集团化工总控工(高级)-判断题库2', taskId: 'qa-task-judge-2', type: 'judge', stem: '判断库二的维护措施正确吗?', answer: '错误' }, + { id: 'single-1', name: '华谊集团化工总控工(高级)-单选题库1', taskId: 'qa-task-single-1', type: 'single', stem: '单选库一应该采用哪一种换热操作?', answer: '提高温度' }, + { id: 'single-2', name: '华谊集团化工总控工(高级)-单选题库2', taskId: 'qa-task-single-2', type: 'single', stem: '单选库二应该采用哪一种压力操作?', answer: '降低压力' }, + { id: 'multiple', name: '华谊集团化工总控工(高级)-多选题库', taskId: 'qa-task-multiple', type: 'multiple', stem: '多选库有哪些需要同时执行的巡检操作?', answer: '检查温度' } +]; + +function multiBankFixture() { + const first = BANK_FIXTURES[0]; + const sidebar = ``; + return fixture().replace('干燥过程传质推动力:物料表面水分压P水>热空气中的水分压P水', first.stem) + .replace('', '') + .replace('
', `${sidebar}
`).replace('
', '
'); +} + +function bankResponse(bank) { + const choiceItems = bank.type === 'judge' ? undefined : bank.type === 'multiple' + ? [{ id: 'check-t', content: '检查温度', answer: 1 }, { id: 'check-p', content: '检查压力', answer: 1 }, { id: 'skip', content: '跳过巡检', answer: 0 }] + : [{ id: 'yes', content: bank.answer, answer: 1 }, { id: 'hold', content: '保持不变', answer: 0 }, { id: 'no', content: '停止运行', answer: 0 }]; + return { data: { praId: bank.id, praName: bank.name, listQues: [{ id: `question-${bank.id}`, quesType: { single: 0, multiple: 1, judge: 2 }[bank.type], content: bank.stem, ...(choiceItems ? { choiceItems } : { judgeAnswer: bank.answer === '正确' ? 1 : 0 }) }] } }; +} + +async function setupBankSwitches(page) { + await page.evaluate(banks => { + window.__qaBanks = banks; + const installQuestion = bank => { + const container = document.querySelector('.yxtulcdsdk-exam-container'); + container.setAttribute('task-id', bank.taskId); + document.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip').textContent = bank.name; + const main = container.querySelector('.yxtulcdsdk-pc-marking-main'); + main.querySelector('.standard-size-14 span').textContent = { judge: '判断题', single: '单选题', multiple: '多选题' }[bank.type]; + const choices = bank.type === 'judge' ? [['1', '正确'], ['0', '错误']] : bank.type === 'multiple' + ? [['check-t', '检查温度'], ['check-p', '检查压力'], ['skip', '跳过巡检']] + : [['hold', '保持不变'], ['no', '停止运行'], ['yes', bank.answer]]; + const isMultiple = bank.type === 'multiple'; + main.querySelector('.yxtulcdsdk-form__content').innerHTML = '
' + choices.map(([id, text], index) => ``).join('') + '
'; + main.querySelector('.yxtulcdsdk-form__content > [data-rich-text]').textContent = bank.stem; + }; + installQuestion(banks[0]); + document.querySelectorAll('[data-qa-bank]').forEach(button => button.addEventListener('click', () => { + const bank = banks.find(item => item.id === button.dataset.qaBank); + document.querySelectorAll('[data-qa-bank]').forEach(item => { + item.classList.toggle('task-content-active', item === button); + item.querySelector('.group-title').classList.toggle('task-content-color-active', item === button); + }); + location.hash = `/playinfo?taskId=${bank.taskId}`; + window.setTimeout(async () => { installQuestion(bank); await fetch(`/upm/start?praId=${bank.id}&item=bank-${bank.id}`).then(response => response.json()); }, 90); + })); + }, BANK_FIXTURES); + await page.evaluate(bank => fetch(`/upm/start?praId=${bank.id}&item=bank-${bank.id}`).then(response => response.json()), BANK_FIXTURES[0]); +} + +async function downloadedCSV(page, filename) { + const waiting = page.waitForEvent('download'); + await page.locator('[data-action="export-csv"]').click(); + const download = await waiting; + const location = path.join(QA_DIR, filename); + await download.saveAs(location); + return { name: download.suggestedFilename(), text: fs.readFileSync(location, 'utf8') }; +} + +function installGMShim() { + window.unsafeWindow = window; + window.__qbMenus = []; + window.GM_getValue = (key, fallback) => { + const value = localStorage.getItem(key); + return value === null ? fallback : JSON.parse(value); + }; + window.GM_setValue = (key, value) => localStorage.setItem(key, JSON.stringify(value)); + window.GM_registerMenuCommand = (label, callback) => { window.__qbMenus.push({ label, callback }); return window.__qbMenus.length; }; + window.GM_addValueChangeListener = () => 1; + window.GM_removeValueChangeListener = () => {}; +} + +async function waitFor(predicate, message, timeout = 5000) { + const expires = Date.now() + timeout; + do { + const value = await predicate(); + if (value) return value; + await new Promise(resolve => setTimeout(resolve, 40)); + } while (Date.now() < expires); + assert.fail(message); +} + +async function storageRecords(page) { + return page.evaluate(key => window.GM_getValue(key, []), RECORDS_KEY); +} + +async function activate(page, tab) { + const exact = page.locator(`#huayi-qb-root [data-tab="${tab}"]`); + if (await exact.count()) { await exact.click(); return; } + const names = { search: /检索|查询/, library: /题库|导出/, settings: /设置/, collect: /采集/ }; + const candidates = page.getByRole('tab', { name: names[tab] }); + if (await candidates.count()) await candidates.first().click(); +} + +async function importFile(page, name, text) { + await activate(page, 'library'); + await page.locator('#qb-import-file').setInputFiles({ name, mimeType: name.endsWith('.json') ? 'application/json' : 'text/csv', buffer: Buffer.from(text) }); +} + +async function selectQuestion(page) { + await page.evaluate(() => { + const content = document.querySelector('.yxtulcdsdk-form__content > [data-rich-text]'); + const range = document.createRange(); + range.selectNodeContents(content); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(range); + content.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + document.dispatchEvent(new Event('selectionchange')); + }); +} + +async function setSingleQuestion(page) { + await page.evaluate(() => { + const container = document.querySelector('.yxtulcdsdk-pc-marking-main'); + container.querySelector('.standard-size-14 span').textContent = '单选题'; + container.querySelector('.yxtulcdsdk-form__sub').textContent = '7.'; + container.querySelector('.yxtulcdsdk-form__content').innerHTML = '
以下哪项操作可以提高温度?
' + + [['z', '保持不变'], ['x', '降低温度'], ['y', '提高温度']].map(([id, text], i) => '').join('') + '
'; + }); +} + +test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢复', { timeout: 150000 }, async t => { + const { chromium } = loadPlaywright(); + const builtPath = path.join(ROOT, 'dist/huayi-question-bank.user.js'); + assert.ok(fs.existsSync(builtPath), '先运行 node build.cjs 生成安装版脚本'); + const script = fs.readFileSync(builtPath, 'utf8'); + fs.mkdirSync(QA_DIR, { recursive: true }); + const markup = fixture(); + const server = http.createServer((req, res) => { + if (req.url === '/favicon.ico') { res.writeHead(204); res.end(); return; } + if (req.url.startsWith('/upm/start') || req.url.startsWith('/unrelated/report')) { + const item = new URL(req.url, 'http://127.0.0.1').searchParams.get('item') || '1'; + res.writeHead(200, { 'Content-Type': 'application/json' }); + if (item.startsWith('bank-')) { + const bank = BANK_FIXTURES.find(candidate => item === `bank-${candidate.id}`); + const delay = Math.min(1000, Number(new URL(req.url, 'http://127.0.0.1').searchParams.get('delay')) || 0); + const finish = () => res.end(JSON.stringify(bank ? bankResponse(bank) : { data: {} })); + if (delay) setTimeout(finish, delay); else finish(); + return; + } + if (item === 'scope-current') { + res.end(JSON.stringify({ data: { praId: 'scope-bank', praName: '范围验收题库', listQues: [{ + id: 'scope-current', quesType: 0, content: '当前题库范围验收题', + choiceItems: [{ id: 'x', content: '降低温度' }, { id: 'y', content: '提高温度' }, { id: 'z', content: '保持不变' }] + }] } })); + return; + } + res.end(JSON.stringify({ data: { praId: 'qa-network', praName: '接口验收题库', listQues: [{ + id: `network-${item}`, quesType: 0, content: `接口加载时尚未返回答案的题目 ${item}`, + choiceItems: [{ id: 'n-a', content: '我的错误作答' }, { id: 'n-b', content: '平台标准选项' }] + }] } })); + return; + } + if (req.url === '/upm/qa-network/users/qa-user/answer') { + req.resume(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: { correctAnswers: ['n-b'], explainText: '来自正常作答结果的标准答案。' } })); + return; + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(req.url.startsWith('/multi-bank') ? multiBankFixture() : markup); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + const browser = await chromium.launch(browserOptions()); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true }); + await context.addInitScript(installGMShim); + await context.addInitScript({ content: script }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 204, body: '' })); + + try { + await page.goto(`${origin}/o2o/#/playinfo?taskId=qa-task-initial`, { waitUntil: 'domcontentloaded' }); + await page.locator('#qb-panel').waitFor({ state: 'visible' }); + await t.test('页面非空、脱敏DOM夹具采集缺答案题目并恢复文字选择', async () => { + assert.match(await page.title(), /判断题库1/); + assert.equal(new URL(page.url()).origin, origin); + assert.match(await page.locator('.fixture-content').innerText(), /干燥过程传质推动力/); + const records = await waitFor(async () => (await storageRecords(page)).find(record => record.stem.includes('干燥过程传质推动力')), '真实题干应写入本地题库'); + assert.equal(records.answerStatus, 'missing'); + assert.equal(records.answerTexts.length, 0); + assert.equal(await page.locator('.yxtulcdsdk-pc-marking-main').evaluate(element => getComputedStyle(element).userSelect), 'text'); + assert.equal(await page.locator('nextjs-portal, vite-error-overlay, #webpack-dev-server-client-overlay').count(), 0); + }); + + await t.test('真实fetch与XHR响应观察:标准答案按同题ID补齐,暂停后停止采集', async () => { + await page.evaluate(() => fetch('/upm/start?praId=qa-network').then(response => response.json())); + const loaded = await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === 'network-1'), 'fetch返回的完整题目应被采集'); + assert.equal(loaded.answerStatus, 'missing'); + await page.evaluate(() => new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('PUT', '/upm/qa-network/users/qa-user/answer'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onload = () => resolve(xhr.status); + xhr.onerror = reject; + xhr.send(JSON.stringify({ answers: [{ quesId: 'network-1', answers: ['n-a'] }] })); + })); + const completed = await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === 'network-1' && record.answerStatus === 'confirmed'), 'XHR标准答案应按请求题ID补齐'); + assert.deepEqual(completed.answerTexts, ['平台标准选项']); + assert.ok(!completed.answerTexts.includes('我的错误作答'), '不能将学生选择作为标准答案'); + await page.evaluate(() => fetch('/unrelated/report?item=noise').then(response => response.text())); + await page.waitForTimeout(300); + assert.equal((await storageRecords(page)).some(record => record.questionId === 'network-noise'), false, '无关接口不能采集'); + await activate(page, 'library'); + await page.locator('[data-action="toggle-capture"]').click(); + await page.evaluate(() => fetch('/upm/start?praId=qa-network&item=paused').then(response => response.json())); + await page.waitForTimeout(300); + assert.equal((await storageRecords(page)).some(record => record.questionId === 'network-paused'), false); + await page.locator('[data-action="toggle-capture"]').click(); + await page.evaluate(() => fetch('/upm/start?praId=qa-network&item=resumed').then(response => response.json())); + await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'network-resumed'), '恢复采集后应重新观察fetch'); + await activate(page, 'search'); + }); + + await t.test('关闭复制恢复开关后原禁选样式生效,再开启恢复文字选择', async () => { + await activate(page, 'settings'); + await page.locator('#qb-copy-enabled').uncheck(); + assert.equal(await page.locator('.yxtulcdsdk-pc-marking-main').evaluate(element => getComputedStyle(element).userSelect), 'none'); + await page.locator('#qb-copy-enabled').check(); + assert.equal(await page.locator('.yxtulcdsdk-pc-marking-main').evaluate(element => getComputedStyle(element).userSelect), 'text'); + await activate(page, 'search'); + }); + + await t.test('折叠后可展开;隐藏与刷新后恢复入口保留', async () => { + const fullHeight = (await page.locator('#qb-panel').boundingBox()).height; + await page.locator('[data-action="collapse"]').click(); + const collapsedHeight = (await page.locator('#qb-panel').boundingBox()).height; + assert.ok(collapsedHeight < fullHeight, '折叠应缩小面板'); + await page.locator('[data-action="collapse"]').click(); + await page.locator('.header [data-action="hide"]').click(); + assert.equal(await page.locator('#qb-panel').isVisible(), false); + assert.equal(await page.locator('#qb-launcher').isVisible(), true); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.locator('#qb-launcher').waitFor({ state: 'visible' }); + assert.equal(await page.locator('#qb-panel').isVisible(), false); + await selectQuestion(page); + await page.waitForTimeout(300); + assert.equal(await page.locator('#qb-panel').isVisible(), false, '隐藏后划词不能擅自弹出面板'); + await page.screenshot({ path: path.join(QA_DIR, 'hidden-launcher.png') }); + await page.locator('[data-action="restore"]').click(); + assert.equal(await page.locator('#qb-panel').isVisible(), true); + assert.match(await page.locator('#qb-search').inputValue(), /干燥过程传质推动力/, '恢复面板后应保留隐藏时划词检索的内容'); + }); + + await t.test('完全隐藏后快捷键与油猴菜单均可唤出', async () => { + await activate(page, 'settings'); + await page.locator('#qb-keep-launcher').uncheck(); + await page.locator('.header [data-action="hide"]').click(); + assert.equal(await page.locator('#qb-panel').isVisible(), false); + assert.equal(await page.locator('#qb-launcher').isVisible(), false); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#huayi-qb-root', { state: 'attached' }); + assert.equal(await page.locator('#qb-launcher').isVisible(), false); + await page.keyboard.press('Alt+Shift+Q'); + await page.locator('#qb-panel').waitFor({ state: 'visible' }); + await page.locator('.header [data-action="hide"]').click(); + const menuFound = await page.evaluate(() => { + const menu = window.__qbMenus.find(item => /显示|唤出|恢复|打开/.test(item.label)); + if (!menu) return false; + menu.callback(); + return true; + }); + assert.equal(menuFound, true, '应提供油猴菜单恢复入口'); + assert.equal(await page.locator('#qb-panel').isVisible(), true); + await activate(page, 'settings'); + await page.locator('#qb-keep-launcher').check(); + }); + + await t.test('JSON导入与当前页面选项乱序:正确内容映射为C', async () => { + const initial = (await storageRecords(page))[0]; + const record = { + bankId: initial.bankId, bankName: initial.bankName, questionId: 'e2e-single-7', + type: 'single', stem: '以下哪项操作可以提高温度?', + options: [{ id: 'x', text: '降低温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }, { id: 'z', text: '保持不变', label: 'C' }], + correctOptionIds: ['y'], answerTexts: ['提高温度'], answerStatus: 'confirmed', + explanation: '用于验证答案内容与页面当前顺序对应。', source: { kind: 'fixture', url: origin, observedAt: new Date().toISOString() } + }; + await importFile(page, 'verified-question.json', JSON.stringify({ schemaVersion: 1, records: [record] })); + await waitFor(async () => (await storageRecords(page)).some(item => item.questionId === record.questionId), 'JSON导入应持久化'); + await setSingleQuestion(page); + await activate(page, 'search'); + await page.locator('#qb-search').fill(record.stem); + await waitFor(async () => /提高温度/.test(await page.locator('#qb-panel').innerText()), '检索应显示正确答案内容'); + await waitFor(async () => /当前[^\n]{0,20}C|对应[^\n]{0,20}C/.test(await page.locator('#qb-panel').innerText()), '原B答案应按页面乱序映射到C'); + await waitFor(async () => (await storageRecords(page)).filter(item => item.stem === record.stem).length === 1, '无ID的DOM题目应与唯一同题标准答案合并'); + assert.equal(await page.locator('#qb-results article').count(), 1, '同一题不能同时出现已确认和待补两张卡'); + await page.screenshot({ path: path.join(QA_DIR, 'desktop-answer-mapping.png') }); + }); + + let exportedCSV = ''; + await t.test('CSV真实下载、BOM与答案内容及重新导入', async () => { + await activate(page, 'library'); + await page.locator('#qb-scope').selectOption(''); + const downloaded = page.waitForEvent('download'); + await page.locator('[data-action="export-csv"]').click(); + const download = await downloaded; + const csvPath = path.join(QA_DIR, 'exported-question-bank.csv'); + await download.saveAs(csvPath); + const bytes = fs.readFileSync(csvPath); + assert.equal(bytes.subarray(0, 3).toString('hex'), 'efbbbf'); + exportedCSV = bytes.toString('utf8'); + assert.match(exportedCSV, /提高温度/); + const before = (await storageRecords(page)).length; + await importFile(page, 'round-trip.csv', exportedCSV); + await page.waitForTimeout(350); + assert.equal((await storageRecords(page)).length, before, 'CSV重复导入应去重'); + }); + + await t.test('390px视口面板可见且不超出视口', async () => { + await page.setViewportSize({ width: 390, height: 844 }); + await activate(page, 'search'); + await page.locator('#qb-search').fill('以下哪项操作可以提高温度?'); + await page.waitForTimeout(200); + const rect = await page.locator('#qb-panel').boundingBox(); + assert.ok(rect.x >= 0 && rect.x + rect.width <= 391, '面板横向不应溢出'); + assert.ok(rect.y >= 0 && rect.y + rect.height <= 845, '面板纵向应适配可用高度'); + await page.screenshot({ path: path.join(QA_DIR, 'mobile-answer-mapping.png') }); + }); + + await t.test('格式错误导入保持已有题库并给出失败提示', async () => { + const before = (await storageRecords(page)).length; + await importFile(page, 'broken.json', '{"schemaVersion":1,"records":[{"stem":"缺少题型"}]}'); + await waitFor(async () => /导入失败/.test(await page.locator('#qb-status').innerText()), '错误文件应显示导入失败'); + assert.equal((await storageRecords(page)).length, before); + }); + + await t.test('当前题库范围包含唯一同名无bankId旧题;同名多bankId时不盲并', async () => { + await setSingleQuestion(page); + await page.evaluate(() => { + document.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip').textContent = '范围验收题库'; + document.querySelector('.yxtulcdsdk-form__content > [data-rich-text]').textContent = '当前题库范围验收题'; + }); + await page.evaluate(() => fetch('/upm/start?praId=scope-bank&item=scope-current').then(response => response.json())); + await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'scope-current' && record.bankId === 'scope-bank'), '当前题应从网络数据取得稳定题库ID'); + const shared = { + bankName: '范围验收题库', type: 'single', + options: [{ id: 'x', text: '降低温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }, { id: 'z', text: '保持不变', label: 'C' }], + correctOptionIds: ['y'], answerTexts: ['提高温度'], answerStatus: 'confirmed', + source: { kind: 'import', url: '', observedAt: new Date().toISOString() } + }; + await importFile(page, 'current-scope.json', JSON.stringify({ schemaVersion: 1, records: [ + { ...shared, bankId: 'scope-bank', questionId: 'scope-current', stem: '当前题库范围验收题' }, + { ...shared, bankId: '', questionId: 'scope-legacy', stem: '只有题库名称的旧题也应导出' } + ] })); + await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'scope-legacy'), '同名无bankId旧题应已导入'); + await page.locator('#qb-scope').selectOption('__current__'); + const downloadCSV = async filename => { + const waiting = page.waitForEvent('download'); + await page.locator('[data-action="export-csv"]').click(); + const download = await waiting; + const location = path.join(QA_DIR, filename); + await download.saveAs(location); + return fs.readFileSync(location, 'utf8'); + }; + const uniqueScope = await downloadCSV('current-scope-unique-name.csv'); + assert.match(uniqueScope, /scope-current/); + assert.match(uniqueScope, /scope-legacy/, '唯一同名旧题不能从当前题库导出中遗漏'); + await importFile(page, 'ambiguous-scope.json', JSON.stringify({ schemaVersion: 1, records: [ + { ...shared, bankId: 'scope-other-bank', questionId: 'scope-other', stem: '同名但不同题库ID的题目' } + ] })); + await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'scope-other'), '第二个同名题库应已导入'); + const ambiguousScope = await downloadCSV('current-scope-ambiguous-name.csv'); + assert.match(ambiguousScope, /scope-current/); + assert.doesNotMatch(ambiguousScope, /scope-legacy/, '存在同名多个题库ID时不能猜测无ID旧题归属'); + assert.doesNotMatch(ambiguousScope, /scope-other/, '其他题库ID不能混入当前题库范围'); + await page.locator('#qb-scope').selectOption(''); + }); + + await t.test('JSON批次第二题非法时整批保持未导入', async () => { + const before = (await storageRecords(page)).length; + const valid = { + bankId: 'batch-bank', bankName: '批次验收题库', questionId: 'atomic-first-must-not-save', + type: 'single', stem: '批次第一条有效题目', options: [{ id: 'a', text: '选项一', label: 'A' }, { id: 'b', text: '选项二', label: 'B' }], + correctOptionIds: ['a'], answerTexts: ['选项一'], answerStatus: 'confirmed' + }; + await importFile(page, 'invalid-second-record.json', JSON.stringify({ schemaVersion: 1, records: [ + valid, { ...valid, questionId: 'atomic-invalid-second', type: 'unsupported', stem: '批次第二条非法题目' } + ] })); + await waitFor(async () => /导入失败/.test(await page.locator('#qb-status').innerText()), '第二题非法应显示整批导入失败'); + const records = await storageRecords(page); + assert.equal(records.length, before); + assert.equal(records.some(record => record.questionId === 'atomic-first-must-not-save'), false, '不能先导入第一条再失败'); + }); + + await t.test('已确认答案不属于选项时展示冲突卡且不给出答案字母', async () => { + const stem = '答案不在选项冲突验收题'; + await importFile(page, 'invalid-confirmed-answer.json', JSON.stringify({ schemaVersion: 1, records: [{ + bankId: 'conflict-bank', bankName: '冲突验收题库', questionId: 'answer-outside-options', type: 'single', stem, + options: [{ id: 'a', text: '选项一', label: 'A' }, { id: 'b', text: '选项二', label: 'B' }], + answerTexts: ['不存在的正确选项'], answerStatus: 'confirmed' + }] })); + const record = await waitFor(async () => (await storageRecords(page)).find(item => item.questionId === 'answer-outside-options'), '不一致答案应保留供核验'); + assert.equal(record.answerStatus, 'conflict'); + await activate(page, 'search'); + await page.locator('#qb-search-scope').selectOption(''); + await page.locator('#qb-search').fill(stem); + await waitFor(async () => (await page.locator('#qb-results .badge.conflict').count()) === 1, '不一致答案应显示冲突状态'); + const card = page.locator('#qb-results article').first(); + assert.match(await card.innerText(), /存在不同标准答案,需要核验/); + assert.doesNotMatch(await card.innerText(), /当前页面对应[::]/, '冲突答案不能显示已确认字母'); + }); + + await t.test('多题库:文字搜索、当前库统计与命名、多文件导入', async group => { + const bankContext = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true }); + await bankContext.addInitScript(installGMShim); + await bankContext.addInitScript({ content: script }); + await bankContext.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 204, body: '' })); + const bankPage = await bankContext.newPage(); + bankPage.on('pageerror', error => errors.push(error.message)); + bankPage.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + try { + await bankPage.goto(`${origin}/multi-bank#/playinfo?taskId=${BANK_FIXTURES[0].taskId}`, { waitUntil: 'domcontentloaded' }); + await bankPage.locator('#qb-panel').waitFor({ state: 'visible' }); + await setupBankSwitches(bankPage); + await waitFor(async () => (await storageRecords(bankPage)).some(record => record.bankId === 'judge-1'), '初始判断题库应采集到稳定身份'); + + await group.test('五个题库侧栏切换只统计和导出当前库,文件名随实际名称变化', async () => { + for (const bank of BANK_FIXTURES) { + if (bank.id !== BANK_FIXTURES[0].id) await bankPage.locator(`[data-qa-bank="${bank.id}"]`).click(); + await waitFor(async () => (await storageRecords(bankPage)).some(record => record.bankId === bank.id), `${bank.name}应采集成功`); + await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes(bank.name), '当前题库名称应跟随红色侧栏项'); + await activate(bankPage, 'library'); + assert.equal(await bankPage.locator('#qb-scope').inputValue(), '__current__', '切换后默认导出当前题库'); + assert.equal((await bankPage.locator('#qb-stat-total').innerText()).replace(/,/g, ''), '1', '切换后主统计不能累积其他题库'); + assert.ok((await bankPage.locator('#qb-export-name').innerText()).includes(bank.name), '导出说明应包含实际题库名称'); + const exported = await downloadedCSV(bankPage, `current-${bank.id}.csv`); + assert.ok(exported.name.includes(bank.name), `文件名应使用实际题库名称:${exported.name}`); + assert.ok(exported.text.includes(bank.stem)); + for (const other of BANK_FIXTURES.filter(item => item.id !== bank.id)) assert.ok(!exported.text.includes(other.stem), '当前库CSV不能混入已保存的其他题库'); + } + assert.match(await bankPage.title(), /判断题库1/, '页面title保持旧值,验证侧栏优先识别'); + const stored = await storageRecords(bankPage); + for (const bank of BANK_FIXTURES) { + const exact = stored.filter(record => record.stem === bank.stem); + assert.equal(exact.length, 1, '切换过渡期间旧题不得采为新题库副本'); + assert.equal(exact[0].bankId, bank.id); + assert.equal(exact[0].bankName, bank.name); + } + await bankPage.screenshot({ path: path.join(QA_DIR, 'current-bank-export.png') }); + }); + + await group.test('文字搜索可手输题干、选项与标准答案,支持当前库和全部库', async () => { + await bankPage.evaluate(() => { window.__qaLateResponse = fetch('/upm/start?praId=multiple&item=bank-multiple&delay=500').then(response => response.json()); }); + await bankPage.locator('[data-qa-bank="single-1"]').click(); + await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes('单选题库1'), '应切至单选库1'); + await bankPage.evaluate(() => window.__qaLateResponse); + assert.ok((await bankPage.locator('#qb-context-name').innerText()).includes('单选题库1'), '旧题库迟到响应不能把当前名称改回去'); + await activate(bankPage, 'search'); + await bankPage.locator('#qb-search-field').selectOption('stem'); + await bankPage.locator('#qb-search').fill('单选库一'); + await bankPage.locator('#qb-search').press('Enter'); + await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 1, '手输题干应得到唯一候选'); + assert.match(await bankPage.locator('#qb-results').innerText(), /提高温度/); + await bankPage.locator('#qb-search-field').selectOption('options'); + await bankPage.locator('#qb-search').fill('提高温度'); + await bankPage.locator('[data-action="search"]').click(); + await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 1, '选项文字应可检索'); + await bankPage.locator('#qb-search-field').selectOption('answer'); + await bankPage.locator('#qb-search').fill('降低压力'); + await bankPage.locator('#qb-search').press('Enter'); + await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 0, '当前单选库1不应查出单选库2答案'); + await bankPage.locator('#qb-search-scope').selectOption(''); + await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 1, '全部库应检索到另一题库的标准答案'); + assert.match(await bankPage.locator('#qb-results').innerText(), /单选题库2/); + await bankPage.locator('[data-action="clear-search"]').click(); + assert.equal(await bankPage.locator('#qb-search').inputValue(), ''); + assert.equal(await bankPage.locator('#qb-results article').count(), 0); + await bankPage.locator('#qb-search-field').selectOption('all'); + await bankPage.locator('#qb-search').fill('巡检'); + await bankPage.locator('#qb-search').press('Enter'); + await waitFor(async () => (await bankPage.locator('#qb-results article').count()) > 0, '全部字段关键词应可查询'); + await bankPage.screenshot({ path: path.join(QA_DIR, 'text-search-all-banks.png') }); + }); + + await group.test('一次导入多个CSV和JSON题库并保留各自名称,重复导入不增加题数', async () => { + const jsonRecord = { + bankId: 'import-chemistry', bankName: '导入化学题库', questionId: 'import-chemical-one', type: 'single', stem: '化学导入样题的答案是什么?', + options: [{ id: 'a', text: '甲', label: 'A' }, { id: 'b', text: '乙', label: 'B' }], answerTexts: ['乙'], correctOptionIds: ['b'], answerStatus: 'confirmed' + }; + const files = [ + { name: '化学.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ schemaVersion: 1, records: [jsonRecord] })) }, + { name: '设备.csv', mimeType: 'text/csv', buffer: Buffer.from('来源题库,题型,题干,选项A,选项B,正确答案内容,答案状态\n导入设备题库,单选题,设备导入样题如何操作?,开阀,关阀,开阀,已确认\n') }, + { name: '文件名备用题库.csv', mimeType: 'text/csv', buffer: Buffer.from('题型,题干,选项A,选项B,正确答案内容,答案状态\n单选题,没有来源名称的题目如何归库?,一,二,二,已确认\n') } + ]; + await activate(bankPage, 'library'); + assert.equal(await bankPage.locator('#qb-import-file').getAttribute('multiple') !== null, true); + await bankPage.locator('#qb-import-file').setInputFiles(files); + await waitFor(async () => (await storageRecords(bankPage)).some(record => record.questionId === jsonRecord.questionId), '多个文件应一批导入'); + const records = await storageRecords(bankPage); + assert.ok(records.some(record => record.bankName === '导入化学题库' && record.stem === jsonRecord.stem)); + assert.ok(records.some(record => record.bankName === '导入设备题库' && record.stem.startsWith('设备导入'))); + assert.ok(records.some(record => record.bankName === '文件名备用题库' && record.stem.startsWith('没有来源名称'))); + const total = records.length; + await bankPage.locator('#qb-import-file').setInputFiles(files); + await waitFor(async () => /导入/.test(await bankPage.locator('#qb-status').innerText()), '重复多文件导入应完成'); + await bankPage.waitForTimeout(350); + assert.equal((await storageRecords(bankPage)).length, total); + assert.equal((await bankPage.locator('#qb-stat-total').innerText()).replace(/,/g, ''), '1', '导入多个题库不改变当前题库统计'); + }); + + await group.test('多个文件中一个格式错误时整批不导入', async () => { + const before = (await storageRecords(bankPage)).length; + await bankPage.locator('#qb-import-file').setInputFiles([ + { name: '这一库不能部分写入.csv', mimeType: 'text/csv', buffer: Buffer.from('来源题库,题型,题干,选项A,选项B,正确答案内容\n原子文件题库,单选题,多文件失败前有效题目,一,二,一\n') }, + { name: '坏文件.json', mimeType: 'application/json', buffer: Buffer.from('{"schemaVersion":1,"records":[{"stem":"缺少题型"}]}') } + ]); + await waitFor(async () => /导入失败/.test(await bankPage.locator('#qb-status').innerText()), '应提示多文件批次失败'); + assert.equal((await storageRecords(bankPage)).length, before); + assert.equal((await storageRecords(bankPage)).some(record => record.stem === '多文件失败前有效题目'), false); + }); + + await group.test('导出指定题库使用实际名称并清理Windows文件名非法字符', async () => { + const bankName = '自定义:专项/题库*?'; + await importFile(bankPage, 'special.json', JSON.stringify({ schemaVersion: 1, records: [{ + bankId: 'special-bank', bankName, questionId: 'special-one', type: 'judge', stem: '特殊名称题库的判断题', options: [{ id: '1', text: '正确' }, { id: '0', text: '错误' }], answerTexts: ['正确'], correctOptionIds: ['1'], answerStatus: 'confirmed' + }] })); + const value = await bankPage.locator('#qb-scope option').evaluateAll((options, name) => options.find(option => option.textContent.includes(name))?.value, bankName); + assert.ok(value, '应可选择刚导入的指定题库'); + await bankPage.locator('#qb-scope').selectOption(value); + const exported = await downloadedCSV(bankPage, 'special-name.csv'); + assert.match(exported.name, /自定义.*专项.*题库/); + assert.doesNotMatch(exported.name, /[<>:"/\\|?*]/, '文件名不能包含Windows非法字符'); + assert.match(exported.text, /特殊名称题库的判断题/); + assert.ok(exported.text.includes(bankName), 'CSV内部来源名称应保留原文'); + }); + + await group.test('新空题库切换后不继续显示或导出上一库数据,窄屏布局正常', async () => { + await bankPage.evaluate(() => { + document.querySelectorAll('.task-content-active').forEach(item => item.classList.remove('task-content-active')); + const button = document.createElement('button'); + button.className = 'task-content task-content-active'; + button.dataset.taskId = 'qa-task-empty'; + button.innerHTML = '尚未载入的空题库'; + document.querySelector('#qa-sidebar').append(button); + location.hash = '/playinfo?taskId=qa-task-empty'; + document.querySelector('.yxtulcdsdk-exam-container').setAttribute('task-id', 'qa-task-empty'); + document.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip').textContent = '尚未载入的空题库'; + document.querySelector('.yxtulcdsdk-pc-marking-main').replaceChildren(); + }); + await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes('尚未载入的空题库'), '空库名称也应及时更新'); + await activate(bankPage, 'library'); + assert.equal(await bankPage.locator('#qb-scope').inputValue(), '__current__'); + await waitFor(async () => (await bankPage.locator('#qb-stat-total').innerText()).trim() === '0', '空库主计数应为0'); + let downloaded = false; + const captureDownload = () => { downloaded = true; }; + bankPage.on('download', captureDownload); + await bankPage.locator('[data-action="export-csv"]').click(); + await bankPage.waitForTimeout(250); + bankPage.off('download', captureDownload); + assert.equal(downloaded, false, '空库不能导出上一题库的数据'); + assert.match(await bankPage.locator('#qb-status').innerText(), /没有题目|暂无|尚未/); + await bankPage.setViewportSize({ width: 390, height: 844 }); + await activate(bankPage, 'search'); + const rect = await bankPage.locator('#qb-panel').boundingBox(); + assert.ok(rect.x >= 0 && rect.x + rect.width <= 391); + assert.ok(rect.y >= 0 && rect.y + rect.height <= 845); + await bankPage.screenshot({ path: path.join(QA_DIR, 'mobile-current-bank.png') }); + }); + } finally { await bankContext.close(); } + }); + + await t.test('控制台无脚本错误', async () => { assert.deepEqual(errors, []); }); + t.diagnostic(`Browser plugin not available; cached Chromium via Playwright. Local origin: ${origin}; viewports 1440x1000 and 390x844; evidence: ${QA_DIR}`); + } finally { + await context.close(); + await browser.close(); + await new Promise(resolve => server.close(resolve)); + } +});