feat: add unified learning workflows and six-tab assistant

This commit is contained in:
薇星幻海
2026-10-18 01:11:33 +08:00
parent 20abb33740
commit eb9c610b9f
32 changed files with 23806 additions and 353 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# 原始下载网页、压缩包和临时导出不提交;题库/ 中的岗位合并 CSV 提交到仓库。
# 原始下载网页、压缩包和临时导出不提交;题库/ 中的统一及兼容 CSV 提交到仓库。
网页样本/
导出结果/
*.zip
+48 -69
View File
@@ -1,106 +1,85 @@
# 谊安学助手 2.0
一个可直接安装的油猴脚本,集成岗位题库搜索、自动选择答案、允许复制文字,以及原有 CSV / JSON / TXT 导出功能。题库在浏览器本地解析和保存,内置 JSZip,无 CDN 依赖。
当前发布版本 **2.1.0**。一个可直接安装的油猴脚本,集成统一题库、六栏面板、视频与考试任务、答案全文核对、复制和题库导出。题库本地保存,内置 ZIP 解析,无 CDN 依赖。
## 安装和更新
## 安装与启动
1. 在 Chrome / Edge 中安装并启用油猴(Tampermonkey)。
2. 新建脚本,将 `谊安学助手 2.0.user.js` 的全部内容粘贴进去,保存。脚本显示名称为“谊安学助手 2.0”。升级时替换原脚本全文,不要同时启用新旧两份脚本。
3. 刷新谊安学网页,点击右下角“题库助手”入口展开面板。拖动顶部标题栏可移动面板,位置会保存。
4. 选择“主操 / 班长 / 副操”,点击“导入题库”,选择 ZIP、CSV 或 JSON 文件。
1. 安装 Tampermonkey 等兼容用户脚本管理器,打开 [谊安学助手 2.0.user.js](谊安学助手%202.0.user.js) 安装或更新。
2. 打开 `http://10.119.67.7/clientcn/` 下的学习地图、课程、练习或考试页面。
3. 展开右下角面板,在“题库搜索”导入 [统一题库.csv](题库/统一题库.csv)。原 ZIP、三个岗位 CSV、CSV/JSON 仍可导入,无需选择岗位。
4. 若考试标题与题库课程不同,在题库栏建立明确的主题关联。关联使用完整标题和完整题库课程名;相似关键词只提供候选,不授权自动答题。
5. 在“任务总览”选择任务模式、顺序或并行,点击“开始本轮”。人工刷新或重开浏览器后默认暂停,检查清单后点击“继续上次任务”。
脚本仅在 `http://10.119.67.7/clientcn/*` 生效,覆盖 `/clientcn/main`、它的下级路径,以及同目录的 `/clientcn/exam/` 练习和考试页面。其他域名、HTTPS、其他端口、目录外页面及本地文件不显示面板,也不解除复制限制。除油猴 `@match` 外,脚本启动时再次核对网址。
脚本仅在 `http://10.119.67.7/clientcn/*` 生效,含其下课程、练习及考试路径;其他主机、HTTPS、其他端口及站点目录外不会注入功能。名称继续为“谊安学助手 2.0”。
同一网页内切换路由离开 `/clientcn/` 时,脚本停止自动选择和导出、隐藏面板并恢复复制限制;返回范围内后重新显示面板,自动选择与导出保持关闭。
## 六个功能栏
标题栏支持鼠标和触屏拖动,展开或收起都可移动;拖拽不会触发展开/收起。按钮、搜索输入和正文滚动保持原操作。刷新或切换页面会恢复保存位置;窗口缩小、面板展开或内容变高时自动调整到可见区域。
| 功能栏 | 内容 |
|---|---|
| 任务总览 | 四种任务模式、顺序/并行、任务清单、当前任务、总进度及预计剩余时间 |
| 视频学习 | 实际倍速、播放进度、平台学习记录、课中题、局部暂停与恢复 |
| 考试答题 | 自动选择开关、答案核对、答题间隔、预计得分、考试计时和交卷状态;兼容原练习页 |
| 题库搜索 | 统一题库导入、关键词搜索、课程筛选、选中文字搜索、来源及主题关联 |
| 控制台 | 最近 500 条运行日志、级别筛选、复制、清空显示 |
| 其他设置 | 允许复制开关、默认关闭的题库导出折叠区、异常恢复参数 |
自动选择适配提供的谊安学桌面练习页,以及明确为 `flag=test`、完整加载并且仍可作答的桌面考试页。未知模式、移动端虚拟翻页试卷、已提交或计时结束的考试不会自动选择。下载的 HTML 可使用独立的“离线导出题库”工具解析。
桌面左导航右内容,窄屏顶部标签。全局状态、进度和暂停/继续常驻;切栏不启停任务、不重置计时、不触发导出。拖动标题栏移动面板,位置自动保存;搜索和设置控件不会触发拖拽。
## 导入题库与搜索答案
## 统一题库与准确匹配
仓库已提供按岗位合并的题库,可直接下载并导入,文件内已标注岗位:
统一文件保留 **5520 条来源记录**,包含 **2458 道唯一题、2459 个答案组合、1 道冲突题**。原主操、副操、班长仅作来源信息,不限制搜索和自动答案。相同题型、题干、选项及正确答案文字可以聚合,并显示全部来源。详见 [题库说明](题库/README.md)。
| 岗位 | 合并文件 | 记录数 |
|---|---|---:|
| 副操 | [副操_合并题库.csv](题库/副操_合并题库.csv) | 1,560 |
| 主操 | [主操_合并题库.csv](题库/主操_合并题库.csv) | 1,620 |
| 班长 | [班长_合并题库.csv](题库/班长_合并题库.csv) | 2,340 |
- 升级自动读取并迁移旧 v2 题库,验证后写入 v3;原存储保留,迁移保存失败仍可读取原库。
- 原题号、课程、答案文字、来源文件及网址保留;重复导入同一来源不增加记录。新增 CSV/JSON 合入统一题库;完整 ZIP 导入作为整库替换。
- 搜索允许关键词、选项或正确答案文字,可按课程筛选;宽松搜索结果不会直接授权自动选择。
- 自动匹配严格比较题型、完整题干与全部选项,以正确答案全文定位网页当前选项,支持选项乱序。仅清理排版空白和不可见字符,保留大小写、单位、指数、数字、否定词和标点。
- 分值、难度和题号单独读取,不污染题干。同题同选项存在不同答案时保留冲突,不按多数票或岗位选答案;只有明确课程/来源能够消歧才自动作答。
- 无匹配、图片题、未适配结构或无法唯一映射时等待人工或跳过,并显示原因。网站正常保存真实选项点击,不伪造作答记录。
可选单个岗位文件,也可同时选择三个文件导入。合并保留全部原记录、课程名称、原题号、参考答案和来源网址,并增加岗位及来源文件列;不同课程的重复题或答案差异保留。详细来源和重新生成方法见 [题库说明](题库/README.md)。
自动选择开关默认关闭。仅手动开启此开关只选择当前页答案;通过任务总览启动考试任务后,才启用下述按条件交卷流程。手动作答、关闭开关、暂停或修改题库/主题关联会取消旧交卷计划,继续前重新核验。
- ZIP 内按“主操 / 班长 / 副操”目录自动归类;成功导入后全量替换已有题库。提供的 `题库.zip` 已核对为 28 个 CSV、5,520 条记录:主操 1,620、班长 2,340、副操 1,560。
- 单个或多个 CSV / JSON 建议先选择岗位;文件缺少岗位信息时,使用当前选择的岗位。导入会更新相同岗位、课程和来源的数据,保留其他岗位及其他来源。
- 所有文件先解析和验证,再保存。题干、选项或参考答案无效时,会提示文件及题目位置,原题库保留。
- 油猴存储在同一浏览器中共享题库、岗位、复制设置及面板位置,站点内切换页面或刷新后无需重新导入。无油猴存储 API 时,回退为当前网站的本地存储,面板会明确提示。
## 视频、任务与后台
搜索时先选择岗位,可再按课程筛选。输入题干、选项文字或关键词后点击“搜索”;多个关键词用空格分开,结果须同时包含所有关键词。也可选中网页题目文字后点击“用选中文字搜索”。切换岗位会保留当前搜索词并重新查询。
任务模式为“仅视频、视频+课中答题、仅考试、视频+考试”,执行方式默认顺序,并行最多一个视频和一场考试。识别网页现有目录和入口,不猜业务接口。阶段数与课件数分别处理,非视频及未开放任务显示具体原因,已跳过或失败不计为完成。
结果显示完整题干、选项、正确答案及课程来源,支持“复制题目与答案”和分页。相同题目、选项及答案合并来源;答案冲突分别展示并标记。遇到冲突,选择对应课程核对。
视频支持 1X、1.25X、1.5X、2X,显示实际播放器倍率。播放到末尾后继续等待平台学习完成标记;平台记录时长与播放器位置分别展示。没有明确学习时长要求或任务耗时依据时,预计时间显示待确认,2X 不代表整轮耗时必然减半。
## 两个功能开关
课中答题适配保存 JS 模板中的 `app-question-modal` 与 PC CDK portal:按完整题干及选项匹配,回读选中集合,走网站“查看答案”判分,只有明确回答正确并关闭/进入下一题后才继续。仅视频模式遇强制题、无库、冲突或未知结构时等待人工,不刷新弹题。课中题不套用考试交卷规则。
**自动选择答案:默认关闭,每次刷新或进入另一份考试后需手动开启。** 先导入题库、选择岗位,并进入已加载完成的兼容练习或桌面考试页,再开启。脚本按岗位、题型、完整题干和全部选项文字精确匹配,优先结合课程来源消歧;以题库正确答案全文定位网页当前选项,支持选项换序。自动匹配只清理排版空白和不可见字符,保留大小写、单位、指数、数字、否定词和标点。关键词及宽松搜索结果不会直接用于自动选择。无匹配、冲突未消除、图片题或选项无法唯一对应时,跳过并显示原因。
默认连续 60 秒没有播放进度时尝试续播,15 秒后仍失败再刷新当前视频页面,最多 3 次,两次刷新至少间隔 30 秒,次数跨刷新保存。主动暂停、课中题、等待平台确认、等待交卷或等待人工不算故障。脚本发起的恢复及任务跳转保存一次性续作标记;人工刷新默认暂停。并行页面通过共享租约、心跳及全局控制协调,打开失败或未确认时显示等待,不假称并行成功。
脚本点击网页真实选项,网站会按正常逻辑保存作答记录。练习单选、判断可能点击后立即判分并锁定;练习多选显示“待确认”,由用户在网页中确认。考试单选、判断可改选,考试多选按当前集合取消多余选项并补选正确项,回读确认完整选中内容与题库答案一致后处理下一题。
后台指正常切换标签页或最小化后尽量继续,恢复前台按真实时间和网站记录校准。浏览器关闭、设备休眠、标签冻结或网站强制暂停期间无法保证持续执行;中断显示真实状态,不伪造学习时长。真实账号的解锁、倍速、后台和并行规则仍需实站验收。
“查看答案核对”折叠区显示已处理题目的题干、题库课程、正确答案全文与网页当前选项字母;未匹配等题目显示在未处理清单中。关闭开关或检测到手动作答时立即停止后续操作,已生效的选择保留。重新开启会重新读取选中状态并核对;切换岗位、题库或页面时取消旧任务。
## 考试预计得分与交卷
## 桌面考试适配
适配明确为 `flag=test`、完整加载且可作答的桌面试卷。开考前检查主题及题库,整卷加载后逐题预检;默认题间隔 **4 秒**,最早交卷时间为本场进入试卷起 **6 分钟**。练习多选的正常确认仍按现有网页流程处理。
考试题干中的分值(例如“(2分)”“(4分)”)和难度属于独立显示元素,提取时按 DOM 标记移除;题目自身的括号和数字保持原文。考试没有“查看答案”按钮不再被误判为锁定。
只有以下条件同时成立才自动交卷:
提供的考试样本“1-1安全生产方针和目标”共有 40 题:15 单选、10 多选、15 判断;页面参考答案为空,答案来自已导入的岗位题库。清理分值与难度后,三岗位均可精确匹配全部 40 题,其中 20 题的正确字母与题库中不同。例如题库 `B. 技术` 对应考试 `D. 技术`,脚本核对“技术”全文后选择网页 D。
1. 主题关联明确,整卷及答题卡完整,所有可匹配题已处理并回读核验。
2. 按网页每题实际分值累计的可核验预计得分 **严格大于 80 分**;正好 80 分也等待人工。
3. 达到最早交卷时长,试卷仍有效、未暂停、未结束,交卷入口及确认流程可以唯一识别。
此次适配桌面全题 DOM 页面,按顺序选择并回读核对,不操作移动端翻页。不自动点击“查看答案”或交卷;完成后检查核对结果并自行交卷。点击交卷确认、页面提交中或倒计时到零时停止自动选择。网页原有的缓存、接口保存及到时自动交卷逻辑继续生效。
未知题计 0 分且不猜答,多选必须整个集合一致才计分;题目分值或 100 分分制无法确认则等待人工。预计得分来自本地题库,不是官方成绩,也不保证真实得分。可有少量未知题,但已核验分值必须严格超过 80。
**允许复制文字:首次使用默认开启,后续保留设置。** 开启后解除已适配的文字选择、复制和右键限制;关闭后恢复网页原有 `body` 限制属性和相关样式。搜索结果的复制按钮独立可用。
提交前再次检查当前试卷、题库版本和选中全文;只走当前场次正常交卷及确认,网站显示成功结果才计完成。提交结果不明停止重复点击。暂停脚本不会暂停网站倒计时;网站剩余时间不足、身份验证或异常流程需人工处理。
## 网页题目导出
## 导出与离线工具
“导出题库”是独立的折叠开关,每次刷新默认关闭,升级前的自动导出设置也不会使它自动开启。关闭时导出区收起,自动下载和手动导出均禁用。
“其他设置”的导出开关默认关闭;开启才展开 CSV/JSON/TXT 按钮,兼容练习页答案完整时自动导出一次 CSV。关闭后停止后续导出,主动重新开启可再次下载。切换功能栏不触发下载。考试页参考答案为空时不提供考试题库导出,导出不会用导入题库填补网页缺失答案。
开启“导出题库”后展开 CSV / JSON / TXT 按钮。在兼容练习页中,题目和参考答案加载完整后会自动下载一次 CSV,也可点击格式按钮手动导出。再次关闭后停止后续导出;主动重新开启可再次下载当前题库。导出直接读取网页中的参考答案,包括隐藏答案,不用导入题库补全缺失答案。
考试页参考答案为空,不提供考试题库导出;可使用岗位题库搜索和答案核对。
- CSV 使用 UTF-8 BOM,可用 Excel 打开;JSON 保留结构化选项和图片地址;TXT 便于阅读。
- 缺题或缺答案时不自动下载;手动导出会标记不完整。没有答题卡总数时,只能核对当前页面内容。
- 分页或滚动后才加载的题目,需要先加载或逐页导出。同一次页面运行中,相同练习内容只自动下载一次。
- 图片以链接导出,不打包图片文件。保存网页的相对图片地址按原始网址解析,离线时可能无法访问。
从下载的网页导出,也可使用原有独立工具:
1. 解压网页压缩包。
2. 双击 `离线导出题库.html`,保持它与 `question-bank-core.js` 在同一目录。
3. 选择解压后的 HTML 文件,查看完整性提示,再点击导出格式。
离线工具只解析 HTML 文本,不运行原网站脚本、不连接原网站,不需要登录或 Node.js。
已保存的 HTML 可双击 [离线导出题库.html](离线导出题库.html) 打开,与 `question-bank-core.js` 放在同一目录。工具以静态 DOM 解析并中和原脚本及资源,不执行原站代码;保留 CSV 防公式转义、UTF-8 BOM 和缺失答案提示。
## 开发与验证
`site-scope.js` 负责站点范围;`question-bank-core.js` 负责网页提取和导出;`bank-store.js`、`answer-matcher.js` 负责本地题库和匹配搜索;`assistant-panel.js`、`copy-controller.js`、`answer-runner.js` 分别负责面板拖拽、复制开关和真实选项点击;`practice-exporter.js` 负责页面识别及功能协调。发布脚本已构建,安装时只需一个 `.user.js` 文件。
修改源码后重新构建:
```powershell
npm run build
```
安装开发依赖并运行验证,需要本机 Chrome:
```powershell
npm install
npm test
npm run merge:banks
```
使用本机 Edge 时可先设置:
测试依赖 Node.js 和 Playwright/Chrome;使用本机模拟页面及中和后的保存 DOM,所有业务 URL 由测试拦截,未访问业务服务器。覆盖统一题库迁移/来源/冲突、真实 40 题全文乱序、题间隔、80/84 分与 4/6 分钟边界、交卷结果、防重复、视频恢复、并行心跳、六栏及拖拽/窄屏、复制/导出/站点范围和完整任务流程。
```powershell
$env:PLAYWRIGHT_CHANNEL = 'edge'
npm test
```
源代码按职责拆分:`bank-store.js`、`answer-matcher.js`、`answer-runner.js`、`exam-controller.js`、`quiz-adapter.js`、`learning-engine.js`、`assistant-panel.js`、`practice-exporter.js`。发布仅需构建后的单个 `.user.js`。
验证使用本地模拟页面,不连接谊安学服务器;真实登录后的线上使用仍需在实际环境验收。本地存在题库 ZIP 或保存网页样本时,还会核对对应内容。原始 ZIP、解压网页及临时导出由 `.gitignore` 排除;`题库/` 中的三个岗位合并 CSV 随仓库提供。
远程仓库:`ssh://git@192.168.10.31:2222/qishizhe/yianxue-shauti.git`。
开发依据与实施顺序见 [需求.md](需求.md) 和 [实施开发方案.md](实施开发方案.md)。真实课中弹题交互、课程完成条件、账号登录后的并发限制与正式提交结果需在实际环境核实;识别不明时脚本等待人工,不把本地模拟通过等同于全站验收完成。
+16 -15
View File
@@ -1,7 +1,6 @@
/* Exact answer matching and role-scoped local search. */
/* Exact answer matching and unified local search. Roles remain source metadata. */
(function (root) {
'use strict';
const ROLES = ['主操', '班长', '副操'];
const cache = new WeakMap();
function normalize(value) {
@@ -35,8 +34,7 @@
if (saved && saved.revision === snapshot.revision && saved.records === snapshot.records) return saved.index;
const index = new Map();
snapshot.records.forEach(record => {
if (!ROLES.includes(record.role)) return;
const key = record.role + '\n' + groupKey(record);
const key = groupKey(record);
if (!index.has(key)) index.set(key, []);
index.get(key).push(record);
});
@@ -70,15 +68,19 @@
function match(question, context, snapshot) {
context = context || {};
if (!ROLES.includes(context.role)) return result('unsupported', '请先选择主操、班长或副操');
if (!question || !['单选', '多选', '判断'].includes(normalizeType(question.type))) return result('unsupported', '当前题型暂不支持自动选择');
if (!normalize(question.question) || !Array.isArray(question.options) || question.options.length < 2) return result('unsupported', '题干或选项尚未完整加载');
if ((question.questionImages && question.questionImages.length) || question.options.some(option => !normalize(option.text) || (option.images && option.images.length))) return result('unsupported', '含图片的题目需要人工核对');
const currentTexts = question.options.map(option => normalize(option.text));
const currentLabels = question.options.map(option => normalizedLabel(option.label));
if (new Set(currentTexts).size !== currentTexts.length || new Set(currentLabels).size !== currentLabels.length || currentLabels.some(label => !/^[A-H]$/.test(label))) return result('unsupported', '当前选项文字或字母重复,无法唯一映射');
let records = getIndex(snapshot).get(context.role + '\n' + groupKey(question)) || [];
if (!records.length) return result('missing', '所选岗位题库没有题型、题干和全部选项均一致的记录');
let records = getIndex(snapshot).get(groupKey(question)) || [];
if (!records.length) return result('missing', '统一题库没有题型、题干和全部选项均一致的记录');
if (Array.isArray(context.courses)) {
const allowed = new Set(context.courses.map(courseNormalize).filter(Boolean));
records = records.filter(record => allowed.has(courseNormalize(record.course)));
if (!records.length) return result('missing', '已确认的题库主题中没有完全一致的题目记录');
}
let sourceMatched = false;
const identity = sourceIdentity(context.sourceUrl);
if (identity) {
@@ -99,14 +101,12 @@
if (normalizeType(question.type) !== '多选' && selected.length !== 1) return result('unsupported', '单选/判断题的参考答案数量不正确', records);
// Keep label/text pairs in current-page order, but obtain each text from bank evidence.
const evidenceTexts = selected.map(option => wanted.find(text => normalize(text) === normalize(option.text)));
return result('matched', sourceMatched ? '岗位、课程、题干及选项完全匹配' : '岗位、题干及选项完全匹配,所有来源答案一致', records, selected.map(option => normalizedLabel(option.label)), evidenceTexts);
return result('matched', sourceMatched ? '课程来源、题干及选项完全匹配' : '题干及选项完全匹配,所有来源答案一致', records, selected.map(option => normalizedLabel(option.label)), evidenceTexts);
}
function search(query, settings, snapshot) {
settings = settings || {};
const role = settings.role;
const empty = reason => ({ items: [], total: 0, reason, role: role || '', query: String(query || ''), hasMore: false });
if (!ROLES.includes(role)) return empty('请先选择主操、班长或副操');
const empty = reason => ({ items: [], total: 0, reason, role: '', query: String(query || ''), hasMore: false });
const fullQuery = searchNormalize(query);
if (!fullQuery) return empty('请输入题干、关键词或选项文字');
const tokens = fullQuery.split(/\s+/).filter(Boolean);
@@ -114,13 +114,14 @@
const records = snapshot && Array.isArray(snapshot.records) ? snapshot.records : [];
const groups = new Map(), conflicts = new Map();
records.forEach(record => {
if (record.role !== role || (course && courseNormalize(record.course) !== course)) return;
if (course && courseNormalize(record.course) !== course) return;
if (settings.sourceUrl && sourceIdentity(record.sourceUrl) !== sourceIdentity(settings.sourceUrl)) return;
const baseKey = groupKey(record), answerKey = correctKey(record), key = baseKey + '\n' + answerKey;
if (!conflicts.has(baseKey)) conflicts.set(baseKey, new Set());
conflicts.get(baseKey).add(answerKey);
if (!groups.has(key)) groups.set(key, { ...record, sources: [], conflict: false, sourceCount: 0, _baseKey: baseKey, _sources: new Set() });
const group = groups.get(key);
const source = { role: record.role, course: record.course, sourceUrl: record.sourceUrl || '', number: record.number };
const source = { role: record.role || '', course: record.course, sourceUrl: record.sourceUrl || '', sourceFile: record.sourceFile || '', number: record.number };
const sourceKey = JSON.stringify(source);
if (!group._sources.has(sourceKey)) { group._sources.add(sourceKey); group.sources.push(source); group.sourceCount++; }
});
@@ -141,10 +142,10 @@
const total = items.length;
const limit = Math.max(1, Math.min(100, Number(settings.limit) || 20));
const offset = Math.max(0, Math.floor(Number(settings.offset) || 0));
return { items: items.slice(offset, offset + limit), total, role, query: String(query), reason: total ? '' : '当前岗位未找到符合条件的题目', hasMore: offset + limit < total };
return { items: items.slice(offset, offset + limit), total, role: '', query: String(query), reason: total ? '' : '统一题库未找到符合条件的题目', hasMore: offset + limit < total };
}
const api = Object.freeze({ version: '2.0.0', normalize, searchNormalize, normalizeType, optionKey, questionKey, sourceIdentity, match, search });
const api = Object.freeze({ version: '3.0.0', normalize, searchNormalize, normalizeType, optionKey, questionKey, sourceIdentity, match, search });
root.YianAnswerMatcher = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
+8 -4
View File
@@ -7,7 +7,7 @@
const WAIT_LIMIT = 1200;
const CLICK_GAP = 150;
const COUNT_KEYS = ['selected', 'pending', 'missing', 'conflict', 'locked', 'failed', 'unsupported'];
const CONTEXT_KEYS = ['role', 'course', 'sourceUrl', 'routeKey', 'pageSignature', 'revision', 'exam', 'mode'];
const CONTEXT_KEYS = ['course', 'sourceUrl', 'routeKey', 'pageSignature', 'revision', 'exam', 'mode', 'topicKey'];
function normalize(value) {
// 仅中和排版空白和不可见分隔符;不折叠大小写、数字、单位或标点。
@@ -132,7 +132,6 @@
if (!context || context.ready !== true) return '等待答题页面就绪。';
if (context.local === true || root.location && root.location.protocol === 'file:') return '保存的静态网页可搜索和导出,不能记录自动选择。';
if (context.practice !== true && context.exam !== true) return '当前页面未识别为可作答的练习或考试,自动选择已停止。';
if (!context.role) return '请先选择主操、班长或副操。';
if (!context.root || !context.root.isConnected || context.root.ownerDocument !== doc) return '答题内容已切换,等待新页面。';
return '';
}
@@ -179,7 +178,7 @@
const context = options.getContext();
const reason = initialReason(context);
if (reason) return reason;
if (!sameContext(run.context, captureContext(context))) return '页面、岗位或题库已切换,已停止本轮自动选择。';
if (!sameContext(run.context, captureContext(context))) return '页面、主题或题库已切换,已停止本轮自动选择。';
return '';
}
@@ -339,6 +338,11 @@
async function execute(run, items) {
try {
for (const item of items) {
if (item !== items[0]) {
const configured = typeof options.getInterval === 'function' ? Number(options.getInterval()) : 0;
const gap = Number.isFinite(configured) ? Math.max(0, Math.min(120, configured)) * 1000 : 4000;
if (gap && !await wait(run, gap)) return progress();
}
if (!await processQuestion(run, item)) return progress();
if (run.cancelled || active !== run) return progress();
}
@@ -382,7 +386,7 @@
// 先写入 promise,再通知或执行,使同步 UI 回调中的 start 也不会并发运行。
run.promise = Promise.resolve().then(function () {
if (active !== run || run.cancelled) return progress();
notifyState('running', '正在选择当前岗位题库中的匹配答案。');
notifyState('running', '正在选择统一题库中的精确匹配答案。');
return execute(run, items);
});
return run.promise;
+331 -57
View File
@@ -2,7 +2,6 @@
(function (global) {
'use strict';
const ROLES = ['主操', '班长', '副操'];
const PAGE_SIZE = 20;
const TYPE_NAMES = { single: '单选', multiple: '多选', judgment: '判断', unknown: '未知' };
@@ -21,7 +20,9 @@
const state = {
autoAnswer: false, allowCopy: true, role: '', course: '', courses: [],
bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '',
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null
canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null,
activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0,
learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' }
};
let destroyed = false;
let selectionText = '';
@@ -48,6 +49,10 @@
`;
style.textContent += '.answer-check{font-size:11px;color:#235c43;margin-top:7px}.answer-check summary{cursor:pointer}.answer-check-list{max-height:210px;overflow:auto;overscroll-behavior:contain;margin-top:5px;display:grid;gap:7px}.answer-check-item{border:1px solid #d9eade;border-radius:6px;background:#f0f8f3;padding:8px;overflow-wrap:anywhere;-webkit-user-select:text;user-select:text}.answer-check-question{font-weight:600;margin:0 0 4px;white-space:pre-wrap}.answer-check-course{color:var(--muted);margin:0 0 5px}.answer-check-label{font-weight:600;margin:0 0 3px}.answer-check-options{margin:0;padding:0;list-style:none;display:grid;gap:4px;white-space:pre-wrap}.answer-check-note{font-size:10px;color:var(--muted);margin:5px 0 0}';
style.textContent += '.header{cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.panel.dragging .header{cursor:grabbing}.panel.dragging,.panel.dragging *{-webkit-user-select:none!important;user-select:none!important}';
style.textContent += `
.panel{width:640px;max-width:calc(100vw - 24px)}.panel.collapsed{width:auto;max-width:calc(100vw - 24px);min-width:210px}.brand{font-size:14px}.body{padding:0;overflow:hidden;max-height:calc(100vh - 88px);max-height:calc(100dvh - 88px);display:flex;flex-direction:column}.global-bar{flex:none;padding:11px 13px;border-bottom:1px solid var(--line);background:#f8faff}.global-top{display:flex;align-items:center;gap:8px}.global-status{font-weight:650;flex:1;min-width:0;overflow-wrap:anywhere}.global-action{font-size:12px;white-space:nowrap}.global-progress-row{display:flex;align-items:center;gap:9px;margin-top:7px}.global-progress{min-width:30px;flex:1;height:7px;border:0;border-radius:5px;overflow:hidden;accent-color:var(--accent)}.global-progress::-webkit-progress-bar{background:#e1e8f3;border-radius:5px}.global-progress::-webkit-progress-value{background:var(--accent);border-radius:5px}.global-progress-label{font-size:11px;color:var(--muted);white-space:nowrap}.worker-status{font-size:11px;color:var(--muted);margin-top:5px;white-space:pre-wrap;overflow-wrap:anywhere}.workspace{display:flex;min-height:0;height:500px;max-height:calc(100vh - 230px);max-height:calc(100dvh - 230px)}.navigation{display:flex;flex-direction:column;gap:4px;flex:none;width:123px;padding:12px 8px;border-right:1px solid var(--line);background:#f8faff;overflow:auto}.nav-tab{border:0;background:transparent;text-align:left;padding:9px 10px;border-radius:7px;font-size:12px;white-space:nowrap}.nav-tab[aria-selected=true]{background:#e6efff;color:#174fae;font-weight:700}.nav-tab[data-alert=true]::after{content:' ·';color:#ad6921;font-weight:800}.panes{flex:1;min-width:0;min-height:0}.tab-pane{height:100%;padding:13px;overflow:auto;overscroll-behavior:contain}.tab-pane>.section:first-child{padding-top:0}.tab-pane .page-section:first-child{margin-top:0;padding-top:0;border-top:0}.notice{flex:none;margin:0;padding:8px 13px;border-radius:0;max-height:65px;overflow:auto}.setting-row{display:grid;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:9px;margin:10px 0}.setting-row>span{font-size:12px;color:#43546d}.setting-row input[type=number]{min-width:0;width:100%;border:1px solid #cfd8e7;border-radius:7px;padding:6px 8px;color:#17253d;background:white}.task-actions,.console-actions{display:flex;flex-wrap:wrap;gap:7px;margin:12px 0}.summary-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:10px 0}.summary-card{padding:9px 10px;background:var(--surface);border:1px solid var(--line);border-radius:8px;overflow-wrap:anywhere}.summary-card strong{display:block;font-size:20px;font-weight:650;color:#214e91}.summary-card span{font-size:11px;color:var(--muted)}.worker-card{display:block;text-align:left;width:100%;margin-top:8px;padding:10px;white-space:pre-wrap;overflow-wrap:anywhere}.task-list{display:grid;gap:7px}.task-row{padding:9px 10px;border:1px solid var(--line);border-radius:7px;overflow-wrap:anywhere}.task-name{font-weight:600;margin:0 0 3px;font-size:12px}.task-state{font-size:11px;color:var(--muted)}.info-grid{display:grid;grid-template-columns:80px minmax(0,1fr);gap:7px;font-size:12px;margin:10px 0}.info-grid dt{color:var(--muted)}.info-grid dd{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}.explanation{padding:9px 10px;background:var(--surface);border-radius:7px;font-size:12px;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere}.mode-note{font-size:11px;color:#926214;margin:7px 0}.console-filter{max-width:125px}.log-list{display:grid;gap:6px;font-size:11px;-webkit-user-select:text;user-select:text}.log-entry{padding:7px 8px;border-radius:5px;background:#f4f7fb;white-space:pre-wrap;overflow-wrap:anywhere;border-left:3px solid #b9c9de}.log-entry.warning{background:#fff6e6;border-left-color:#d0a354}.log-entry.error{background:#fff0ed;border-left-color:#d68370}.log-time{color:var(--muted);margin-right:7px}.threshold{font-size:12px;font-weight:600;color:#825b20;margin:9px 0}.export-section{padding-top:13px!important}.collapse-summary{font-size:10px;color:#bbd3fa;margin-left:7px;white-space:nowrap}.bank-counts{font-size:11px;color:var(--muted);margin:8px 0}.topic-row{display:flex;gap:6px;margin-top:9px}.topic-row input{flex:1;min-width:0}.theme-associations{font-size:11px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);margin:8px 0}.tab-pane .switches{margin-bottom:12px}
@media(max-width:600px){.panel{width:calc(100vw - 24px)}.workspace{flex-direction:column;height:480px;max-height:calc(100vh - 244px);max-height:calc(100dvh - 244px)}.navigation{flex-direction:row;width:100%;border-right:0;border-bottom:1px solid var(--line);padding:7px;overflow-x:auto;flex-shrink:0}.nav-tab{padding:8px 9px}.panes{min-height:0}.tab-pane{padding:11px}.global-bar{padding:9px 11px}.brand{font-size:13px}.collapsed .header{gap:8px}.global-top{gap:5px}.global-action{padding:5px 7px}.setting-row{grid-template-columns:80px minmax(0,1fr)}}
`;
root.appendChild(style);
const visibilityStyle = doc.createElement('style');
visibilityStyle.textContent = '[hidden]{display:none!important}';
@@ -56,17 +61,79 @@
panel.setAttribute('aria-label', '谊安学助手 2.0');
const header = el(doc, 'div', 'header');
header.title = '拖动标题栏移动面板';
const brand = el(doc, 'span', 'brand', '题库助手');
const brand = el(doc, 'span', 'brand', '谊安学助手 2.0');
const collapseSummary = el(doc, 'span', 'collapse-summary');
const expand = el(doc, 'button', 'toggle', '展开');
expand.type = 'button';
expand.setAttribute('aria-label', '展开题库助手');
expand.setAttribute('aria-expanded', 'false');
header.append(brand, expand);
header.append(brand, collapseSummary, expand);
const body = el(doc, 'div', 'body');
body.hidden = true;
panel.append(header, body);
root.appendChild(panel);
const globalBar = el(doc, 'div', 'global-bar');
const globalTop = el(doc, 'div', 'global-top');
const globalStatus = el(doc, 'span', 'global-status', '尚未开始');
globalStatus.setAttribute('data-testid', 'learning-status');
globalStatus.setAttribute('aria-live', 'polite');
const globalPause = el(doc, 'button', 'global-action', '暂停全部');
globalPause.type = 'button';
globalPause.setAttribute('data-testid', 'global-pause');
globalPause.addEventListener('click', function () { invoke('onTaskAction', ['paused', 'stopped'].includes(state.learning.status) ? 'resume' : 'pause'); });
globalTop.append(globalStatus, globalPause);
const globalProgressRow = el(doc, 'div', 'global-progress-row');
const globalProgress = el(doc, 'progress', 'global-progress');
globalProgress.max = 1; globalProgress.value = 0;
globalProgress.setAttribute('aria-label', '已完成任务进度');
const globalProgressLabel = el(doc, 'span', 'global-progress-label', '0 / 0');
const workerStatus = el(doc, 'div', 'worker-status');
globalProgressRow.append(globalProgress, globalProgressLabel);
globalBar.append(globalTop, globalProgressRow, workerStatus);
const workspace = el(doc, 'div', 'workspace');
const navigation = el(doc, 'nav', 'navigation');
navigation.setAttribute('role', 'tablist');
navigation.setAttribute('aria-label', '助手功能');
const panes = el(doc, 'div', 'panes');
const tabs = {}, views = {};
const tabOrder = ['overview', 'video', 'exam', 'bank', 'console', 'settings'];
const tabNames = { overview: '任务总览', video: '视频学习', exam: '考试答题', bank: '题库搜索', console: '控制台', settings: '其他设置' };
function selectTab(key, focus) {
if (!views[key]) return;
state.activeTab = key;
tabOrder.forEach(function (id) {
const active = id === key;
tabs[id].setAttribute('aria-selected', String(active));
tabs[id].tabIndex = active ? 0 : -1;
views[id].hidden = !active;
});
if (focus) tabs[key].focus();
scheduleClamp(true);
}
tabOrder.forEach(function (key, index) {
const tab = el(doc, 'button', 'nav-tab', tabNames[key]);
tab.type = 'button'; tab.id = 'yian-tab-' + key;
tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', 'yian-pane-' + key);
tab.setAttribute('data-testid', 'tab-' + key);
tab.addEventListener('click', function () { selectTab(key, false); });
tab.addEventListener('keydown', function (event) {
let target;
if (event.key === 'Home') target = tabOrder[0];
if (event.key === 'End') target = tabOrder[tabOrder.length - 1];
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') target = tabOrder[(index + 1) % tabOrder.length];
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') target = tabOrder[(index + tabOrder.length - 1) % tabOrder.length];
if (target) { event.preventDefault(); selectTab(target, true); }
});
const view = el(doc, 'section', 'tab-pane');
view.id = 'yian-pane-' + key; view.setAttribute('role', 'tabpanel');
view.setAttribute('aria-labelledby', tab.id); view.setAttribute('data-testid', 'pane-' + key);
tabs[key] = tab; views[key] = view;
navigation.appendChild(tab); panes.appendChild(view);
});
workspace.append(navigation, panes);
body.append(globalBar, workspace);
function invoke(name) {
if (destroyed || typeof callbacks[name] !== 'function') return;
const args = Array.prototype.slice.call(arguments, 1);
@@ -213,7 +280,8 @@
const copySwitch = makeSwitch('允许复制文字', function () { state.allowCopy = copySwitch.input.checked; invoke('onCopyChange', state.allowCopy); refresh(); });
const answerHint = el(doc, 'p', 'hint');
switches.appendChild(answerHint);
body.appendChild(switches);
views.exam.appendChild(switches);
views.settings.appendChild(copySwitch.label);
const bankSection = el(doc, 'div', 'section');
const importRow = el(doc, 'div', 'import-row');
@@ -228,7 +296,7 @@
importRow.append(importButton, bankInfo, fileInput);
const storageInfo = el(doc, 'p', 'storage');
bankSection.append(importRow, storageInfo);
body.appendChild(bankSection);
views.bank.appendChild(bankSection);
importButton.addEventListener('click', function () { fileInput.click(); });
fileInput.addEventListener('change', function () {
const files = Array.from(fileInput.files || []);
@@ -238,37 +306,6 @@
const searchSection = el(doc, 'div', 'section');
searchSection.appendChild(el(doc, 'h2', 'section-title', '搜索题库'));
const roles = el(doc, 'div', 'roles');
roles.setAttribute('role', 'radiogroup');
roles.setAttribute('aria-label', '选择岗位');
const roleInputs = {};
const roleLabels = {};
const roleCounters = {};
ROLES.forEach(function (role) {
const label = el(doc, 'label', 'role');
const input = el(doc, 'input');
input.type = 'radio';
input.name = 'yian-search-role';
input.value = role;
input.setAttribute('aria-label', role);
const counter = el(doc, 'span', 'role-count');
label.append(input, el(doc, 'span', '', role), counter);
roles.appendChild(label);
roleInputs[role] = input;
roleLabels[role] = label;
roleCounters[role] = counter;
input.addEventListener('change', function () {
if (!input.checked) return;
const query = searchInput.value;
state.role = role;
state.course = '';
currentOffset = 0;
refresh();
invoke('onRoleChange', role);
if (normalized(query)) invoke('onSearch', query, { offset: 0 });
});
});
searchSection.appendChild(roles);
const courseRow = el(doc, 'label', 'course-row');
const courseSelect = el(doc, 'select');
courseSelect.setAttribute('aria-label', '选择课程');
@@ -297,7 +334,7 @@
notice.hidden = true;
notice.setAttribute('role', 'status');
notice.setAttribute('aria-live', 'polite');
searchSection.appendChild(notice);
body.appendChild(notice);
const resultsTitle = el(doc, 'p', 'results-title');
const results = el(doc, 'div', 'results');
const pagination = el(doc, 'div', 'pagination');
@@ -308,10 +345,9 @@
pagination.append(previous, pageLabel, next);
pagination.hidden = true;
searchSection.append(resultsTitle, results, pagination);
body.appendChild(searchSection);
views.bank.appendChild(searchSection);
function search(offset) {
if (!state.role) { setNotice('请先选择岗位:主操、班长或副操。', 'warning'); return; }
if (!normalized(searchInput.value)) { setNotice('请输入题干、关键词或选项文字。', 'warning'); searchInput.focus(); return; }
currentOffset = Math.max(0, Number(offset) || 0);
invoke('onSearch', searchInput.value, { offset: currentOffset });
@@ -331,7 +367,6 @@
selectionButton.addEventListener('pointerdown', rememberSelection);
selectionButton.addEventListener('click', function () {
rememberSelection();
if (!state.role) { setNotice('请先选择岗位:主操、班长或副操。', 'warning'); return; }
if (!selectionText) { setNotice('请先在网页正文选中题目文字。', 'warning'); return; }
searchInput.value = selectionText;
currentOffset = 0;
@@ -355,7 +390,7 @@
unprocessed.append(unprocessedSummary, unprocessedList);
unprocessed.hidden = true;
pageSection.append(pageStatus, metrics, answerCheck, unprocessed);
body.appendChild(pageSection);
views.exam.appendChild(pageSection);
const exportSection = el(doc, 'section', 'page-section export-section');
exportSection.setAttribute('aria-label', '题库导出设置');
@@ -382,11 +417,143 @@
});
exportContent.appendChild(exportRow);
exportSection.appendChild(exportContent);
body.appendChild(exportSection);
views.settings.appendChild(exportSection);
const learningControls = {};
function learningSetting(container, key, label, values, fallback, range) {
const row = el(doc, 'label', 'setting-row');
const input = el(doc, values ? 'select' : 'input');
input.setAttribute('aria-label', label);
input.setAttribute('data-testid', 'setting-' + key);
if (values) values.forEach(function (entry) {
const option = el(doc, 'option', '', entry[1]); option.value = plain(entry[0]); input.appendChild(option);
});
else { input.type = 'number'; input.min = plain(range && range[0] || 0); input.max = plain(range && range[1] || 3600); input.step = plain(range && range[2] || 1); }
row.append(el(doc, 'span', '', label), input); container.appendChild(row);
learningControls[key] = { input: input, fallback: fallback, numeric: !values || typeof fallback === 'number' };
input.addEventListener('change', function () {
if (!input.checkValidity()) { setNotice('请输入有效的' + label + '。', 'warning'); return; }
const value = learningControls[key].numeric ? Number(input.value) : input.value;
state.learning[key] = value;
invoke('onLearningSetting', key, value);
refreshLearning();
});
return input;
}
function taskButton(container, label, action, className) {
const button = el(doc, 'button', className || '', label); button.type = 'button';
button.setAttribute('data-testid', 'task-' + action);
button.addEventListener('click', function () { invoke('onTaskAction', action); });
container.appendChild(button); return button;
}
views.overview.appendChild(el(doc, 'h2', 'section-title', '本轮学习任务'));
learningSetting(views.overview, 'mode', '任务模式', [['video', '只刷视频(课中题人工处理)'], ['videoQuiz', '视频与课中答题'], ['exam', '只做考试'], ['all', '视频、课中题与考试']], 'all');
learningSetting(views.overview, 'execution', '执行方式', [['sequential', '顺序执行'], ['parallel', '并行:一个视频 + 一场考试']], 'sequential');
const taskActions = el(doc, 'div', 'task-actions');
const startTask = taskButton(taskActions, '开始本轮', 'start', 'primary');
const resumeTask = taskButton(taskActions, '继续上次任务', 'resume');
const stopTask = taskButton(taskActions, '停止本轮', 'stop');
views.overview.appendChild(taskActions);
const learningMessage = el(doc, 'p', 'explanation');
const summaryGrid = el(doc, 'div', 'summary-grid');
const summaryNodes = {};
[['total', '任务总数'], ['completed', '已完成'], ['skipped', '已跳过'], ['waiting', '等待人工'], ['failed', '失败'], ['etaSeconds', '预计剩余']].forEach(function (entry) {
const card = el(doc, 'div', 'summary-card'); const value = el(doc, 'strong', '', '0');
card.append(value, el(doc, 'span', '', entry[1])); summaryNodes[entry[0]] = value; summaryGrid.appendChild(card);
});
const videoSummary = el(doc, 'button', 'worker-card'); videoSummary.type = 'button';
const examSummary = el(doc, 'button', 'worker-card'); examSummary.type = 'button';
videoSummary.addEventListener('click', function () { selectTab('video', true); });
examSummary.addEventListener('click', function () { selectTab('exam', true); });
const taskList = el(doc, 'div', 'task-list');
views.overview.append(learningMessage, summaryGrid, videoSummary, examSummary, el(doc, 'h3', 'section-title', '任务清单'), taskList);
views.video.appendChild(el(doc, 'h2', 'section-title', '视频学习'));
const videoModeNote = el(doc, 'p', 'mode-note'); views.video.appendChild(videoModeNote);
learningSetting(views.video, 'rate', '播放倍速', [[1, '1X'], [1.25, '1.25X'], [1.5, '1.5X'], [2, '2X(最高)']], 1);
const videoInfo = el(doc, 'dl', 'info-grid');
const videoActions = el(doc, 'div', 'task-actions');
const videoPause = taskButton(videoActions, '暂停视频任务', 'videoPause');
const videoResume = taskButton(videoActions, '继续视频任务', 'videoResume');
const videoMessage = el(doc, 'p', 'explanation');
views.video.append(videoInfo, videoActions, videoMessage, el(doc, 'p', 'hint', '播放结束后等待平台确认完成;倍速不会保证平台要求的学习时长按比例缩短。课中题出现时暂停播放,精确匹配后处理。'));
const examIntro = el(doc, 'div', 'section');
examIntro.appendChild(el(doc, 'h2', 'section-title', '考试设置与进度'));
const examModeNote = el(doc, 'p', 'mode-note'); examIntro.appendChild(examModeNote);
learningSetting(examIntro, 'answerInterval', '答题间隔(秒)', null, 4, [0, 120, 0.5]);
learningSetting(examIntro, 'minSubmitMinutes', '最早交卷(分)', null, 6, [0.5, 180, 0.5]);
const examInfo = el(doc, 'dl', 'info-grid');
const examMessage = el(doc, 'p', 'explanation');
const examActions = el(doc, 'div', 'task-actions');
const examPause = taskButton(examActions, '暂停考试任务', 'examPause');
const examResume = taskButton(examActions, '继续考试任务', 'examResume');
examIntro.append(examInfo, el(doc, 'p', 'threshold', '预计得分必须严格大于 80 分;正好 80 分等待人工。'), examMessage, examActions,
el(doc, 'p', 'hint', '仅已精确匹配、点击并核验完整答案文字的题目计分。进入原试卷满设定时长、核验完成且满足条件后才自动交卷;预计分数以本地题库为依据。练习与课中题不使用考试交卷门槛。'));
views.exam.insertBefore(examIntro, switches);
const bankCounts = el(doc, 'p', 'bank-counts'); bankSection.appendChild(bankCounts);
const associationSection = el(doc, 'details', 'page-section');
associationSection.appendChild(el(doc, 'summary', 'section-title', '课程与考试主题关联'));
const associationTitle = el(doc, 'input'); associationTitle.type = 'text';
associationTitle.placeholder = '课程或考试的完整标题'; associationTitle.setAttribute('aria-label', '关联的完整标题');
const associationCourses = el(doc, 'textarea');
associationCourses.placeholder = '填写题库中的完整课程名,多个课程用换行或逗号分隔';
associationCourses.setAttribute('aria-label', '关联题库课程');
associationCourses.style.cssText = 'font:inherit;width:100%;min-height:70px;margin-top:7px;padding:8px;border:1px solid #cfd8e7;border-radius:7px;resize:vertical;color:#17253d;background:white;';
const associationActions = el(doc, 'div', 'task-actions');
const saveAssociation = el(doc, 'button', '', '保存关联'); saveAssociation.type = 'button';
const deleteAssociation = el(doc, 'button', '', '删除关联'); deleteAssociation.type = 'button';
associationActions.append(saveAssociation, deleteAssociation);
const associationList = el(doc, 'p', 'theme-associations');
associationSection.append(associationTitle, associationCourses, associationActions,
el(doc, 'p', 'hint', '填写精确标题和题库课程名称。相似关键词只能帮助查找,不能授权自动答题。'), associationList);
views.bank.appendChild(associationSection);
let associationSignature = '';
associationTitle.addEventListener('change', function () {
associationCourses.value = ((state.learning.associations || {})[normalized(associationTitle.value)] || []).join('\n');
});
saveAssociation.addEventListener('click', function () {
const title = normalized(associationTitle.value);
const courses = Array.from(new Set(associationCourses.value.split(/[\n,,]+/).map(normalized).filter(Boolean)));
if (!title || !courses.length) { setNotice('请填写完整标题及至少一个完整题库课程名。', 'warning'); return; }
const knownCourses = (state.courses || []).map(function (course) { return typeof course === 'string' ? course : plain(course.value == null ? course.course || course.name : course.value); });
const missing = courses.filter(function (course) { return !knownCourses.includes(course); });
if (missing.length) { setNotice('题库中没有找到这些完整课程名:' + missing.join('、'), 'warning'); return; }
const associations = Object.assign({}, state.learning.associations || {});
associations[title] = courses; state.learning.associations = associations;
invoke('onLearningSetting', 'associations', associations); refreshLearning(); setNotice('主题关联已保存,将重新检查受影响的答题任务。', 'success');
});
deleteAssociation.addEventListener('click', function () {
const title = normalized(associationTitle.value);
if (!title || !Object.prototype.hasOwnProperty.call(state.learning.associations || {}, title)) { setNotice('该完整标题尚未保存关联。', 'warning'); return; }
const associations = Object.assign({}, state.learning.associations || {});
delete associations[title]; state.learning.associations = associations; associationCourses.value = '';
invoke('onLearningSetting', 'associations', associations); refreshLearning(); setNotice('主题关联已删除。', 'success');
});
views.console.appendChild(el(doc, 'h2', 'section-title', '运行控制台'));
const logActions = el(doc, 'div', 'console-actions');
const logFilter = el(doc, 'select', 'console-filter'); logFilter.setAttribute('aria-label', '日志级别');
[['all', '全部日志'], ['info', '运行状态'], ['warning', '警告'], ['error', '错误']].forEach(function (entry) { const option = el(doc, 'option', '', entry[1]); option.value = entry[0]; logFilter.appendChild(option); });
const copyLog = el(doc, 'button', '', '复制日志'); copyLog.type = 'button'; copyLog.addEventListener('click', function () { invoke('onLogAction', 'copy'); });
const clearLog = el(doc, 'button', '', '清空显示'); clearLog.type = 'button'; clearLog.addEventListener('click', function () { invoke('onLogAction', 'clear'); });
logActions.append(logFilter, copyLog, clearLog);
const logCount = el(doc, 'p', 'hint'); const logList = el(doc, 'div', 'log-list');
logList.setAttribute('data-testid', 'learning-logs');
views.console.append(logActions, logCount, logList);
logFilter.addEventListener('change', function () { refreshLearning(); });
const recoverySection = el(doc, 'div', 'page-section');
recoverySection.appendChild(el(doc, 'h2', 'section-title', '异常恢复'));
learningSetting(recoverySection, 'stallSeconds', '停滞检查(秒)', null, 60, [5, 600, 1]);
learningSetting(recoverySection, 'retryDelaySeconds', '续播等待(秒)', null, 15, [1, 120, 1]);
learningSetting(recoverySection, 'maxRefresh', '最多自动刷新', null, 3, [0, 10, 1]);
recoverySection.appendChild(el(doc, 'p', 'hint', '主动暂停、课中题、等待平台确认、等待交卷和等待人工不会触发故障刷新。两次刷新至少间隔 30 秒。'));
views.settings.append(recoverySection, el(doc, 'p', 'explanation', '切换标签页或最小化后尽量继续运行,回到前台会按网页实际状态核对。浏览器关闭、设备休眠或标签冻结期间无法保证继续;不会虚增平台学习时长。'));
function autoUnavailableReason() {
if (!state.role) return '请选择岗位后再开启自动选择。';
if (!(Number(state.roleCounts && state.roleCounts[state.role]) > 0)) return '请导入包含' + state.role + '题目的题库。';
if (!(Number(state.bankTotal) > 0)) return '请先在题库搜索栏导入统一题库。';
if (!state.canAnswer) return state.pageStatus || '当前页面未识别到支持的练习或考试题目。';
return '';
}
@@ -470,12 +637,119 @@
});
}
const learningStatusNames = { idle: '尚未开始', ready: '等待开始', running: '运行中', loading: '加载中', scanning: '识别任务中', video: '视频学习中', playing: '播放中', quiz: '课中答题', exam: '考试答题中', answering: '答题中', preflight: '题库预检', waiting: '等待人工', waitingHuman: '等待人工', waitingSubmit: '等待交卷', waitingPlatform: '等待平台确认', paused: '已暂停', stopped: '已停止', completed: '已完成', done: '已完成', skipped: '已跳过', failed: '失败', error: '异常', retrying: '恢复中', pending: '待处理', submitted: '已交卷', finished: '本轮结束', unknown: '结果待确认', submitting: '提交中', confirming: '确认交卷中', expanded: '已读取目录' };
function statusName(value) { return value === 'awaitingAttempt' ? '等待确认新场次' : learningStatusNames[plain(value)] || plain(value) || '待处理'; }
function firstValue(object, keys, fallback) {
object = object || {};
for (let i = 0; i < keys.length; i += 1) if (object[keys[i]] != null && object[keys[i]] !== '') return object[keys[i]];
return fallback;
}
function duration(value) {
if (value == null || value === '' || !Number.isFinite(Number(value))) return '待确认';
const seconds = Math.max(0, Math.ceil(Number(value)));
return (seconds >= 3600 ? Math.floor(seconds / 3600) + ':' : '') + String(Math.floor(seconds / 60) % 60).padStart(2, '0') + ':' + String(seconds % 60).padStart(2, '0');
}
function infoRows(container, entries) {
container.replaceChildren();
entries.forEach(function (entry) { container.append(el(doc, 'dt', '', entry[0]), el(doc, 'dd', '', entry[1])); });
}
let taskSignature = '', logSignature = '';
function refreshLearning() {
const learning = state.learning || {};
const video = learning.video || {}, exam = learning.exam || {};
const total = Math.max(0, Number(learning.total) || 0), completed = Math.max(0, Number(learning.completed) || 0);
const paused = ['paused', 'stopped'].includes(learning.status);
const active = !['idle', 'ready', 'stopped', 'completed', 'done', 'finished'].includes(learning.status || 'idle');
setText(globalStatus, statusName(learning.status) + (Number(learning.waiting) > 0 ? ' · ' + Number(learning.waiting) + ' 项等待人工' : ''));
setText(globalPause, paused ? '继续全部' : '暂停全部');
globalPause.disabled = !active && !paused;
globalProgress.max = Math.max(total, 1); globalProgress.value = Math.min(completed, total);
setText(globalProgressLabel, completed + ' / ' + total + ' 已完成');
const videoTitle = plain(firstValue(video, ['title', 'course', 'name'], '暂无视频任务'));
const examTitle = plain(firstValue(exam, ['title', 'theme', 'name'], '暂无考试任务'));
const videoState = statusName(video.status || 'idle'), examState = statusName(exam.status || 'idle');
setText(workerStatus, '视频:' + videoState + ' 考试:' + examState + (learning.message ? '\n' + plain(learning.message) : ''));
setText(collapseSummary, completed + '/' + total + (Number(learning.waiting) > 0 ? ' · 待人工' : paused ? ' · 已暂停' : ''));
collapseSummary.hidden = Boolean(state.expanded);
setText(learningMessage, learning.message || '在学习计划、课程或考试页面开始本轮,先读取实际任务,再按网页状态执行。人工刷新后需继续上次任务。');
Object.keys(summaryNodes).forEach(function (key) { setText(summaryNodes[key], key === 'etaSeconds' ? duration(learning.etaSeconds) : Math.max(0, Number(learning[key]) || 0)); });
startTask.disabled = active;
resumeTask.disabled = !paused && !['waiting', 'waitingHuman'].includes(learning.status);
stopTask.disabled = !active && !paused;
setText(videoSummary, '视频 · ' + videoState + '\n' + videoTitle);
setText(examSummary, '考试 · ' + examState + '\n' + examTitle);
const tasks = Array.isArray(learning.tasks) ? learning.tasks : [];
const taskNextSignature = JSON.stringify(tasks);
if (taskNextSignature !== taskSignature || !taskList.childElementCount) {
taskSignature = taskNextSignature; taskList.replaceChildren();
if (!tasks.length) taskList.appendChild(el(doc, 'p', 'empty', '尚未读取到任务。完成状态以平台记录为准,跳过项目不会计为已完成。'));
tasks.forEach(function (task) {
const row = el(doc, 'article', 'task-row');
row.append(el(doc, 'p', 'task-name', firstValue(task, ['title', 'name', 'course'], '未命名任务')),
el(doc, 'div', 'task-state', ([(task.kind || task.type) === 'video' ? '视频' : (task.kind || task.type) === 'exam' ? '考试' : (task.kind || task.type) === 'course' ? '课程' : task.kind || task.type, statusName(task.status), task.message || task.reason].filter(Boolean)).join(' · ')));
taskList.appendChild(row);
});
}
Object.keys(learningControls).forEach(function (key) {
const setting = learningControls[key];
if (root.activeElement !== setting.input) setting.input.value = plain(learning[key] == null ? setting.fallback : learning[key]);
});
const videoEnabled = learning.mode !== 'exam';
setText(videoModeNote, videoEnabled ? '' : '本模式未启用视频;可查看设置与记录。'); videoModeNote.hidden = videoEnabled;
const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode;
setText(examModeNote, examEnabled ? '' : '本模式未启用独立考试;仍可操作当前练习页。'); examModeNote.hidden = examEnabled;
const videoDuration = firstValue(video, ['duration', 'durationSeconds', 'totalSeconds'], null);
const learnedTime = firstValue(video, ['learnedTime', 'learnedSeconds', 'platformSeconds'], null);
infoRows(videoInfo, [['当前课程', videoTitle], ['播放进度', duration(firstValue(video, ['currentTime', 'position', 'positionSeconds'], null)) + ' / ' + duration(videoDuration)],
['平台记录', duration(learnedTime)], ['平台完成', video.isLearned === true || video.completed === true ? '已确认完成' : '尚未确认'],
['实际倍速', firstValue(video, ['actualRate', 'playbackRate', 'rate'], null) == null ? '等待播放器' : plain(firstValue(video, ['actualRate', 'playbackRate', 'rate'], 1)) + 'X'],
['课中题', firstValue(video, ['quizStatus', 'quizMessage'], '未发现弹题')], ['恢复次数', plain(firstValue(video, ['refreshCount', 'retries', 'retryCount'], 0))]]);
setText(videoMessage, video.message || video.reason || '等待视频任务。播放结束与平台确认完成分别记录。');
const videoHasTask = Boolean(video.title || video.name || video.course || video.taskId || video.id);
videoPause.disabled = !videoHasTask || ['paused', 'completed', 'done', 'idle'].includes(video.status || 'idle');
videoResume.disabled = !videoHasTask || !['paused', 'waiting', 'waitingHuman', 'failed', 'error'].includes(video.status);
infoRows(examInfo, [['考试主题', examTitle], ['题库预检', plain(firstValue(exam, ['preflightMessage', 'themeStatus', 'preflight'], '尚未预检'))],
['预计得分', firstValue(exam, ['predictedScore', 'score'], null) == null ? '待核算' : plain(firstValue(exam, ['predictedScore', 'score'], 0)) + ' 分(题库估计)'],
['网页剩余', duration(firstValue(exam, ['remainingSeconds', 'remainingTime'], null))], ['进入试卷', duration(firstValue(exam, ['elapsedSeconds', 'elapsedTime'], null))],
['最早交卷', duration(firstValue(exam, ['submitWaitSeconds', 'waitSeconds'], null))], ['交卷状态', statusName(exam.status || 'idle')]]);
setText(examMessage, exam.message || exam.reason || '先确认主题与题库对应。没有对应题库则跳过;不明确的答案等待人工。');
const examHasTask = Boolean(exam.title || exam.name || exam.theme || exam.taskId || exam.id);
examPause.disabled = !examHasTask || ['paused', 'submitted', 'completed', 'done', 'idle'].includes(exam.status || 'idle');
examResume.disabled = !examHasTask || !['paused', 'waiting', 'waitingHuman', 'failed', 'error'].includes(exam.status);
tabs.video.setAttribute('data-alert', String(['waiting', 'waitingHuman', 'failed', 'error'].includes(video.status)));
tabs.exam.setAttribute('data-alert', String(['waiting', 'waitingHuman', 'failed', 'error'].includes(exam.status)));
const associations = learning.associations && typeof learning.associations === 'object' ? learning.associations : {};
const nextAssociationSignature = JSON.stringify(associations);
if (!associationTitle.value && learning.currentTitle && root.activeElement !== associationTitle) associationTitle.value = plain(learning.currentTitle);
if (associationSignature !== nextAssociationSignature) {
associationSignature = nextAssociationSignature;
setText(associationList, Object.keys(associations).map(function (title) { return title + ' → ' + (Array.isArray(associations[title]) ? associations[title] : []).join('、'); }).join('\n') || '暂无手工主题关联。');
if (root.activeElement !== associationCourses) associationCourses.value = (associations[normalized(associationTitle.value)] || []).join('\n');
}
const logs = (Array.isArray(learning.logs) ? learning.logs : []).slice(-500);
const filtered = logs.filter(function (entry) { return logFilter.value === 'all' || (entry.level === 'warn' ? 'warning' : entry.level || 'info') === logFilter.value; });
const nextLogSignature = JSON.stringify([logFilter.value, logs]);
if (nextLogSignature !== logSignature || !logList.childElementCount) {
logSignature = nextLogSignature; logList.replaceChildren();
if (!filtered.length) logList.appendChild(el(doc, 'p', 'empty', logs.length ? '该级别暂无日志。' : '暂无日志。清空显示不会删除任务检查点。'));
filtered.forEach(function (entry) {
const level = entry.level === 'warn' ? 'warning' : entry.level || 'info';
const row = el(doc, 'div', 'log-entry ' + level);
let time = plain(entry.time || '');
if (typeof entry.time === 'number' || /^\d{4}-\d{2}-\d{2}T/.test(time)) { const date = new Date(entry.time); if (!Number.isNaN(date.getTime())) time = date.toLocaleTimeString('zh-CN', { hour12: false }); }
row.append(el(doc, 'span', 'log-time', time), doc.createTextNode((entry.task ? '[' + plain(entry.task) + '] ' : '') + plain(entry.message)));
logList.appendChild(row);
});
}
setText(logCount, '显示 ' + filtered.length + ' 条 / 最近 ' + logs.length + ' 条;最多保留 500 条。');
}
let courseSignature = '';
function refresh() {
if (destroyed) return;
panel.classList.toggle('collapsed', !state.expanded);
body.hidden = !state.expanded;
brand.textContent = state.expanded ? '谊安学助手 2.0' : '题库助手';
brand.textContent = '谊安学助手 2.0';
expand.textContent = state.expanded ? '收起' : '展开';
expand.setAttribute('aria-label', state.expanded ? '收起题库助手' : '展开题库助手');
expand.setAttribute('aria-expanded', String(Boolean(state.expanded)));
@@ -488,18 +762,13 @@
autoSwitch.input.disabled = !state.autoAnswer && (Boolean(unavailable) || Boolean(state.loading));
autoSwitch.label.title = unavailable;
copySwitch.input.checked = Boolean(state.allowCopy);
answerHint.textContent = unavailable || (state.autoAnswer ? '正在按题干和选项全文匹配答案;关闭即可停止后续操作。' : '支持练习和考试,按题干和选项全文匹配答案;交卷由你操作。');
bankInfo.textContent = state.loading ? '正在读取题库…' : '已加载 ' + Number(state.bankTotal || 0).toLocaleString('zh-CN') + ' 条' + (state.role ? ' · ' + state.role + ' ' + Number(state.roleCounts && state.roleCounts[state.role] || 0).toLocaleString('zh-CN') + ' 条' : '');
answerHint.textContent = unavailable || (state.autoAnswer ? '正在按题干和选项全文匹配答案;关闭会停止后续选择并取消自动交卷计划。' : '使用统一题库,按题干和选项全文匹配;练习页保留自动选择和答案核对。');
bankInfo.textContent = state.loading ? '正在读取题库…' : '统一题库已加载 ' + Number(state.bankTotal || 0).toLocaleString('zh-CN') + ' 条来源记录';
bankCounts.textContent = '题目 ' + Number(state.uniqueTotal || 0).toLocaleString('zh-CN') + ' 道 · 答案冲突 ' + Number(state.conflictTotal || 0).toLocaleString('zh-CN') + ' 道';
bankInfo.classList.toggle('busy', Boolean(state.loading));
importButton.disabled = Boolean(state.loading);
storageInfo.textContent = state.storageMode === 'origin' ? '此模式仅在当前网站保存题库和设置。' : '';
storageInfo.hidden = !storageInfo.textContent;
ROLES.forEach(function (role) {
roleInputs[role].checked = state.role === role;
roleLabels[role].classList.toggle('active', state.role === role);
const count = Number(state.roleCounts && state.roleCounts[role] || 0);
roleCounters[role].textContent = count ? '(' + count.toLocaleString('zh-CN') + ')' : '';
});
const availableCourses = (state.courses || []).map(function (course) {
return typeof course === 'string' ? { value: course, label: course } : { value: plain(course.value == null ? (course.course || course.name) : course.value), label: plain(course.label || course.name || course.course || course.value) };
});
@@ -513,7 +782,7 @@
availableCourses.forEach(function (course) { const option = el(doc, 'option', '', course.label); option.value = course.value; courseSelect.appendChild(option); });
}
courseSelect.value = state.course || '';
courseSelect.disabled = !state.role || Boolean(state.loading);
courseSelect.disabled = Boolean(state.loading);
searchButton.disabled = Boolean(state.loading);
selectionButton.disabled = Boolean(state.loading);
setText(pageTitle, state.pageMode === 'test' ? '当前考试' : state.pageMode === 'exercise' ? '当前练习' : '当前页面');
@@ -524,6 +793,8 @@
exportButtons.forEach(function (button) { button.disabled = !state.exportEnabled || !state.canExport; });
previous.disabled = currentOffset === 0 || Boolean(state.loading);
next.disabled = currentOffset + PAGE_SIZE >= resultTotal || Boolean(state.loading);
refreshLearning();
selectTab(views[state.activeTab] ? state.activeTab : 'overview', false);
}
function optionsOf(item) {
@@ -571,9 +842,9 @@
const items = (result.items || []).slice(0, PAGE_SIZE);
resultTotal = Number(result.total == null ? items.length : result.total) || 0;
currentOffset = Math.max(0, Number(result.offset == null ? currentOffset : result.offset) || 0);
resultsTitle.textContent = state.role ? '找到 ' + resultTotal.toLocaleString('zh-CN') + ' 道题 · 当前岗位:' + state.role : '先选择岗位,再搜索题目';
resultsTitle.textContent = '统一题库 · 找到 ' + resultTotal.toLocaleString('zh-CN') + ' 道题';
results.replaceChildren();
if (!items.length) results.appendChild(el(doc, 'div', 'empty', state.role ? '当前岗位未找到题目,可调整关键词、课程或切换岗位。' : '选择主操、班长或副操,导入题库后即可查找答案。'));
if (!items.length) results.appendChild(el(doc, 'div', 'empty', Number(state.bankTotal) > 0 ? '未找到题目,可调整关键词或课程筛选。' : '导入统一题库后,输入题干、关键词或选项文字查找答案。'));
items.forEach(function (item) {
const card = el(doc, 'article', 'result');
const title = el(doc, 'p', 'question');
@@ -618,6 +889,9 @@
lastPositionInput = signature;
delete update.position;
}
if (Object.prototype.hasOwnProperty.call(update, 'learning')) {
update.learning = Object.assign({}, state.learning || {}, update.learning || {});
}
Object.assign(state, update);
refresh();
if (acceptPosition) {
+45 -15
View File
@@ -3,10 +3,12 @@
'use strict';
const ROLES = Object.freeze(['主操', '班长', '副操']);
const BANK_KEY = 'yianxue.assistant.bank.v2';
const BANK_KEY = 'yianxue.assistant.bank.v3';
const LEGACY_BANK_KEY = 'yianxue.assistant.bank.v2';
const SETTING_PREFIX = 'yianxue.assistant.setting.v2.';
const MAX_BYTES = 60 * 1024 * 1024;
let current = null;
let loading = null;
let importQueue = Promise.resolve();
function normalize(value) {
@@ -61,9 +63,20 @@
}
function snapshot(records, revision, storageName) {
const counts = { 主操: 0, 班长: 0, 副操: 0 };
records.forEach(record => { counts[record.role]++; });
return { schemaVersion: 2, records, counts, total: records.length, revision: revision || 0, storage: storageName, updatedAt: new Date().toISOString() };
const counts = { 主操: 0, 班长: 0, 副操: 0, 未标明: 0 };
const questions = new Map(), variants = new Set();
records.forEach(record => {
const role = record.role || '未标明';
counts[role] = (counts[role] || 0) + 1;
const key = record.questionKey + '\n' + record.optionKey;
const answer = JSON.stringify(record.correctTexts.map(normalize).sort());
if (!questions.has(key)) questions.set(key, new Set());
questions.get(key).add(answer);
variants.add(key + '\n' + answer);
});
return { schemaVersion: 3, records, counts, total: records.length, uniqueTotal: variants.size, questionTotal: questions.size,
conflictTotal: Array.from(questions.values()).filter(answers => answers.size > 1).length,
revision: revision || 0, storage: storageName, updatedAt: new Date().toISOString() };
}
// RFC 4180: quoted commas, escaped quotes, CRLF and embedded line breaks.
@@ -137,7 +150,7 @@
context = context || {};
if (!raw || typeof raw !== 'object') throw new Error('题目记录格式不正确');
const role = String(raw.role || context.role || '').trim();
if (!ROLES.includes(role)) throw new Error('题库未标明岗位;请先选择主操、班长或副操再导入');
// Role is provenance only. Older exports and role-free banks share one index.
if (context.pathRole && raw.role && raw.role !== context.pathRole) throw new Error('题目岗位与 ZIP 目录岗位不一致');
const type = normalizeType(raw.type);
if (!['单选', '多选', '判断'].includes(type)) throw new Error('题型不支持或缺失:' + (type || '空'));
@@ -214,6 +227,7 @@
role: read(row, '岗位') || read(row, '角色') || context.role,
course: read(row, '题库名称') || read(row, '课程') || context.course,
sourceUrl: read(row, '来源网址') || context.sourceUrl,
sourceFile: read(row, '来源文件') || context.sourceFile,
number: read(row, '题号') || index + 1,
type: read(row, '题型'), question: read(row, '题目'), options,
answer: read(row, '参考答案'), explanation: read(row, '题目解析')
@@ -251,28 +265,45 @@
function deduplicate(records) {
const seen = new Set();
return records.filter(record => {
const key = JSON.stringify([sourceScope(record), record.number, record.questionKey, record.optionKey, record.correctTexts.map(normalize).sort()]);
const key = JSON.stringify([sourceScope(record), record.sourceFile, record.number, record.questionKey, record.optionKey, record.correctTexts.map(normalize).sort()]);
if (seen.has(key)) return false;
seen.add(key); return true;
});
}
async function load() {
if (current) return current;
async function readSaved() {
const backend = storage();
let saved = await backend.get(BANK_KEY, null);
const legacy = saved == null;
if (legacy) saved = await backend.get(LEGACY_BANK_KEY, null);
if (typeof saved === 'string') {
try { saved = JSON.parse(saved); } catch (_) { throw new Error('已缓存题库格式损坏;请重新导入题库'); }
}
if (!saved) return (current = snapshot([], 0, backend.name));
if (!Array.isArray(saved.records)) throw new Error('已缓存题库格式损坏;请重新导入题库');
if (saved == null) return snapshot([], 0, backend.name);
if (!saved || !Array.isArray(saved.records) || (!legacy && saved.schemaVersion !== 3)) throw new Error('已缓存题库格式损坏;请重新导入题库');
const records = saved.records.map((record, index) => {
try { return validateRecord(record, { number: index + 1 }); }
catch (error) { throw new Error('已缓存题库的第 ' + (index + 1) + ' 道题无效;请重新导入:' + error.message); }
});
current = snapshot(deduplicate(records), Number(saved.revision) || 0, backend.name);
if (saved.updatedAt) current.updatedAt = saved.updatedAt;
return current;
const next = snapshot(deduplicate(records), Number(saved.revision) || 0, backend.name);
if (saved.updatedAt) next.updatedAt = saved.updatedAt;
if (legacy) {
// Do not alter the v2 value: a failed migration always leaves a recoverable copy.
try {
await backend.set(BANK_KEY, next);
next.migration = { from: 2, status: 'complete' };
} catch (error) {
// The validated legacy bank remains usable when a storage quota is exhausted.
next.migration = { from: 2, status: 'pending', error: String(error.message || error) };
}
}
return next;
}
async function load() {
if (current) return current;
if (!loading) loading = readSaved().then(value => (current = value)).finally(() => { loading = null; });
return loading;
}
async function fileBytes(file) {
@@ -293,7 +324,6 @@
async function performImport(files, options) {
const selected = Array.from(files || []).filter(file => !isIgnored(file.webkitRelativePath || file.name));
if (!selected.length) throw new Error('请选择 ZIP、CSV 或 JSON 题库文件');
if (options.role && !ROLES.includes(options.role)) throw new Error('岗位只能选择主操、班长或副操');
const zipFiles = selected.filter(file => /\.zip$/i.test(file.name || ''));
if (zipFiles.length && (zipFiles.length !== 1 || selected.length !== 1)) throw new Error('ZIP 全量导入时请只选择一个 ZIP 文件');
const incoming = [];
@@ -354,7 +384,7 @@
async function getSetting(key, fallback) { return storage().get(SETTING_PREFIX + String(key), fallback); }
async function setSetting(key, value) { await storage().set(SETTING_PREFIX + String(key), value); return value; }
const api = Object.freeze({ version: '2.0.0', roles: ROLES, load, importFiles, getSetting, setSetting, parseCSV, validateRecord, normalize, normalizeType, optionKey, questionKey });
const api = Object.freeze({ version: '3.0.0', roles: ROLES, load, importFiles, getSetting, setSetting, parseCSV, validateRecord, normalize, normalizeType, optionKey, questionKey });
root.YianBankStore = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
+4 -3
View File
@@ -3,13 +3,14 @@ import { readFileSync, writeFileSync } from 'node:fs';
const metadata = `// ==UserScript==
// @name 谊安学助手 2.0
// @namespace yianxue-question-bank
// @version 2.0.3
// @description 谊安学站点助手:可拖动面板、岗位搜索、答案全文核对及自动选择。
// @version 2.1.0
// @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。
// @match http://10.119.67.7/clientcn/*
// @run-at document-idle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_setClipboard
// @grant GM_openInTab
// @noframes
// ==/UserScript==
`;
@@ -22,7 +23,7 @@ const notices = '/* Third-party notices: JSZip and pako distributed under their
const zipWrapper = '(function () { const module = { exports: {} }; const exports = module.exports;\n'
+ zip + '\nwindow.JSZip = module.exports;\n})();\n';
const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js',
'copy-controller.js', 'assistant-panel.js', 'answer-runner.js', 'practice-exporter.js'];
'copy-controller.js', 'assistant-panel.js', 'answer-runner.js', 'learning-engine.js', 'quiz-adapter.js', 'exam-controller.js', 'practice-exporter.js'];
const source = modules.map(name => readFileSync(new URL('./' + name, import.meta.url), 'utf8')).join('\n');
const scope = readFileSync(new URL('./site-scope.js', import.meta.url), 'utf8');
// Also enforce the boundary when a manager injects manually or ignores metadata.
+290
View File
@@ -0,0 +1,290 @@
/* Exam evidence, original entry time and bounded real submit interaction. */
(function (root) {
'use strict';
const clean = value => String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/\s+/g, ' ').trim();
const same = (a, b) => JSON.stringify(a.map(clean).sort()) === JSON.stringify(b.map(clean).sort());
function visible(node) {
if (!node || !node.isConnected || node.closest('[hidden],[aria-hidden="true"],.hidden,[inert]')) return false;
const style = root.getComputedStyle(node);
return node.getClientRects().length > 0 && style.display !== 'none' && style.visibility !== 'hidden';
}
function remainingSeconds(doc) {
const text = clean(doc.querySelector('.pc_examtest_countdown .time')?.textContent);
const parts = text.match(/^(\d{1,3}):(\d{2}):(\d{2})$/);
if (!parts || Number(parts[2]) > 59 || Number(parts[3]) > 59) return null;
return Number(parts[1]) * 3600 + Number(parts[2]) * 60 + Number(parts[3]);
}
function paperKey(url, container) {
const parsed = new URL(url);
const route = parsed.hash.startsWith('#/') ? new URL(parsed.hash.slice(1), parsed.origin) : parsed;
const identities = new Map();
for (const params of [parsed.searchParams, route.searchParams]) for (const [name, value] of params) {
const key = name.toLowerCase().replace(/_/g, '');
if (/^(?:attemptid|examattemptid|answerrecordid|examrecordid|paperrecordid|recordid|examid|paperid|examinationid)$/.test(key) && value) identities.set(key, value);
}
if (container) for (const attribute of ['data-attempt-id', 'data-exam-attempt-id', 'data-answer-record-id', 'data-exam-record-id']) {
const node = container.matches?.('[' + attribute + ']') ? container : container.querySelector?.('[' + attribute + ']');
if (node && clean(node.getAttribute(attribute))) identities.set(attribute, clean(node.getAttribute(attribute)));
}
return route.origin + route.pathname + '|test' + (identities.size ? '|' + JSON.stringify(Array.from(identities).sort()) : '');
}
function readPaper(doc, context, snapshot, topic) {
const core = root.QuestionBankCore, matcher = root.YianAnswerMatcher;
const bank = core.extract(doc, context.sourceUrl);
const boxes = Array.from(doc.querySelectorAll(core.selectors.question));
const details = [];
let totalScore = 0, predictedScore = 0, matchedCount = 0, verifiedCount = 0, validPoints = true;
bank.questions.forEach((question, index) => {
const box = boxes[index];
const pointNodes = box ? Array.from(box.querySelectorAll('.pc_examtest_content_question_value')) : [];
const marks = pointNodes.length === 1 ? clean(pointNodes[0].textContent).match(/^[((]?\s*(\d+(?:\.\d+)?)\s*分\s*[))]?$/) : null;
const points = marks ? Number(marks[1]) : null;
if (!(points > 0 && points <= 100)) validPoints = false;
else totalScore += points;
const match = matcher.match(question, Object.assign({}, context, { courses: topic?.courses || [] }), snapshot);
const nodes = box ? Array.from(box.querySelectorAll(core.selectors.option)) : [];
const currentTexts = question.options.filter((_, i) => nodes[i]?.classList.contains('examtest_content_list_des_checked')).map(option => option.text);
const verified = match.status === 'matched' && same(currentTexts, match.correctTexts);
if (match.status === 'matched') matchedCount++;
if (verified) { verifiedCount++; if (points > 0) predictedScore += points; }
details.push({ number: question.number, question: question.question, points, verified,
status: match.status, reason: !marks ? '分值不明' : verified ? '当前答案全文核验通过' : match.status === 'matched' ? '选中内容尚未核验一致' : match.reason,
correctTexts: match.correctTexts || [], currentTexts });
});
const complete = bank.expectedTotal > 0 && bank.expectedTotal === bank.total && boxes.length === bank.total;
// The confirmed threshold is 80 raw points on the supplied 100-point paper.
const scoreValid = complete && validPoints && Math.abs(totalScore - 100) < 0.000001;
return { title: bank.title, total: bank.total, complete, totalScore, predictedScore, matchedCount, verifiedCount,
missing: details.filter(item => item.status === 'missing').length,
conflict: details.filter(item => item.status === 'conflict').length, scoreValid, details,
signature: JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map((q, index) => [q.type, q.question, q.options.map(o => [o.label, o.text]), details[index].points])]),
reason: !complete ? '等待完整试卷及答题卡' : !scoreValid ? '题目分值或 100 分分制未完整确认,等待人工检查' : '' };
}
function create(options) {
options = options || {};
const doc = options.document || root.document;
const now = options.now || Date.now;
const storage = options.storage || { get: async (_key, fallback) => fallback, set: async () => {} };
let enabled = false, destroyed = false, busy = false, entry = null, currentKey = '', stage = 'idle';
let error = '', last = {}, scope = '', ownSubmit = false, generation = 0, observing = null, observationVersion = 0;
const emit = patch => {
last = Object.assign({}, last, patch, { enabled, stage });
if (options.onState) options.onState(Object.assign({}, last));
return last;
};
const log = message => options.onLog && options.onLog('info', message);
const persist = () => entry ? storage.set('exam:' + currentKey, Object.assign({}, entry)) : Promise.resolve();
function context() { return options.getContext(); }
function keyFor(ctx) { return paperKey(ctx.sourceUrl || root.location.href, ctx.root); }
function sameContext(left, right) {
return left && right && left.root === right.root && left.mode === right.mode && left.revision === right.revision && keyFor(left) === keyFor(right);
}
function timerEvidence(ctx) {
const stamp = now(), remain = remainingSeconds(doc);
const parsed = new URL(ctx.sourceUrl || root.location.href);
const route = parsed.hash.startsWith('#/') ? new URL(parsed.hash.slice(1), parsed.origin) : parsed;
const minutes = Number(route.searchParams.get('totaltime'));
const duration = minutes > 0 && minutes <= 1440 ? minutes * 60 : null;
const strong = Boolean(duration && remain !== null && remain <= duration);
return { stamp, remain, strong, startedAt: strong ? stamp - (duration - remain) * 1000 : stamp };
}
function timerReset(saved, timer) {
if (!Number.isFinite(saved.lastRemaining) || !Number.isFinite(saved.lastObservedAt) || timer.remain === null) return false;
const elapsed = Math.max(0, (timer.stamp - saved.lastObservedAt) / 1000);
return timer.remain - Math.max(0, saved.lastRemaining - elapsed) > 5;
}
function identifiedAttempt(ctx) {
return /"(?:attemptid|examattemptid|answerrecordid|examrecordid|paperrecordid|recordid|data-attempt-id|data-exam-attempt-id|data-answer-record-id|data-exam-record-id)"/.test(keyFor(ctx));
}
function settings() { return options.getSettings ? options.getSettings() : {}; }
function resultVisible() {
return Array.from(doc.querySelectorAll('.exam_submited_box,.exam-result,app-exam-result,[data-exam-result]'))
.some(node => visible(node) && /提交成功|交卷成功|考试结束|得分|成绩/.test(node.textContent));
}
function observe() {
const ctx = context();
if (!ctx || ctx.mode !== 'test' || !ctx.root || destroyed) return Promise.resolve(null);
const key = keyFor(ctx);
if (observing && observing.key === key) return observing.promise;
if (key === currentKey && entry) {
const timer = timerEvidence(ctx);
const resetTimer = timerReset(entry, timer);
const changedPaper = entry.paperSignature && ctx.ready && readPaper(doc, ctx, options.getSnapshot(), options.getTopic()).signature !== entry.paperSignature;
const pending = ['submitting', 'confirming', 'unknown'].includes(entry.stage);
if ((resetTimer || changedPaper) && !pending) {
enabled = false; stage = 'awaitingAttempt'; scope = ''; error = '检测到新试卷或重置的倒计时,请明确启动新场次'; last = { submitted: false };
}
// A stored result belongs to the earlier attempt until the current website
// actually shows that result. Explicit enable may start a fresh visible paper.
if (stage === 'submitted' && !resultVisible() && ctx.exam && !ctx.submitted) {
enabled = false; stage = 'awaitingAttempt'; scope = ''; error = '检测到可作答试卷,请明确启动新场次';
last = { submitted: false };
}
return Promise.resolve(entry);
}
enabled = false; stage = 'idle'; scope = ''; error = ''; currentKey = key; entry = null; last = { submitted: false };
const version = ++observationVersion;
const promise = (async () => {
const timer = timerEvidence(ctx);
const saved = await storage.get('exam:' + key, null);
if (destroyed || version !== observationVersion || key !== currentKey || !sameContext(ctx, context())) return null;
entry = saved && Number.isFinite(saved.startedAt) ? Object.assign({}, saved) : { startedAt: timer.startedAt, initialRemaining: timer.remain, stage: 'idle' };
const pending = ['submitting', 'confirming', 'unknown'].includes(entry.stage);
const expiredCache = Number.isFinite(entry.lastObservedAt) && Number.isFinite(entry.lastRemaining) && timer.remain > 0 && timer.stamp - entry.lastObservedAt > (entry.lastRemaining + 30) * 1000;
const resetTimer = timerReset(entry, timer);
const changedPaper = entry.paperSignature && ctx.ready && readPaper(doc, ctx, options.getSnapshot(), options.getTopic()).signature !== entry.paperSignature;
const unconfirmedClock = saved && timer.remain === null && !identifiedAttempt(ctx);
// Current explicit duration/countdown evidence wins over an older attempt's
// timestamp; taking Math.min here would make a retake immediately eligible.
if (timer.strong && !pending) entry.startedAt = timer.startedAt;
else entry.startedAt = Math.min(timer.stamp, entry.startedAt);
if (pending) { stage = 'unknown'; error = '上次交卷结果尚未确认,请检查网站结果后人工处理'; }
else if (entry.stage === 'submitted' || expiredCache || resetTimer || changedPaper || unconfirmedClock) { stage = 'awaitingAttempt'; error = '旧场次或计时连续性未确认,请明确启动新场次,重新计算最早交卷时间'; }
entry.lastObservedAt = timer.stamp; entry.lastRemaining = timer.remain;
// Preserve a submitted/pending record until explicit new-attempt authorization.
if (stage !== 'awaitingAttempt') await persist();
return !destroyed && version === observationVersion && key === currentKey && sameContext(ctx, context()) ? entry : null;
})().finally(() => { if (observing?.promise === promise) observing = null; });
observing = { key, promise }; return promise;
}
async function enable() {
const token = ++generation, requested = context();
enabled = false;
await observe();
if (token !== generation || !sameContext(requested, context()) || !entry || ['submitting', 'confirming', 'unknown'].includes(stage) || destroyed || resultVisible()) return false;
const ctx = context(), topic = options.getTopic();
if (!topic || topic.status !== 'matched') { error = topic?.reason || '请先确认考试主题与题库关联'; emit({ message: error }); return false; }
const evidence = readPaper(doc, ctx, options.getSnapshot(), topic);
if (!evidence.complete || !evidence.scoreValid || !ctx.ready || !ctx.exam || ctx.submitted) { emit({ message: evidence.reason || '等待整卷加载完成后启动考试任务' }); return false; }
if (stage === 'awaitingAttempt') {
const timer = timerEvidence(ctx);
entry = { startedAt: timer.startedAt, initialRemaining: timer.remain, lastRemaining: timer.remain, lastObservedAt: timer.stamp, stage: 'idle' };
}
scope = JSON.stringify([currentKey, ctx.revision, evidence.signature, topic.courses]);
enabled = true; stage = 'answering'; error = ''; entry.stage = stage; entry.paperSignature = evidence.signature; await persist();
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context()) || !context().ready || options.getTopic()?.status !== 'matched' || scope !== scopeFor(context())) {
if (token === generation) disable('启动过程中页面、题库或任务状态已变化,请重新启动'); return false;
}
log('当前考试已授权:全文核验后严格大于 80 分且达到最早交卷时间才交卷');
return true;
}
function disable(reason) {
generation++;
enabled = false;
if (!['submitting', 'confirming', 'submitted', 'unknown'].includes(stage)) stage = 'paused';
error = reason || '考试自动交卷已暂停';
emit({ message: error });
}
function scopeFor(ctx) {
const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic);
return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses]);
}
function canClick(token, ctx, confirmation) {
if (token !== generation || destroyed || !enabled || !sameContext(ctx, context())) return false;
const current = context(), topic = options.getTopic(), evidence = readPaper(doc, current, options.getSnapshot(), topic);
const configured = Number(settings().minSubmitMinutes), minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
const pendingTooLong = confirmation && now() - entry.submitAt > 12000;
return current.mode === 'test' && current.root?.isConnected && current.ready && topic?.status === 'matched'
&& scope === JSON.stringify([keyFor(current), current.revision, evidence.signature, topic.courses])
&& evidence.scoreValid && evidence.matchedCount === evidence.verifiedCount && evidence.predictedScore > 80
&& options.getRunnerState() === 'complete' && (now() - entry.startedAt) / 1000 >= minimum
&& remainingSeconds(doc) !== 0 && !pendingTooLong && (confirmation || current.exam && !current.submitted);
}
function confirmationButton() {
const dialogs = Array.from(doc.querySelectorAll('.dialog_box,[role="dialog"],.modal')).filter(visible);
const containers = dialogs.filter(node => !dialogs.some(other => other !== node && other.contains(node)));
if (!containers.length) return { pending: true };
const matches = containers.filter(node => /(?:确认|确定|是否).*交卷|交卷.*(?:确认|确定)|未答.*(?:提交|交卷)|(?:提交|交卷).*未答/.test(clean(node.textContent)));
if (matches.length !== 1 || /验证|人脸|异常|失败|重新考试/.test(matches[0].textContent)) return { reason: '交卷对话框无法唯一确认,请人工检查' };
const buttons = Array.from(matches[0].querySelectorAll('button,[role="button"],.btn,.submited_btn'))
.filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && /^(确定|确认|确认交卷|确定交卷|提交|交卷|继续交卷|仍要交卷)$/.test(clean(node.textContent)));
return buttons.length === 1 ? { button: buttons[0], container: matches[0] } : { reason: '交卷确认按钮无法唯一识别,请人工检查' };
}
async function tick() {
if (destroyed || busy) return last;
busy = true;
const token = generation;
try {
const ctx = context();
if (!ctx || ctx.mode !== 'test' || !ctx.root) { if (enabled) disable('考试页面已切换'); return emit({}); }
await observe();
if (token !== generation || destroyed || !sameContext(ctx, context()) || !entry) return last;
if (resultVisible()) {
stage = 'submitted'; entry.stage = stage; enabled = false; await persist();
if (token !== generation || destroyed || !sameContext(ctx, context())) return last;
return emit({ message: '网站已显示考试结果', submitted: true });
}
const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic);
const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000);
const configured = Number(settings().minSubmitMinutes);
const minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360;
const waitSeconds = Math.max(0, Math.ceil(minimum - elapsedSeconds));
const remaining = remainingSeconds(doc);
entry.lastObservedAt = now(); entry.lastRemaining = remaining;
emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining,
predictedScore: Math.round(evidence.predictedScore * 100) / 100, submitted: false, message: error || evidence.reason }));
if (!enabled) return last;
if (scope !== JSON.stringify([currentKey, ctx.revision, evidence.signature, topic?.courses]) || topic?.status !== 'matched') {
disable('试卷、题库或主题关联已变更,请重新启动核验'); return last;
}
if (remaining === 0 || ctx.submitted && !['submitting', 'confirming'].includes(stage)) { disable('网站考试已经结束或提交'); return last; }
if (['submitting', 'confirming'].includes(stage) && now() - entry.submitAt > 12000) {
stage = 'unknown'; entry.stage = stage; enabled = false; await persist();
if (token !== generation || destroyed || !sameContext(ctx, context())) return last;
return emit({ message: '提交结果未确认,已停止重复提交,请检查网站状态' });
}
const runner = options.getRunnerState();
const allVerified = evidence.matchedCount === evidence.verifiedCount;
if (!evidence.scoreValid || !allVerified || runner !== 'complete') {
if (stage === 'confirming' || stage === 'submitting') {
enabled = false; stage = 'unknown'; entry.stage = stage; await persist();
if (token !== generation || destroyed || !sameContext(ctx, context())) return last;
return emit({ message: '交卷过程中核验条件变化,等待人工检查' });
}
stage = 'answering'; return emit({ message: evidence.reason || '等待所有可匹配题处理并回读核验' });
}
if (!(evidence.predictedScore > 80)) { stage = 'waiting'; return emit({ message: '预计得分小于或等于 80 分,等待人工操作' }); }
if (remaining !== null && remaining <= waitSeconds) { stage = 'waiting'; return emit({ message: '网站截止时间早于最早交卷时间,等待人工操作' }); }
if (waitSeconds) { stage = 'waitingSubmit'; return emit({ message: '核验完成,等待最早交卷时间' }); }
if (stage === 'confirming' || stage === 'submitting') {
const target = confirmationButton();
if (target.pending) return last;
if (target.reason) { disable(target.reason); return last; }
if (!entry.confirmed) {
entry.confirmed = true; entry.stage = stage = 'confirming'; await persist();
if (!canClick(token, ctx, true) || confirmationButton().button !== target.button) {
if (token === generation && enabled) disable('交卷确认前页面或核验条件已变化,请人工检查'); return last;
}
ownSubmit = true; try { target.button.click(); } finally { ownSubmit = false; }
log('已点击当前场次的交卷确认,等待网站结果');
}
return last;
}
if (!ctx.exam || !ctx.ready || runner !== 'complete') return last;
const findCommits = () => Array.from(ctx.root.querySelectorAll('.pc_examtest_commit')).filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && clean(node.textContent) === '交卷');
const commits = findCommits();
if (commits.length !== 1) { disable('当前交卷入口无法唯一识别'); return last; }
stage = entry.stage = 'submitting'; entry.submitAt = now(); entry.confirmed = false; await persist();
const currentCommits = findCommits();
if (!canClick(token, ctx, false) || currentCommits.length !== 1 || currentCommits[0] !== commits[0]) {
if (token === generation && enabled) disable('交卷前页面或核验条件已变化,请人工检查'); return last;
}
ownSubmit = true; try { commits[0].click(); } finally { ownSubmit = false; }
log('已点击交卷,等待当前场次的确认或结果');
return emit({ message: '交卷处理中,等待网站确认' });
} catch (failure) { disable('考试核验失败:' + failure.message); return last; }
finally { busy = false; }
}
function manual(event) {
if (!event.isTrusted || ownSubmit || !enabled) return;
const node = event.target?.nodeType === 1 ? event.target : event.target?.parentElement;
if (node?.closest('.pc_examtest_content_list_des,.pc_examtest_commit,.dialog_box button,[role="dialog"] button,.examine_box button')) disable('检测到人工处理,已取消自动交卷计划');
}
doc.addEventListener('click', manual, true);
return { observe, enable, disable, tick, getState: () => Object.assign({}, last, { enabled, stage }),
destroy() { generation++; observationVersion++; destroyed = true; enabled = false; doc.removeEventListener('click', manual, true); } };
}
const api = { create, readPaper, remainingSeconds, paperKey };
root.YianExamController = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
+650
View File
@@ -0,0 +1,650 @@
/* YianLearningEngine: 读取网页实际目录、控制原生播放器并协调已授权的学习任务。 */
(function (root, factory) {
'use strict';
const api = factory(root);
if (typeof module === 'object' && module.exports) module.exports = api;
root.YianLearningEngine = api;
})(typeof window !== 'undefined' ? window : globalThis, function (root) {
'use strict';
const TERMINAL = ['completed', 'skipped', 'failed', 'expanded'];
const ACTIVE = ['loading', 'playing', 'quiz', 'answering', 'waitingSubmit', 'retrying'];
const MODES = ['video', 'videoQuiz', 'exam', 'all'];
const LEASE_MS = 18000;
const SESSION_KEY = 'session.v1';
const CONTROL_KEY = 'control.v1';
function clean(value) { return String(value == null ? '' : value).replace(/[\u200b-\u200d\ufeff]/g, '').replace(/\s+/g, ' ').trim(); }
function clone(value) { return JSON.parse(JSON.stringify(value)); }
function hash(value) { let h = 2166136261; for (const c of String(value)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return (h >>> 0).toString(36); }
function token() { return root.crypto && root.crypto.randomUUID ? root.crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2); }
function safeURL(value, base) {
try { const url = new URL(value, base); return url.origin === 'http://10.119.67.7' && url.pathname.startsWith('/clientcn/') ? url : null; } catch (_) { return null; }
}
function canonical(value) { try { const u = new URL(value); u.hash = ''; return u.href; } catch (_) { return String(value || ''); } }
function visible(node) {
if (!node || node.closest('[hidden],[aria-hidden="true"],.ant-modal-hidden')) return false;
for (let n = node; n && n.nodeType === 1; n = n.parentElement) {
if (n.style && (n.style.display === 'none' || n.style.visibility === 'hidden')) return false;
if (n.isConnected && n.ownerDocument.defaultView && n.ownerDocument.defaultView.getComputedStyle) {
const style = n.ownerDocument.defaultView.getComputedStyle(n);
if (style.display === 'none' || style.visibility === 'hidden') return false;
}
}
return true;
}
function textAt(doc, selectors) { for (const selector of selectors.split(',')) { const node = doc.querySelector(selector); if (node && clean(node.textContent)) return clean(node.textContent); } return ''; }
function number(value) { const n = Number(value); return Number.isFinite(n) && n >= 0 ? n : null; }
function seconds(value) {
const text = clean(value); let total = 0; let matched = false;
const clock = text.match(/(?:^|\s)(\d{1,3}):(\d{2})(?::(\d{2}))?(?:$|\s)/);
if (clock) return clock[3] == null ? Number(clock[1]) * 60 + Number(clock[2]) : Number(clock[1]) * 3600 + Number(clock[2]) * 60 + Number(clock[3]);
for (const entry of [[/(\d+(?:\.\d+)?)小时/, 3600], [/(\d+(?:\.\d+)?)分钟/, 60], [/(\d+(?:\.\d+)?)分(?!钟)/, 60], [/(\d+(?:\.\d+)?)秒/, 1]]) {
const m = text.match(entry[0]); if (m) { total += Number(m[1]) * entry[1]; matched = true; }
}
return matched ? total : null;
}
function nodeURL(node, url) {
const own = node.matches('[href],[data-task-url],[routerlink]') ? node : node.querySelector('a[href],[data-task-url],[routerlink]');
if (!own) return '';
const raw = own.getAttribute('href') || own.getAttribute('data-task-url') || own.getAttribute('routerlink');
if (!raw || /^(?:javascript:|#|\[|\{)/i.test(raw)) return '';
const resolved = safeURL(raw, url); return resolved ? resolved.href : '';
}
function isComplete(node) {
if (!node) return false;
if (node.matches('[data-is-learned="true"],[data-learned="true"],[data-completed="true"]')) return true;
if (node.querySelector('[data-is-learned="true"],[data-completed="true"]')) return true;
if (node.matches('.map_activity_content_info_completed') || node.querySelector('.map_activity_content_info_completed')) return true;
// 原站未完成图标也含 en_courseware_finished,必须排除 noFinished。
if (Array.from(node.querySelectorAll('.en_courseware_finished')).some(n => !n.classList.contains('en_courseware_noFinished'))) return true;
const explicit = node.querySelector('[data-learning-status],.courseware_finished_status,.learning-status');
return Boolean(explicit && /^(?:已完成|学习完成|已学完|完成)$/.test(clean(explicit.textContent)));
}
function titleOf(doc) {
const exact = textAt(doc, '[data-course-title],.course-courseware-other-top-left');
if (exact) return exact;
const header = doc.querySelector('.pc_course_left_top_header');
if (header) {
const copy = header.cloneNode(true); copy.querySelectorAll('.pc_course_left_top_header_info,.course-courseware,.course-courseware-playdefult').forEach(n => n.remove());
const title = clean(copy.textContent).replace(/(?:学习时长|发布时间|学习计划)[\s\S]*$/, '').trim(); if (title) return title;
}
const exam = doc.querySelector('.examdetail_header');
if (exam) return clean(exam.textContent).split(/已答|正确率|得分[::]/)[0].trim();
return textAt(doc, '[data-course-title],.pc_examtest_title,.top_map_title,h1') || clean(doc.title) || '未命名任务';
}
function quizOf(doc) {
const portal = Array.from(doc.querySelectorAll('.question-modal-pc .question_box,.question-modal-pc .answer_box')).find(visible);
if (portal) return portal.closest('.question-modal-pc');
return Array.from(doc.querySelectorAll('app-question-modal,[data-learning-quiz]')).find(node => visible(node) && Boolean(node.querySelector('.pc_examtest_content,[app-exam-question],input[type="radio"],input[type="checkbox"],[data-question],.question-content,.question_content') || clean(node.textContent))) || null;
}
function describePage(doc, url) {
const current = canonical(url || doc.URL);
const video = Array.from(doc.querySelectorAll('app-course-courseware-video video,app-video-base video,video[data-learning-video],video')).find(visible) || null;
const kind = doc.querySelector('app-examtest') ? 'paper' : video ? 'video' : doc.querySelector('app-examdetail') ? 'exam' : doc.querySelector('app-learning-map-activity-list') ? 'map' : doc.querySelector('app-course') ? 'course' : 'unknown';
const active = doc.querySelector('.cn_courseware_img.active,[data-courseware-id][aria-current="true"]');
const explicit = doc.querySelector('[data-account-id]');
return { kind, url: current, title: titleOf(doc), accountId: explicit ? clean(explicit.getAttribute('data-account-id')) : '',
coursewareTitle: active ? clean((active.querySelector('p') || active).textContent) : '',
video, activeCourseware: active, quiz: quizOf(doc), platformComplete: isComplete(active) || Boolean(doc.querySelector('app-course-courseware-video[data-is-learned="true"]')),
learnedSeconds: active ? seconds(clean((active.querySelector('.menu_courseware_left') || active).textContent)) : null,
requiredSeconds: number(active && active.getAttribute('data-required-seconds')),
hidden: doc.visibilityState === 'hidden' };
}
function discoverTasks(doc, url) {
const page = describePage(doc, url); const tasks = []; const seen = new Set();
function add(node, kind, selector, index, parentId) {
const explicitId = node.getAttribute('data-task-id') || node.getAttribute('data-courseware-id') || node.getAttribute('data-course-id') || node.getAttribute('data-exam-id') || node.id;
const targetURL = nodeURL(node, page.url);
let title = clean((node.querySelector('.activity_name,p,[data-task-title]') || node).textContent);
if (node.matches('.cn_courseware_img')) title = clean((node.querySelector(':scope > p') || node.querySelector('p') || node).textContent);
const identity = targetURL ? canonical(targetURL) : explicitId ? page.url + ':' + explicitId : page.url + ':' + selector + ':' + index;
const id = kind + ':' + hash(identity); if (seen.has(id)) return;
seen.add(id);
const locked = node.matches('[disabled],[aria-disabled="true"],[data-locked="true"],.locked') || Boolean(node.querySelector('[data-locked="true"]'));
tasks.push({ id, kind, title: title || kind, url: targetURL, pageUrl: page.url, selector, index, identity: explicitId || '', parentId: parentId || '',
status: isComplete(node) ? 'completed' : locked ? 'waiting' : 'pending', reason: locked ? '网站尚未开放此任务,等待前置条件' : '',
progress: isComplete(node) ? 1 : 0, duration: number(node.getAttribute('data-duration')), locked, retryCount: 0, lastRefreshAt: 0 });
return tasks[tasks.length - 1];
}
const mapNodes = doc.querySelectorAll('app-learning-map-activity-list .activity_content');
mapNodes.forEach((node, i) => {
const label = clean((node.querySelector('.acitvity_other_info') || node).textContent);
add(node, /考试/.test(label) ? 'exam' : 'course', 'app-learning-map-activity-list .activity_content', i);
});
const chapterNodes = doc.querySelectorAll('app-course .cn_courseware_img,[data-courseware-id]');
chapterNodes.forEach((node, i) => {
const icon = clean((node.querySelector('.cn_courseware_img_icon') || node).textContent);
const task = add(node, /视频/.test(icon) || node.getAttribute('data-kind') === 'video' ? 'video' : 'unsupported', 'app-course .cn_courseware_img,[data-courseware-id]', i);
if (task && task.kind === 'unsupported' && task.status !== 'completed') { task.status = 'skipped'; task.reason = '暂未适配此非视频课件'; }
node.querySelectorAll('.related_test,[data-related-exam]').forEach((exam, j) => {
const examTask = add(exam, 'exam', 'app-course .cn_courseware_img,[data-courseware-id]', i, task && task.id);
if (examTask) { examTask.selector = 'app-course .cn_courseware_img,[data-courseware-id]'; examTask.subSelector = '.related_test,[data-related-exam]'; examTask.subIndex = j; examTask.id += ':exam:' + j; examTask.requiresParent = exam.getAttribute('data-requires-complete') === 'true'; }
});
});
doc.querySelectorAll('a[href],[data-task-url]').forEach((node, i) => {
const kind = node.getAttribute('data-kind'); const u = nodeURL(node, page.url);
if (!u || node.closest('.activity_content,.cn_courseware_img,[data-courseware-id]')) return;
let inferred = /\/exam\//.test(u) ? 'exam' : /\/(?:course|courseware)\//.test(u) ? 'course' : '';
if (['video', 'exam', 'course'].includes(kind)) inferred = kind;
if (inferred) add(node, inferred, 'a[href],[data-task-url]', i);
});
if ((page.kind === 'exam' || page.kind === 'paper') && !tasks.some(t => t.kind === 'exam' && canonical(t.url || t.pageUrl) === page.url)) {
tasks.unshift({ id: 'exam:' + hash(page.url), kind: 'exam', title: page.title, url: page.url, pageUrl: page.url, current: true, status: 'pending', progress: 0, retryCount: 0 });
}
if (page.video && !tasks.some(t => t.kind === 'video' && doc.querySelectorAll(t.selector || ':not(*)')[t.index] === page.activeCourseware)) {
tasks.unshift({ id: 'video:' + hash(page.url + ':' + (page.video.id || 'player')), kind: 'video', title: page.coursewareTitle || page.title, url: page.url, pageUrl: page.url, current: true,
status: page.platformComplete ? 'completed' : 'pending', progress: page.platformComplete ? 1 : 0, retryCount: 0, lastRefreshAt: 0 });
}
return tasks;
}
function preflightTopic(doc, snapshot, associations, task, explicitTask) {
// 兼容控制器公开调用 (document, url, snapshot, associations, task)。
if (typeof snapshot === 'string') { snapshot = associations; associations = task; task = explicitTask; }
snapshot = snapshot || {}; associations = associations || {};
const title = clean(task && task.title || titleOf(doc));
const records = snapshot.records || snapshot.bank && snapshot.bank.records || [];
const declared = snapshot.courses || snapshot.themes || snapshot.bank && snapshot.bank.courses || [];
const available = Array.from(new Set((declared.length ? declared : records).map(c => clean(typeof c === 'string' ? c : c.course || c.name)).filter(Boolean)));
const explicit = associations[task && task.id] || associations[title];
const list = (Array.isArray(explicit) ? explicit : explicit ? [explicit] : []).map(clean).filter(Boolean);
if (list.length) {
const missing = list.filter(c => !available.includes(c));
return missing.length ? { status: 'missing', title, courses: [], candidates: available, reason: '明确关联的题库主题不存在:' + missing.join('、') } : { status: 'matched', title, courses: list, candidates: list, reason: '使用用户明确确认的主题关联' };
}
if (!available.length) return { status: 'missing', title, courses: [], candidates: [], reason: '统一题库为空或没有可用课程/主题' };
const exact = available.filter(c => c === title || c === clean(snapshot.course));
if (exact.length) return { status: 'matched', title, courses: exact, candidates: exact, reason: '考试主题与题库课程完全对应' };
const candidates = available.filter(c => c.length >= 2 && (title.includes(c) || c.includes(title))).slice(0, 20);
return { status: candidates.length ? 'waiting' : 'missing', title, courses: [], candidates, reason: candidates.length ? '仅发现相似主题,请先明确关联,暂不自动开考' : '未发现对应题库主题,已跳过自动开考;可在题库栏建立明确关联' };
}
function create(options) {
options = options || {};
const now = options.now || Date.now;
const storage = options.storage || { get: async () => null, set: async () => {} };
const owner = options.ownerId || token(); let initialized = false; let initializing = null; let destroyed = false; let busy = false; let generation = 0;
let session = null; let activeTaskId = ''; let pageKey = ''; let saveSignature = ''; let pendingQuiz = null; let worker = null;
let lastTime = null; let lastAdvancedAt = now(); let attemptedAt = 0; let playStarted = false;
let interactionAt = -Infinity; let lastAutoPlayAt = -Infinity; let manualVideoPause = false; let watchedDocument = null;
let sharedPaused = false;
const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false };
const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' };
function snapshot() {
const given = typeof options.getSnapshot === 'function' ? options.getSnapshot() || {} : {};
return Object.assign({ document: root.document, url: root.location ? root.location.href : '', courses: [] }, given);
}
function onInteraction(event) {
if (event.isTrusted && event.target && event.target.closest && event.target.closest('video,.xgplayer')) interactionAt = now();
}
function onVideoPause(event) {
if (event.target && event.target.tagName === 'VIDEO' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) manualVideoPause = true;
}
function notify() {
Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, videoPaused: config.videoPaused, examPaused: config.examPaused });
const counted = state.tasks.filter(t => t.status !== 'expanded' && allows(t.kind));
state.total = counted.length; state.completed = counted.filter(t => t.status === 'completed').length;
state.skipped = counted.filter(t => t.status === 'skipped').length; state.waiting = counted.filter(t => t.status === 'waiting').length; state.failed = counted.filter(t => t.status === 'failed').length;
const todo = counted.filter(t => !TERMINAL.includes(t.status));
const estimates = todo.map(t => {
if (t.status === 'waiting' || t.kind === 'course' || t.kind === 'unsupported') return null;
if (t.kind === 'video') {
const v = state.video && state.video.taskId === t.id ? state.video : t;
if (!(v.duration > 0)) return null;
const playback = Math.max(0, v.duration - (v.currentTime || 0)) / (v.actualRate || config.rate);
// 播放时长已知仍不能推定平台学习条件,未知条件须明确显示未知。
if (v.requiredSeconds == null || v.learnedSeconds == null) return null;
return Math.max(playback, Math.max(0, v.requiredSeconds - v.learnedSeconds));
}
const e = state.exam && state.exam.taskId === t.id ? state.exam : null;
if (!e || e.total == null || ['waiting', 'paused', 'unknown'].includes(e.stage || e.status)) return null;
const elapsed = e.elapsedSeconds != null ? e.elapsedSeconds : e.enteredAt != null ? (now() - e.enteredAt) / 1000 : null;
if (elapsed == null && e.waitSeconds == null) return null;
return Math.max(0, (e.total - (e.verified || 0)) * config.answerInterval, e.waitSeconds != null ? e.waitSeconds : config.minSubmitMinutes * 60 - elapsed);
});
state.etaSeconds = estimates.some(e => e == null) ? null : config.execution === 'parallel' ? Math.max(0, ...['video', 'exam'].map(kind => estimates.reduce((n, e, i) => n + (todo[i].kind === kind ? e : 0), 0))) : estimates.reduce((n, e) => n + e, 0);
if (typeof options.onState === 'function') options.onState(clone(state));
}
function log(level, message, task) {
state.logs.push({ time: now(), level: ['info', 'warn', 'error'].includes(level) ? level : 'info', message: String(message), task: task && task.title || '', taskId: task && task.id || '' });
state.logs = state.logs.slice(-500); notify();
}
function taskState(task, status, reason) {
const changed = task.status !== status || task.reason !== (reason || ''); task.status = status; task.reason = reason || '';
if (changed) log(status === 'waiting' || status === 'failed' ? 'warn' : 'info', reason || status, task);
}
function allows(kind) { return kind === 'course' || kind === 'unsupported' || kind === 'video' && config.mode !== 'exam' || kind === 'exam' && (config.mode === 'exam' || config.mode === 'all'); }
function account(snap, page) { return clean(snap.accountId || page.accountId || ''); }
async function persist() {
if (!session) return;
session.config = clone(config); session.tasks = clone(state.tasks); session.logs = clone(state.logs); session.activeTaskId = activeTaskId; session.updatedAt = now();
// 执行器只写自己领取的任务检查点,主协调器合并,避免并行页面覆盖整份清单。
if (worker) {
const own = state.tasks.find(t => t.id === (activeTaskId || worker.taskId));
if (own) await storage.set('task:' + session.id + ':' + own.id, clone(own));
return;
}
const currentControl = await storage.get(CONTROL_KEY);
if (currentControl && currentControl.sessionId !== session.id) return;
const signature = JSON.stringify([session.tasks, session.config, session.activeTaskId, session.status, session.accountId, session.planId, session.bankVersion]);
if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; }
}
async function release() {
for (const kind of ['video', 'exam']) { const key = 'lease:' + kind; const lease = await storage.get(key); if (lease && lease.owner === owner) await storage.set(key, null); }
}
async function claim(task) {
const kind = task.kind === 'course' ? 'video' : task.kind;
if (kind !== 'video' && kind !== 'exam') return true;
async function acquire() {
const key = 'lease:' + kind; const lease = await storage.get(key);
const shared = await storage.get(CONTROL_KEY);
if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) return false;
if (lease && lease.owner !== owner && lease.sessionId === session.id && lease.expires > now()) return false;
const wanted = { owner, taskId: task.id, sessionId: session.id, expires: now() + LEASE_MS };
await storage.set(key, wanted);
// GM存储无CAS;延后回读并在每次动作前复核。支持Web Locks时同源标签领取是互斥的。
if (!(root.navigator && root.navigator.locks)) await new Promise(resolve => root.setTimeout(resolve, 25));
const saved = await storage.get(key); return Boolean(saved && saved.owner === owner && saved.taskId === task.id);
}
return root.navigator && root.navigator.locks ? root.navigator.locks.request('yian-learning-' + kind, acquire) : acquire();
}
async function heartbeat(task) {
const key = 'lease:' + (task.kind === 'course' ? 'video' : task.kind); const lease = await storage.get(key);
if (!lease || lease.owner !== owner || lease.taskId !== task.id || lease.sessionId !== session.id) return false;
const shared = await storage.get(CONTROL_KEY); if (shared && (shared.sessionId !== session.id || shared.status !== 'running')) return false;
lease.expires = now() + LEASE_MS; await storage.set(key, lease); return true;
}
function mergeTasks(tasks, parent) {
tasks.forEach(task => {
if (parent && task.id !== parent.id && task.kind !== 'course') task.parentCourse = parent.id;
const current = state.tasks.find(t => t.id === activeTaskId);
// 开考前详情路由与试卷路由不同;同一已授权转场不再生成第二场考试。
const alias = task.current && current && current.kind === task.kind &&
(task.kind === 'exam' && (current.enterClickedAt || current.examLinkClickedAt || current.navigationRequestedAt) && task.title === current.title || task.kind === 'video' && task.title === current.title);
const old = state.tasks.find(t => t.id === task.id || task.identity && t.kind === task.kind && t.identity === task.identity) || (alias ? current : null);
if (!old) state.tasks.push(task);
else {
if (alias && task.url) old.url = task.url;
if (task.status === 'completed') { old.status = 'completed'; old.progress = 1; old.reason = '平台确认已完成'; }
}
});
}
function resolveNode(task, snap) {
if (canonical(snap.url) !== canonical(task.pageUrl) || !task.selector) return null;
let node = snap.document.querySelectorAll(task.selector)[task.index];
if (node && task.subSelector) node = node.querySelectorAll(task.subSelector)[task.subIndex];
if (!node || !visible(node)) return null;
const actualId = node.getAttribute('data-task-id') || node.getAttribute('data-courseware-id') || node.getAttribute('data-course-id') || node.getAttribute('data-exam-id') || node.id;
if (task.identity && actualId !== task.identity) return null;
const title = clean((node.querySelector('.activity_name,:scope > p,[data-task-title]') || node).textContent);
if (!task.identity && title !== task.title) return null;
return node;
}
function initialize() {
if (initialized) return Promise.resolve();
if (initializing) return initializing;
initializing = initializeWork().then(() => { initialized = true; }).finally(() => { initializing = null; });
return initializing;
}
async function initializeWork() {
const snap = snapshot(); if (!snap.document) return;
watchedDocument = snap.document; watchedDocument.addEventListener('pointerdown', onInteraction, true); watchedDocument.addEventListener('keydown', onInteraction, true); watchedDocument.addEventListener('pause', onVideoPause, true);
const page = describePage(snap.document, snap.url);
const saved = await storage.get(SESSION_KEY);
const marker = new URL(snap.url || 'http://invalid').hash.match(/(?:^#|&)__yian_worker=([^&]+)/);
const recover = await storage.get('recover:' + canonical(snap.url));
const transition = await storage.get('transition.v1');
if (saved && Array.isArray(saved.tasks)) {
const identityOK = (!saved.accountId || saved.accountId === account(snap, page)) && (!saved.planId || !snap.planId || saved.planId === snap.planId) && (!saved.bankVersion || !snap.bankVersion || saved.bankVersion === snap.bankVersion);
if (identityOK) {
session = saved; Object.assign(config, saved.config || {}); state.tasks = saved.tasks; state.logs = Array.isArray(saved.logs) ? saved.logs.slice(-500) : []; activeTaskId = saved.activeTaskId || '';
if (marker) {
const request = await storage.get('worker:' + marker[1]);
const control = await storage.get(CONTROL_KEY);
if (request && control && request.sessionId === session.id && request.url === canonical(snap.url) && request.expires > now() && ['running', 'paused'].includes(control.status)) {
worker = request; activeTaskId = request.taskId; state.status = control.status; sharedPaused = control.status === 'paused'; request.owner = owner; request.confirmedAt = now(); await storage.set('worker:' + marker[1], request);
}
}
if (!worker) {
const sharedControl = await storage.get(CONTROL_KEY);
const targetOK = transition && transition.sessionId === session.id && transition.expires > now() && transition.accountId === account(snap, page) &&
(transition.targetURL ? transition.targetURL === page.url : transition.sourceURL !== page.url && transition.title === page.title && ['course', 'video', 'exam', 'paper'].includes(page.kind));
const controlled = sharedControl && sharedControl.status === 'running' && (recover && recover.sessionId === session.id && recover.taskId === activeTaskId && recover.expires > now() && recover.accountId === account(snap, page) || targetOK);
if (targetOK) {
activeTaskId = transition.taskId;
if (transition.workerNonce && sharedControl && sharedControl.status === 'running') {
const request = await storage.get('worker:' + transition.workerNonce);
if (request && request.sessionId === session.id && request.taskId === activeTaskId) { worker = request; worker.owner = owner; worker.confirmedAt = now(); await storage.set('worker:' + worker.nonce, worker); }
}
await storage.set('transition.v1', null);
}
if (controlled) { state.status = 'running'; session.status = 'running'; await storage.set('recover:' + canonical(snap.url), null); log('info', '已核对脚本恢复标记,继续同一视频任务'); }
else { state.status = 'paused'; session.status = 'paused'; state.message = '已恢复上次任务清单,人工刷新后默认暂停;点击继续重新核对'; }
}
} else { state.message = '账号、学习计划或题库版本已变化,未恢复旧任务'; log('warn', state.message); }
}
if (worker && session) { const checkpoint = await storage.get('task:' + session.id + ':' + activeTaskId); const task = state.tasks.find(t => t.id === activeTaskId); if (checkpoint && task) Object.assign(task, checkpoint); }
mergeTasks(discoverTasks(snap.document, snap.url)); notify();
}
async function control(status, force, explicitWorkerAction) {
if (!session || worker && !explicitWorkerAction) return;
const current = await storage.get(CONTROL_KEY);
if (!force && current && current.sessionId !== session.id) return;
await storage.set(CONTROL_KEY, { sessionId: session.id, status, config: clone(worker && current ? current.config : config), updatedAt: now(), owner: worker && current ? current.owner : owner });
}
async function configure(patch) {
await initialize(); patch = patch || {}; const wasRunning = state.status === 'running';
if (patch.retryDelaySeconds != null) patch = Object.assign({}, patch, { recoverySeconds: patch.retryDelaySeconds });
if (patch.maxRefresh != null) patch = Object.assign({}, patch, { maxRefreshes: patch.maxRefresh });
const modeChanged = patch.mode && patch.mode !== config.mode || patch.execution && patch.execution !== config.execution;
if (MODES.includes(patch.mode)) config.mode = patch.mode;
if (['sequential', 'parallel'].includes(patch.execution)) config.execution = patch.execution;
for (const key of ['rate', 'answerInterval', 'minSubmitMinutes', 'stallSeconds', 'recoverySeconds', 'maxRefreshes', 'minRefreshSeconds']) {
if (Number.isFinite(Number(patch[key]))) config[key] = Math.max(key === 'rate' ? 0.25 : key === 'answerInterval' || key === 'minSubmitMinutes' ? 0 : 1, Number(patch[key]));
}
config.rate = Math.min(2, config.rate); config.maxRefreshes = Math.min(3, Math.floor(config.maxRefreshes));
if (patch.associations && typeof patch.associations === 'object') config.associations = clone(patch.associations);
if (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused;
if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused;
if (patch.clearLogs) state.logs = [];
if (modeChanged && wasRunning) await pause('执行模式已改变,请核对任务后继续');
if (config.videoPaused) { const snap = snapshot(); const v = snap.document && describePage(snap.document, snap.url).video; if (v) v.pause(); }
if (config.examPaused && typeof options.onExamPause === 'function') options.onExamPause('考试任务已局部暂停');
await control(state.status); await persist(); notify(); return getState();
}
async function start() {
await initialize(); if (destroyed) return getState();
const snap = snapshot(); const page = describePage(snap.document, snap.url);
if (!safeURL(snap.url, snap.url)) { state.status = 'paused'; state.message = '当前页面不在已授权站点目录'; notify(); return getState(); }
const previous = session;
config.videoPaused = false; config.examPaused = false;
session = { id: token(), accountId: account(snap, page), planId: snap.planId || page.url, bankVersion: snap.bankVersion || '', status: 'running', createdAt: now(), tasks: [], config: clone(config), logs: clone(state.logs) };
state.tasks = []; activeTaskId = ''; worker = null; saveSignature = ''; mergeTasks(discoverTasks(snap.document, snap.url));
// 全新一轮不沿用旧执行状态,但保留平台本身已完成标记。
if (previous) await release();
state.status = 'running'; sharedPaused = false; state.message = '正在按当前模式核对任务'; generation++; await control('running', true); await persist(); log('info', '开始本轮学习:' + config.mode + ' / ' + config.execution);
return tick();
}
async function pause(reason) {
await initialize(); generation++; state.status = 'paused'; sharedPaused = true; state.message = reason || '已暂停全部任务;网站考试倒计时仍会继续';
if (session) session.status = 'paused'; const snap = snapshot(); const page = snap.document && describePage(snap.document, snap.url); if (page && page.video) page.video.pause();
if (typeof options.onExamPause === 'function') options.onExamPause(state.message);
await control('paused', false, true); await release(); await persist(); log('info', state.message); return getState();
}
async function resume() {
await initialize(); if (!session) return start();
const snap = snapshot(); const page = describePage(snap.document, snap.url);
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) {
state.message = '账号或题库版本已变化,请开始新一轮任务'; state.status = 'paused'; notify(); return getState();
}
state.tasks.filter(t => (t.status === 'waiting' || ACTIVE.includes(t.status) || t.status === 'waitingPlatform') && !t.locked).forEach(t => { t.status = 'pending'; t.reason = ''; t.examRequested = false; });
config.videoPaused = false; config.examPaused = false;
generation++; state.status = 'running'; sharedPaused = false; session.status = 'running'; activeTaskId = ''; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
await control('running', false, true); await persist(); log('info', '继续任务,重新核验页面实际状态'); return tick();
}
async function stop() {
await pause('已停止本轮;保留网站已发生的学习和作答记录'); state.status = 'stopped'; if (session) session.status = 'stopped'; await control('stopped'); await persist(); notify(); return getState();
}
async function activate(task, snap, page) {
if (task.locked) { taskState(task, 'waiting', '网站尚未开放此任务'); return false; }
if (task.requiresParent && task.parentId) { const parent = state.tasks.find(t => t.id === task.parentId); if (parent && parent.status !== 'completed') return false; }
if (task.kind === 'exam') {
task.preflight = preflightTopic(snap.document, snap, config.associations, task);
if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return false; }
}
if (!(await claim(task))) { taskState(task, 'waiting', '另一个页面正在处理同类任务,避免重复领取'); return false; }
activeTaskId = task.id; playStarted = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); manualVideoPause = false;
const onPage = canonical(task.url || task.pageUrl) === page.url;
const isCurrentVideo = task.kind === 'video' && page.video && (task.current || resolveNode(task, snap) === page.activeCourseware);
const isCurrentExam = task.kind === 'exam' && ['exam', 'paper'].includes(page.kind) && onPage;
if (isCurrentVideo || isCurrentExam) { taskState(task, 'loading', '已领取当前页面任务'); return true; }
const node = resolveNode(task, snap);
taskState(task, 'loading', '正在通过网页现有入口打开任务');
await persist();
const targetURL = task.url || (canonical(task.pageUrl) !== page.url ? task.pageUrl : '');
task.navigationRequestedAt = now();
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, title: task.title, targetURL: canonical(targetURL), accountId: account(snap, page), expires: now() + 60000 });
if (node) { (node.matches('.activity_content') && node.querySelector('.map_activity_content_info_top') || node).click(); return true; }
if (task.url && typeof options.navigate === 'function' && safeURL(task.url, snap.url)) { await options.navigate(task.url); return true; }
if (canonical(task.pageUrl) !== page.url && safeURL(task.pageUrl, snap.url) && typeof options.navigate === 'function') { await options.navigate(task.pageUrl); return true; }
taskState(task, 'waiting', '找不到原任务入口,请打开该课程后继续'); activeTaskId = ''; await release(); return false;
}
async function videoTick(task, snap, page, epoch) {
if (config.videoPaused) { if (page.video) page.video.pause(); taskState(task, 'waiting', '视频任务已局部暂停'); return; }
if (!page.video) {
if (task.selector) { const node = resolveNode(task, snap); if (node && task.status === 'loading' && !task.openedAt) { task.openedAt = now(); node.click(); return; } }
if (!task.openedAt) task.openedAt = now();
if (now() - task.openedAt > 15000) taskState(task, 'waiting', '播放器未加载或自动播放需要用户操作');
return;
}
const video = page.video;
if (page.activeCourseware && !task.current && task.title !== clean((page.activeCourseware.querySelector(':scope > p') || page.activeCourseware).textContent)) {
taskState(task, 'waiting', '当前选中课件与已领取任务不同,请核对后继续'); return;
}
if (page.platformComplete) {
state.video = { taskId: task.id, title: task.title, currentTime: Number.isFinite(video.currentTime) ? video.currentTime : 0,
duration: Number.isFinite(video.duration) ? video.duration : null, actualRate: video.playbackRate,
learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, platformComplete: true,
background: page.hidden, status: 'completed', retryCount: task.retryCount || 0 };
task.progress = 1; taskState(task, 'completed', '平台已确认课件学习完成'); activeTaskId = ''; await release(); return;
}
const popup = page.quiz;
const currentTime = Number.isFinite(video.currentTime) ? video.currentTime : 0; const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : null;
state.video = { taskId: task.id, title: task.title, currentTime, duration, actualRate: video.playbackRate, learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, platformComplete: false, background: page.hidden, status: 'playing', retryCount: task.retryCount || 0 };
Object.assign(task, { currentTime, duration, learnedSeconds: page.learnedSeconds, requiredSeconds: page.requiredSeconds, progress: duration ? Math.min(1, currentTime / duration) : 0 });
if (popup) {
video.pause(); lastAdvancedAt = now(); attemptedAt = 0; playStarted = false;
if (config.mode === 'video') { taskState(task, 'waiting', '仅视频模式遇到强制课中题,请人工完成后继续'); state.video.status = 'waiting'; return; }
taskState(task, 'quiz', '正在核对课中题完整文字'); state.video.status = 'quiz';
if (typeof options.solveQuiz !== 'function') { taskState(task, 'waiting', '缺少此课中题结构适配,请人工处理;不会猜选或刷新'); return; }
if (pendingQuiz) return;
pendingQuiz = popup;
try {
const result = await options.solveQuiz(popup, { task: clone(task), answerInterval: config.answerInterval, document: snap.document });
if (epoch !== generation || state.status !== 'running') return;
if (!result || result.status !== 'completed' || result.verified !== true || quizOf(snap.document)) taskState(task, 'waiting', result && result.reason || '课中题尚未确认提交成功,请人工核对后继续');
else { taskState(task, 'playing', '课中题已核验且弹题已关闭,继续视频'); lastAdvancedAt = now(); }
} catch (error) { if (epoch === generation) taskState(task, 'waiting', '课中题处理失败:' + error.message); }
finally { pendingQuiz = null; }
return;
}
if (manualVideoPause) { taskState(task, 'waiting', '用户已在播放器主动暂停,请点击继续恢复任务'); state.video.status = 'waiting'; return; }
if (video.ended || duration && currentTime >= duration - 0.05) { taskState(task, 'waitingPlatform', '视频已播放到末尾,等待平台确认学习完成'); state.video.status = 'waitingPlatform'; attemptedAt = 0; return; }
if (!Number.isFinite(video.playbackRate) || Math.abs(video.playbackRate - config.rate) > 0.01) { try { video.playbackRate = config.rate; } catch (_) {} }
state.video.actualRate = video.playbackRate;
if (Math.abs(video.playbackRate - config.rate) > 0.01) state.video.rateMessage = '网站未接受所选倍速,当前实际 ' + video.playbackRate + 'X';
if (!playStarted) {
playStarted = true;
lastAutoPlayAt = now();
try { const promise = video.play(); if (promise && promise.catch) await promise; }
catch (_) { taskState(task, 'waiting', '浏览器拒绝自动播放,请手动播放后继续'); state.video.status = 'waiting'; return; }
if (epoch !== generation || state.status !== 'running') { video.pause(); return; }
lastAdvancedAt = now();
}
if (lastTime === null || Math.abs(currentTime - lastTime) > 0.01) { lastTime = currentTime; lastAdvancedAt = now(); attemptedAt = 0; taskState(task, 'playing', page.hidden ? '页面在后台,按真实播放与平台记录跟踪' : '视频播放中'); return; }
if (!attemptedAt && now() - lastAdvancedAt >= config.stallSeconds * 1000) {
attemptedAt = now(); taskState(task, 'retrying', '播放进度长时间未变化,先尝试续播');
try { await video.play(); } catch (_) { taskState(task, 'waiting', '续播被浏览器拒绝,请人工操作'); }
return;
}
if (attemptedAt && now() - attemptedAt >= config.recoverySeconds * 1000) {
if ((task.retryCount || 0) >= config.maxRefreshes) { taskState(task, 'failed', '已达到本课件最多 ' + config.maxRefreshes + ' 次恢复上限,请人工处理'); state.video.status = 'failed'; return; }
if (task.lastRefreshAt && now() - task.lastRefreshAt < config.minRefreshSeconds * 1000) return;
if (!await heartbeat(task) || epoch !== generation || state.status !== 'running') return;
task.retryCount = (task.retryCount || 0) + 1; task.lastRefreshAt = now(); taskState(task, 'retrying', '视频续播失败,保存检查点后刷新当前视频页(' + task.retryCount + '/' + config.maxRefreshes + ')');
await persist();
await storage.set('recover:' + page.url, { sessionId: session.id, taskId: task.id, accountId: account(snap, page), expires: now() + 60000 });
if (typeof options.reload === 'function') await options.reload(); else if (root.location && canonical(root.location.href) === page.url) root.location.reload();
attemptedAt = 0; lastAdvancedAt = now();
}
}
async function examTick(task, snap, page) {
if (config.examPaused) { taskState(task, 'waiting', '考试任务已局部暂停'); return; }
if (!['exam', 'paper'].includes(page.kind)) {
const node = resolveNode(task, snap);
if (node && !task.examLinkClickedAt) {
task.examLinkClickedAt = now(); taskState(task, 'loading', '通过课程目录的原始关联考试入口打开考试'); await persist();
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, targetURL: '', title: task.title, accountId: account(snap, page), expires: now() + 60000 });
node.click();
} else if (task.examLinkClickedAt && now() - task.examLinkClickedAt > 15000) taskState(task, 'waiting', '关联考试入口未进入考试,请核对网站限制');
return;
}
task.preflight = preflightTopic(snap.document, snap, config.associations, task);
if (task.preflight.status !== 'matched') { taskState(task, task.preflight.status === 'missing' ? 'skipped' : 'waiting', task.preflight.reason); return; }
if (page.kind === 'exam') {
const blockers = Array.from(snap.document.querySelectorAll('.exam_qrcode_box,[data-auth-required],.ant-modal-wrap')).filter(node => visible(node) && /人脸|身份验证|考前须知|登录/.test(clean(node.textContent)));
if (blockers.length) { taskState(task, 'waiting', '考试需要身份验证或阅读须知,请人工完成后继续'); return; }
if (task.enterClickedAt) { if (now() - task.enterClickedAt > 15000) taskState(task, 'waiting', '开考页面未进入试卷,请人工检查限制或提示'); return; }
const buttons = Array.from(snap.document.querySelectorAll('.examdetail_button')).filter(node => visible(node) && !node.matches('[disabled],[aria-disabled="true"]') && /^(?:开始答题|继续答题|开始考试|继续考试)$/.test(clean(node.textContent)));
if (buttons.length !== 1) { taskState(task, 'waiting', '找不到唯一有效的正常开考入口,请人工进入试卷'); return; }
task.enterClickedAt = now(); taskState(task, 'loading', '主题预检通过,通过网页正常按钮进入考试'); await persist();
await storage.set('transition.v1', { sessionId: session.id, taskId: task.id, workerNonce: worker && worker.nonce || '', sourceURL: page.url, targetURL: '', title: task.title, accountId: account(snap, page), expires: now() + 60000 });
buttons[0].click(); return;
}
if (!task.examRequested || task.examPage !== page.kind) {
task.examPage = page.kind; taskState(task, 'answering', '考试主题已确认,交给答题与交卷控制器');
if (typeof options.onExamStart === 'function') {
const accepted = await options.onExamStart({ task: clone(task), preflight: clone(task.preflight), document: snap.document, page: page.kind, config: clone(config) });
task.examRequested = accepted !== false;
if (accepted === false) { if (!task.readyWaitAt) task.readyWaitAt = now(); if (now() - task.readyWaitAt > 15000) taskState(task, 'waiting', '试卷未完整加载,请人工核对后继续'); }
}
else taskState(task, 'waiting', '考试控制器尚未接入,请人工处理');
}
const exam = typeof options.getExamState === 'function' ? options.getExamState() : null;
if (exam) {
state.exam = Object.assign({ taskId: task.id, title: task.title }, clone(exam));
state.exam.total = exam.total == null && exam.details ? exam.details.length : exam.total;
state.exam.verified = exam.verifiedCount == null ? exam.verified : exam.verifiedCount;
const stage = exam.stage || exam.status;
if (stage === 'completed' || stage === 'submitted' || exam.submitted === true && exam.confirmed === true) { task.progress = 1; taskState(task, 'completed', '网站已确认本场考试提交完成'); activeTaskId = ''; await release(); }
else if (['waiting', 'paused', 'failed', 'unknown', 'awaitingAttempt'].includes(stage)) taskState(task, 'waiting', exam.reason || exam.message || '考试等待人工处理');
else if (stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间');
}
}
async function parallelTick(snap, page) {
if (config.execution !== 'parallel' || worker || !session) return;
const local = state.tasks.find(t => t.id === activeTaskId); const opposite = local && local.kind === 'exam' ? 'video' : 'exam';
for (const t of state.tasks) {
const checkpoint = await storage.get('task:' + session.id + ':' + t.id);
if (checkpoint) Object.assign(t, checkpoint);
if (t.workerNonce) {
const status = await storage.get('worker:' + t.workerNonce);
if (status && status.confirmedAt && status.owner && (!status.heartbeatAt || now() - status.heartbeatAt < LEASE_MS)) {
if (status.video && (!local || local.kind !== 'video')) state.video = clone(status.video);
if (status.exam && (!local || local.kind !== 'exam')) state.exam = clone(status.exam);
}
}
}
const requested = state.tasks.find(t => t.workerNonce && !TERMINAL.includes(t.status));
if (requested) {
const request = await storage.get('worker:' + requested.workerNonce);
if (request && request.confirmedAt && request.owner && (!request.heartbeatAt || now() - request.heartbeatAt < LEASE_MS)) {
requested.status = request.status || 'loading'; state.message = '并行执行器已确认启动:最多一个视频和一场考试';
if (request.video && (!local || local.kind !== 'video')) state.video = clone(request.video);
if (request.exam && (!local || local.kind !== 'exam')) state.exam = clone(request.exam);
}
else if (!request || request.expires <= now()) { taskState(requested, 'waiting', '另一个页面未确认启动,可能被浏览器拦截;请打开任务后继续'); }
else state.message = '正在等待另一执行页面确认,尚未开始并行';
return;
}
if (!local || !['video', 'exam'].includes(local.kind)) return;
const next = state.tasks.find(t => t.kind === opposite && t.status === 'pending' && allows(t.kind) && !t.locked && (!t.requiresParent || !t.parentId || state.tasks.find(p => p.id === t.parentId && p.status === 'completed')));
if (!next) return;
if (next.kind === 'exam') { next.preflight = preflightTopic(snap.document, snap, config.associations, next); if (next.preflight.status !== 'matched') { taskState(next, next.preflight.status === 'missing' ? 'skipped' : 'waiting', next.preflight.reason); return; } }
// Angular目录没有href时,先打开已经访问过的真实课程URL,再在工作页点击原目录节点。
const targetURL = next.url || next.selector && next.pageUrl || '';
if (!targetURL || !safeURL(targetURL, snap.url)) { taskState(next, 'waiting', '并行任务缺少网页真实链接或可恢复目录,请在另一页面打开对应任务'); return; }
if (typeof options.openWorker !== 'function') { taskState(next, 'waiting', '当前环境不能创建受控执行页面,请人工打开任务'); return; }
const nonce = token(); const url = new URL(targetURL); url.hash = (url.hash ? url.hash.slice(1) + '&' : '') + '__yian_worker=' + nonce;
next.workerNonce = nonce; next.status = 'loading';
await storage.set('worker:' + nonce, { nonce, sessionId: session.id, taskId: next.id, url: canonical(targetURL), expires: now() + 30000 }); await persist();
try { const opened = await options.openWorker(url.href); if (opened === false || opened === null) taskState(next, 'waiting', '并行页面被浏览器拦截,请人工打开任务'); else log('info', '已请求打开执行页面,等待其心跳确认', next); }
catch (_) { taskState(next, 'waiting', '并行页面打开失败,请人工处理'); }
}
async function tick() {
if (destroyed) return getState();
// 课中题异步处理期间仍响应另一标签页的全局暂停,不能等整段答题结束。
if (busy) {
if (await observeControl()) return getState();
const task = state.tasks.find(t => t.id === activeTaskId);
if (state.status === 'running' && task && !await heartbeat(task)) {
// 弹题等待中的旧异步流程必须立即失效,getActive 随状态变更阻止后续提交。
generation++; state.status = 'paused'; sharedPaused = false;
state.message = '当前任务租约已失效或被另一执行器领取,旧答题流程已暂停';
if (session) session.status = 'paused';
const snap = snapshot(); const page = snap.document && describePage(snap.document, snap.url);
if (page && page.video) page.video.pause();
if (state.video) state.video.status = 'waiting';
if (options.onExamPause) options.onExamPause(state.message);
taskState(task, 'waiting', state.message); await release(); notify();
}
return getState();
}
busy = true; const epoch = generation;
try {
await initialize(); const snap = snapshot(); if (!snap.document) return getState();
const page = describePage(snap.document, snap.url);
if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); }
if (session && (state.status === 'running' || sharedPaused)) {
const controlValue = await storage.get(CONTROL_KEY);
if (await observeControl(controlValue)) return getState();
if (worker && controlValue && controlValue.config) Object.assign(config, controlValue.config);
if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) { await pause('账号或题库版本改变,已暂停旧任务'); return getState(); }
}
if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); }
let task = state.tasks.find(t => t.id === activeTaskId);
if (task && task.kind === 'course' && page.kind === 'course' || task && task.kind === 'course' && page.kind === 'video') {
if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续');
else { mergeTasks(discoverTasks(snap.document, snap.url), task); taskState(task, 'expanded', '已读取课程目录;课件须逐一等待平台确认完成'); activeTaskId = ''; await release(); task = null; }
} else mergeTasks(discoverTasks(snap.document, snap.url));
if (state.status !== 'running') { notify(); return getState(); }
if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; }
if (!task) {
const candidates = state.tasks.filter(t => t.status === 'pending' && allows(t.kind) && (!worker || t.id === worker.taskId));
// 当前页面的实际课件优先;顺序执行先完成可用视频,再处理关联考试。
function rank(t) { return (canonical(t.pageUrl) === page.url ? 0 : 10) + (t.kind === 'video' ? 0 : t.kind === 'exam' ? 1 : 2); }
candidates.sort((a, b) => rank(a) - rank(b));
for (const next of candidates) { if (await activate(next, snap, page)) { task = next; break; } }
}
if (task && epoch === generation) {
const held = await heartbeat(task) || await claim(task);
if (!held) state.message = '等待同类任务租约释放,暂不操作当前页面';
else if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch);
else if (task.status !== 'waiting' && task.kind === 'exam') await examTick(task, snap, page);
}
if (epoch === generation) await parallelTick(snap, page);
if (worker && session) { const request = await storage.get('worker:' + worker.nonce); if (request) { const own = task || state.tasks.find(t => t.id === worker.taskId); request.heartbeatAt = now(); request.status = own && own.status || 'idle'; request.video = state.video; request.exam = state.exam; await storage.set('worker:' + worker.nonce, request); } }
const remaining = state.tasks.filter(t => allows(t.kind) && !TERMINAL.includes(t.status));
if (!task && !remaining.length) { state.status = state.tasks.length ? 'finished' : 'waiting'; state.message = state.tasks.length ? '本轮调度结束;请检查完成、跳过及失败清单' : '当前页面没有可识别的任务,请打开学习地图、课程或试卷'; if (session) session.status = state.status; await control(state.status); }
else if (!task && remaining.every(t => t.status === 'waiting')) { state.message = '剩余任务等待人工处理;确认后点击继续'; }
await persist(); notify(); return getState();
} catch (error) { state.message = '学习调度异常:' + error.message; state.status = 'paused'; if (session) session.status = 'paused'; await control('paused'); await release(); log('error', state.message); return getState(); }
finally { busy = false; }
}
async function observeControl(value) {
if (!session || state.status !== 'running' && !sharedPaused) return false;
const shared = value === undefined ? await storage.get(CONTROL_KEY) : value;
if (sharedPaused && shared && shared.sessionId === session.id && shared.status === 'running') {
sharedPaused = false; generation++; state.status = 'running'; session.status = 'running'; state.message = '已收到继续全部任务,重新核验当前页面'; playStarted = false; manualVideoPause = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now();
const active = state.tasks.find(t => t.id === activeTaskId); if (active) { active.examRequested = false; if (active.status === 'waiting') active.status = 'pending'; }
notify(); return false;
}
if (!shared || shared.sessionId === session.id && shared.status === 'running') return false;
if (state.status !== 'running') return false;
generation++; state.status = shared.status === 'stopped' && shared.sessionId === session.id ? 'stopped' : 'paused'; sharedPaused = shared.sessionId === session.id && shared.status === 'paused';
state.message = shared.sessionId !== session.id ? '另一页面已开始新一轮任务,旧执行器已停止' : '全局任务已暂停;网站考试倒计时仍会继续';
const snap = snapshot(); const page = snap.document && describePage(snap.document, snap.url); if (page && page.video) page.video.pause();
if (options.onExamPause) options.onExamPause(state.message); await release(); notify(); return true;
}
function getState() { return clone(state); }
function clearLogs() { state.logs = []; notify(); persist().catch(() => {}); }
function destroy() {
destroyed = true; generation++;
if (watchedDocument) { watchedDocument.removeEventListener('pointerdown', onInteraction, true); watchedDocument.removeEventListener('keydown', onInteraction, true); watchedDocument.removeEventListener('pause', onVideoPause, true); }
if (typeof options.onExamPause === 'function') options.onExamPause('学习控制器已退出'); release().catch(() => {});
}
return { configure, start, pause, resume, stop, tick, getState, destroy, log, clearLogs };
}
return { create, describePage, discoverTasks, preflightTopic };
});
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "yianxue-question-bank-exporter",
"version": "2.0.3",
"version": "2.1.0",
"private": true,
"description": "谊安学站点助手:可拖动面板、岗位搜索、答案全文核对及自动选择",
"description": "谊安学助手:统一题库、六栏面板、视频任务与考试全文核验",
"scripts": {
"build": "node build-userscript.mjs",
"merge:banks": "node scripts/merge-question-banks.cjs",
+148 -20
View File
@@ -14,8 +14,15 @@
const emptyMetrics = () => ({ selected: 0, pending: 0, missing: 0, conflict: 0, locked: 0, failed: 0, unsupported: 0, total: 0 });
let metrics = emptyMetrics();
let positionRevision = 0;
let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle';
let actionEpoch = 0;
let learningSettings = { mode: 'videoQuiz', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, associations: {} };
const inScope = () => YianSiteScope.isAllowedURL(location.href);
function topicPreflight() {
return window.YianLearningEngine.preflightTopic(document, location.href, snapshot, learningSettings.associations || {});
}
function visible(node) {
if (!node || node.closest('[hidden],.hidden')) return false;
const style = getComputedStyle(node);
@@ -61,29 +68,47 @@
const route = routeInfo();
const bank = route.root ? core.extract(document, route.sourceUrl) : { title: '', questions: [] };
const signature = route.root ? structureSignature(bank) : '';
const topic = route.mode === 'test' ? topicPreflight() : null;
return { ready: inScope() && !!page && page.ready && !loading && page.key === route.key
&& page.root === route.root && page.signature === signature,
practice: route.practice, exam: route.exam, mode: route.mode, local: route.local, role: config.role, course: bank.title,
sourceUrl: route.sourceUrl, routeKey: route.key, root: route.root,
pageSignature: signature, revision: snapshot.revision, snapshot };
pageSignature: signature, revision: snapshot.revision, snapshot, submitted: route.submitted, expired: route.expired,
courses: topic && topic.status === 'matched' ? topic.courses : undefined,
topicKey: topic ? JSON.stringify([topic.status, topic.courses, learningSettings.associations]) : '' };
}
const runner = window.YianAnswerRunner.create({ getContext,
match: (question, context) => matcher.match(question, context, snapshot),
getInterval: () => learningSettings.answerInterval,
onProgress: progress => { metrics = progress; panel.setState({ metrics }); },
onState: (state, reason) => {
if (disposed) return;
runnerState = state;
if (state === 'paused') { config.autoAnswer = false; runKey = ''; panel.setState({ autoAnswer: false }); }
if (state === 'paused') { examController?.disable(reason); learning?.configure({ examPaused: true }); }
if (reason && (state === 'paused' || state === 'complete')) panel.setNotice(reason, state === 'paused' ? 'warning' : 'success');
}
});
function courses() {
return Array.from(new Set(snapshot.records.filter(record => record.role === config.role).map(record => record.course))).sort();
return Array.from(new Set(snapshot.records.map(record => record.course))).sort();
}
function renderLearning() {
if (!learning) return;
const video = learningState.video ? Object.assign({}, learningState.video, { learnedTime: learningState.video.learnedSeconds,
isLearned: learningState.video.platformComplete, refreshCount: learningState.video.retryCount }) : null;
const route = routeInfo();
const currentExam = route.mode === 'test' && route.root ? Object.assign({}, learningState.exam || {}, examState) : learningState.exam || {};
panel.setState({ learning: Object.assign({}, learningSettings, learningState, {
video, exam: Object.assign({}, currentExam, { status: currentExam.stage || currentExam.status,
submitWaitSeconds: currentExam.waitSeconds }), associations: learningSettings.associations || {},
currentTitle: window.YianLearningEngine.describePage(document, location.href).title }) });
}
function refreshPanel(extra) {
panel.setState(Object.assign({ autoAnswer: config.autoAnswer, allowCopy: config.allowCopy,
exportEnabled: config.exportEnabled, position: config.panelPosition, role: config.role, course: config.course, courses: courses(),
bankTotal: snapshot.total, roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
bankTotal: snapshot.total, uniqueTotal: snapshot.questionTotal, conflictTotal: snapshot.conflictTotal,
roleCounts: snapshot.counts, storageMode: snapshot.storage, loading, metrics }, extra || {}));
}
function performSearch(query = searchQuery, options = {}) {
searchQuery = String(query || '');
@@ -91,7 +116,6 @@
const result = searchQuery.trim() ? matcher.search(searchQuery,
{ role: config.role, course: config.course, limit: 20, offset }, snapshot) : { items: [], total: 0 };
panel.renderSearch(Object.assign({}, result, { offset }));
if (!config.role && !options.quiet) panel.setNotice('请先选择主操、班长或副操。', 'warning');
return result;
}
async function saveSetting(key, value) {
@@ -99,28 +123,33 @@
catch (error) { panel.setNotice('设置保存失败:' + error.message, 'error'); }
}
async function setRole(role) {
if (!store.roles.includes(role)) throw new Error('请选择主操、班长或副操');
runner.stop('岗位已切换'); config.role = role; config.course = ''; runKey = ''; metrics = emptyMetrics();
refreshPanel(); performSearch(); tick(); await saveSetting('role', role); return role;
// Compatibility for old integrations; a role no longer filters any answers.
config.role = ''; return '';
}
function setAutoAnswer(enabled) {
if (!enabled) actionEpoch++;
const context = getContext();
if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !config.role || !snapshot.counts[config.role])) {
panel.setNotice('请先导入所选岗位题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
if (enabled && (!context.ready || !(context.practice || context.exam) || context.local || !snapshot.total)) {
panel.setNotice('请先导入统一题库,并进入已加载完成的兼容练习或桌面考试页面。', 'warning'); refreshPanel(); return false;
}
if (enabled && context.mode === 'test' && topicPreflight().status !== 'matched') {
panel.setNotice('请先在题库栏确认当前考试标题与题库课程/主题的关联。', 'warning'); return false;
}
config.autoAnswer = !!enabled; runKey = '';
if (!enabled) runner.stop('自动选择已关闭,已生效的选择保留。');
if (!enabled) { runner.stop('自动选择已关闭,已生效的选择保留。'); examController?.disable('自动选择已关闭'); }
refreshPanel(); tick(); return config.autoAnswer;
}
function setCopy(enabled) {
config.allowCopy = !!enabled; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); saveSetting('allowCopy', config.allowCopy);
}
async function importFiles(files) {
examController?.disable('题库正在更新');
if (learning) await learning.pause();
runner.stop('正在更新题库'); loading = true; runKey = ''; refreshPanel(); panel.setNotice('正在解析题库,请稍候…');
try {
snapshot = await store.importFiles(Array.from(files || []), { role: config.role });
metrics = emptyMetrics(); config.course = '';
panel.setNotice('题库导入成功:' + snapshot.total + ' 条记录。请选择岗位后搜索或自动选择。', 'success');
panel.setNotice('统一题库导入成功:' + snapshot.total + ' 条来源记录,' + snapshot.questionTotal + ' 道题,' + snapshot.conflictTotal + ' 道存在答案冲突。', 'success');
return snapshot;
} catch (error) {
panel.setNotice('导入失败:' + error.message + '。原有题库保留。', 'error'); throw error;
@@ -154,6 +183,97 @@
onSelectionSearch: text => performSearch(text),
onExport: format => { try { exportCurrent(format); } catch (error) { panel.setNotice(error.message, 'error'); } },
onExportEnabledChange: setExportEnabled
,onTaskAction: action => { taskAction(action).catch(error => panel.setNotice(error.message, 'error')); },
onLearningSetting: (key, value) => { changeLearningSetting(key, value).catch(error => panel.setNotice(error.message, 'error')); },
onLogAction: action => {
if (action === 'clear') learning?.clearLogs?.();
if (action === 'copy') {
const text = (learningState.logs || []).map(item => new Date(item.time).toLocaleTimeString() + ' [' + item.level + '] ' + item.message).join('\n');
if (typeof GM_setClipboard === 'function') GM_setClipboard(text); else navigator.clipboard?.writeText(text).catch(() => {});
}
}
});
async function changeLearningSetting(key, value) {
const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh'];
if (!allowed.includes(key)) return;
if (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].includes(value)) throw new Error('请选择有效任务模式');
if (key === 'execution' && !['sequential', 'parallel'].includes(value)) throw new Error('请选择顺序或并行');
const ranges = { rate: [1, 2], answerInterval: [0, 120], minSubmitMinutes: [0.5, 180], stallSeconds: [1, 600], retryDelaySeconds: [1, 120], maxRefresh: [1, 3] };
if (ranges[key]) {
const range = ranges[key]; value = Number(value);
if (!Number.isFinite(value) || value < range[0] || value > range[1] || key === 'maxRefresh' && !Number.isInteger(value)) {
throw new Error('设置数值须在 ' + range[0] + '~' + range[1] + ' 范围内');
}
}
if (key === 'associations' && (!value || typeof value !== 'object' || Array.isArray(value)
|| Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) {
throw new Error('主题关联须使用完整标题及题库课程名称列表');
}
if (['mode', 'execution', 'associations'].includes(key)) {
actionEpoch++;
config.autoAnswer = false; runner.stop('执行模式或主题关联变更'); examController.disable('设置变更,须重新启动核验');
await learning.pause();
}
learningSettings = Object.assign({}, learningSettings, { [key]: value });
await learning.configure({ [key]: value });
await saveSetting('learningSettings', learningSettings);
refreshPanel(); renderLearning();
}
async function taskAction(action) {
actionEpoch++;
await ready;
if (!inScope()) return;
if (action === 'start' || action === 'resume') {
if (action === 'start') await learning.start(); else await learning.resume();
} else if (action === 'pause' || action === 'stop') {
config.autoAnswer = false; runner.stop('用户暂停或停止任务'); examController.disable('用户暂停或停止任务');
if (action === 'pause') await learning.pause(); else await learning.stop();
} else if (action === 'videoPause' || action === 'videoResume') await learning.configure({ videoPaused: action === 'videoPause' });
else if (action === 'examPause') { await learning.configure({ examPaused: true }); setAutoAnswer(false); }
else if (action === 'examResume') {
await learning.configure({ examPaused: false });
const epoch = actionEpoch;
if (await examController.enable() && epoch === actionEpoch) setAutoAnswer(true);
}
renderLearning();
}
const learningStorage = { get: (key, fallback) => store.getSetting('learning:' + key, fallback), set: (key, value) => store.setSetting('learning:' + key, value) };
examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot,
getTopic: topicPreflight, getSettings: () => learningSettings, getRunnerState: () => runnerState,
storage: learningStorage, onLog: (level, message) => learning?.log(level, message),
onState: state => { examState = state; renderLearning(); } });
learning = window.YianLearningEngine.create({
getSnapshot: () => ({ document, url: location.href, courses: courses(), bank: snapshot,
bankVersion: snapshot.revision, accountId: document.querySelector('[data-user-id]')?.getAttribute('data-user-id') || '', planId: '' }),
getExamState: () => examController.getState(),
onExamStart: async () => {
const epoch = actionEpoch;
if (await examController.enable() && epoch === actionEpoch && learning.getState().status === 'running'
&& !learning.getState().examPaused) return setAutoAnswer(true);
return false;
},
onExamPause: reason => { setAutoAnswer(false); examController.disable(reason); },
solveQuiz: async (_popup, quizContext) => {
const revision = snapshot.revision;
const mode = learningSettings.mode;
const epoch = actionEpoch;
const value = await window.YianQuizAdapter.solve({ document, snapshot, matcher,
context: { course: window.YianLearningEngine.describePage(document, location.href).title,
sourceUrl: location.href, answerInterval: quizContext.answerInterval },
getActive: () => epoch === actionEpoch && !disposed && inScope() && learning.getState().status === 'running'
&& !learning.getState().videoPaused && mode === learningSettings.mode && revision === snapshot.revision });
return Object.assign({}, value, { status: value.status === 'complete' ? 'completed' : value.status });
},
storage: learningStorage,
navigate: url => { if (YianSiteScope.isAllowedURL(url)) location.assign(url); else throw new Error('任务链接不在脚本范围内'); },
reload: () => location.reload(),
openWorker: url => {
if (!YianSiteScope.isAllowedURL(url)) throw new Error('任务链接不在脚本范围内');
if (typeof GM_openInTab === 'function') return GM_openInTab(url, { active: false, insert: true, setParent: true });
const child = window.open(url, '_blank'); if (!child) throw new Error('浏览器阻止并行标签页,请允许打开后重试'); return child;
},
onState: state => { learningState = state; renderLearning(); }
});
function tick() {
@@ -163,6 +283,7 @@
scopedOut = true;
config.autoAnswer = false; config.exportEnabled = false; runKey = '';
runner.stop('页面已离开脚本生效范围'); copy.setEnabled(false);
examController.disable('页面已离开脚本范围'); learning.pause();
panel.host.style.setProperty('display', 'none', 'important');
previous = page; page = null; metrics = emptyMetrics();
}
@@ -174,6 +295,10 @@
copy.setEnabled(config.allowCopy);
}
const route = routeInfo();
if (!loading) {
examController.tick().catch(error => panel.setNotice(error.message, 'error'));
learning.tick().catch(error => panel.setNotice(error.message, 'error'));
}
if (!route.root) {
if (runner.running) runner.stop('当前页面未识别到练习题目');
if (page) previous = page;
@@ -207,15 +332,15 @@
else if (!bank.total || now - page.since < settleMs) status = '正在读取题目与隐藏答案…';
else if (!countComplete) status = bank.expectedTotal === null ? '等待考试答题卡总题数,确认完整试卷后才能自动选择。'
: '已读取 ' + bank.total + ' / ' + bank.expectedTotal + ' 题,等待全部加载。';
else if (route.exam) status = bank.title + ':' + bank.total + ' 题。按所选岗位题库严格核对题干、选项和正确答案全文。'
+ (route.local ? ' 保存网页只用于核对,自动选择需要在线考试页面。' : '完成后请检查结果并自行交卷。');
else if (route.exam) status = bank.title + ':' + bank.total + ' 题。按统一题库严格核对题干、选项和正确答案全文。'
+ (examController.getState().enabled ? '自动交卷条件已启用,请留意预计得分与倒计时。' : '仅选择答案;通过任务总览启动考试任务后才按条件自动交卷。');
else status = bank.title + ':' + bank.total + ' 题,' + (bank.missingAnswers.length
? '缺少 ' + bank.missingAnswers.length + ' 个网页答案;可用导入题库选择。' : '网页答案齐全。')
+ (route.local ? ' 保存网页支持搜索与导出;自动选择需要在线练习页面。' : '');
if (route.mode === 'test' && !route.exam) { config.autoAnswer = false; runKey = ''; }
refreshPanel({ pageStatus: status, pageMode: route.mode === 'test' ? 'test' : route.practice ? 'exercise' : '',
canExport: route.practice && !!bank.total,
canAnswer: route.answerSupported && !route.local && page.ready && !loading && !!config.role && !!snapshot.counts[config.role] });
canAnswer: route.answerSupported && !route.local && page.ready && !loading && !!snapshot.total });
if (!route.answerSupported || !page.ready || loading) { if (runner.running) runner.stop('等待可作答页面准备完成'); return; }
if (route.practice && config.exportEnabled && bank.complete) {
const key = route.key + '|' + signature;
@@ -224,8 +349,8 @@
catch (error) { config.exportEnabled = false; refreshPanel(); panel.setNotice('自动下载失败:' + error.message, 'error'); }
}
}
const nextRunKey = route.key + '|' + signature + '|' + config.role + '|' + snapshot.revision;
if (config.autoAnswer && config.role && snapshot.counts[config.role] && runKey !== nextRunKey && !runner.running) {
const nextRunKey = route.key + '|' + signature + '|' + snapshot.revision;
if (config.autoAnswer && snapshot.total && runKey !== nextRunKey && !runner.running) {
runKey = nextRunKey;
runner.start().catch(error => { config.autoAnswer = false; refreshPanel(); panel.setNotice('自动选择失败:' + error.message, 'error'); });
}
@@ -241,13 +366,15 @@
function dispose() {
disposed = true; observer.disconnect(); clearInterval(timer); clearTimeout(debounceTimer);
window.removeEventListener('popstate', onNavigation); window.removeEventListener('hashchange', onNavigation);
runner.destroy(); copy.destroy(); panel.destroy();
runner.destroy(); examController.destroy(); learning.destroy(); copy.destroy(); panel.destroy();
}
const ready = (async () => {
const positionRevisionAtRead = positionRevision;
try {
const values = await Promise.all([store.load(), store.getSetting('role', ''), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
snapshot = values[0]; config.role = store.roles.includes(values[1]) ? values[1] : '';
const values = await Promise.all([store.load(), store.getSetting('learningSettings', {}), store.getSetting('allowCopy', true), store.getSetting('panelPosition', null)]);
snapshot = values[0]; config.role = '';
if (snapshot.migration?.status === 'pending') panel.setNotice('旧题库仍可使用,但统一存储迁移尚未保存:' + snapshot.migration.error, 'warning');
learningSettings = Object.assign({}, learningSettings, values[1]); await learning.configure(learningSettings);
config.allowCopy = values[2] !== false;
const position = values[3];
// A drag during asynchronous storage loading wins over the older saved position.
@@ -259,7 +386,8 @@
loading = false; copy.setEnabled(config.allowCopy && inScope()); refreshPanel(); tick(); return snapshot;
})();
const api = Object.freeze({ ready, importFiles, setRole, setAutoAnswer, setCopy, setExportEnabled, search: performSearch, getContext,
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, pageReady: !!page && page.ready }),
setLearningSetting: changeLearningSetting, taskAction, getLearningState: () => learning.getState(), getExamState: () => examController.getState(),
getSnapshot: () => snapshot, getState: () => ({ ...config, metrics, learning: learning.getState(), exam: examController.getState(), pageReady: !!page && page.ready }),
exportCurrent, collectCurrent: () => core.extract(document, routeInfo().sourceUrl), stop: dispose });
window.YianAssistant = api; window.YianQuestionBankExporter = api;
refreshPanel(); tick();
+186
View File
@@ -0,0 +1,186 @@
/* Courseware quiz DOM adapter. Templates verified in saved 5-es2015 bundle:
* app-question-modal -> CDK .question-modal-pc, .question_box/.answer_box.
* doCheck changes checked class; 查看答案 grades; 下一题/关闭 emits continuePlay.
* The saved site's scripts are never evaluated by this module. */
(function (root) {
'use strict';
const jobs = new WeakMap();
const CHECKED = 'examtest_content_list_des_checked';
const OWNER = '.question-modal-pc,.question-modal-mobile,app-question-modal,[data-learning-quiz]';
const clean = value => String(value == null ? '' : value).replace(/[\u200b-\u200d\u2060\uFEFF]/g, '').replace(/\s+/g, ' ').trim();
const same = (left, right) => JSON.stringify(left.map(clean).sort()) === JSON.stringify(right.map(clean).sort());
function visible(node) {
if (!node || !node.isConnected || node.closest('[hidden],[aria-hidden="true"],.hidden,[inert]')) return false;
const view = node.ownerDocument.defaultView;
for (let parent = node; parent && parent.nodeType === 1; parent = parent.parentElement) {
const style = view.getComputedStyle(parent);
if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || style.opacity === '0') return false;
}
return node.getClientRects().length > 0;
}
function known(node) { return Boolean(node.closest(OWNER)); }
function detect(doc) {
const question = Array.from(doc.querySelectorAll('.question_box')).filter(node => known(node) && visible(node));
const answer = Array.from(doc.querySelectorAll('.answer_box')).filter(node => known(node) && visible(node));
if (question.length > 1 || answer.length > 1 || question.length && answer.length) return { ambiguous: true };
if (answer.length) return { kind: 'portalAnswer', container: answer[0] };
if (question.length) return { kind: 'portal', container: question[0] };
const legacy = Array.from(doc.querySelectorAll('.pc_examtest_content')).filter(node => known(node) && visible(node));
if (legacy.length > 1) return { ambiguous: true };
if (legacy.length) return { kind: 'legacy', container: legacy[0] };
return null;
}
function read(view, context) {
const box = view.container;
if (box.querySelector('img,svg,canvas,video,audio,iframe,math')) return { error: '课中题含图片或非文字内容,需要人工核对' };
let question, nodes;
if (view.kind === 'portal') {
const stems = Array.from(box.querySelectorAll('.examtest_content_question > p')).filter(visible);
if (stems.length !== 1) return { error: '课中题题干无法唯一识别' };
const stem = clean(stems[0].textContent);
const type = stem.match(/^[\[【](单选|多选|判断)(?:题)?[\]】]\s*/);
if (!type) return { error: '课中题缺少明确支持的题型' };
nodes = Array.from(box.querySelectorAll('.examtest_content_list_des')).filter(visible);
const options = nodes.map(node => {
const labels = node.querySelectorAll('.examtest_content_list_des_optionsort');
if (labels.length !== 1) return null;
const label = clean(labels[0].textContent).toUpperCase().replace(/[..、::))]$/, '');
const copy = node.cloneNode(true); copy.querySelector('.examtest_content_list_des_optionsort').remove();
return { label, text: clean(copy.textContent) };
});
if (options.some(option => !option || !/^[A-H]$/.test(option.label) || !option.text)) return { error: '课中题选项文字或字母无法完整识别' };
question = { type: type[1], question: stem.slice(type[0].length), options, number: 1 };
} else {
if (!root.QuestionBankCore) return { error: '课中题提取组件未加载' };
const wrapper = box.ownerDocument.createElement('div'); wrapper.append(box.cloneNode(true));
const bank = root.QuestionBankCore.extract(wrapper, context.sourceUrl);
if (bank.questions.length !== 1) return { error: '课中题结构尚未完整加载' };
question = bank.questions[0]; nodes = Array.from(box.querySelectorAll(root.QuestionBankCore.selectors.option));
}
if (!question.question || nodes.length !== question.options.length || nodes.length < 2 || nodes.length > 8) return { error: '课中题题干及选项尚未完整加载' };
return { question, nodes, signature: JSON.stringify([question.type, question.question, question.options.map(option => [option.label, option.text])]) };
}
function buttons(container, expression) {
return Array.from(container.querySelectorAll('.modal-footer button,.pc_examtest_content_question_showanswer,button[data-quiz-submit]'))
.filter(node => visible(node) && !node.disabled && node.getAttribute('aria-disabled') !== 'true' && expression.test(clean(node.textContent)));
}
async function run(options) {
const doc = options.document || root.document, matcher = options.matcher || root.YianAnswerMatcher;
const context = options.context || {};
if (!doc || !matcher || typeof matcher.match !== 'function') return { status: 'waiting', verified: false, reason: '课中题匹配组件未就绪' };
let cancelled = false, verifiedCount = 0;
const timeout = Math.max(100, Math.min(15000, Number(options.timeoutMs) || 6000));
const configured = Number(context.answerInterval);
const interval = Math.max(0, Math.min(120, Number.isFinite(configured) ? configured : 4)) * 1000;
const result = (status, reason, extra) => Object.assign({ status, verified: status === 'complete', verifiedCount, reason }, extra || {});
const active = () => {
if (cancelled || options.signal?.aborted) return false;
try { return typeof options.getActive !== 'function' || options.getActive() === true; } catch (_) { return false; }
};
const manual = event => {
if (!event.isTrusted) return;
const node = event.target?.nodeType === 1 ? event.target : event.target?.parentElement;
if (node && known(node) && node.closest('.question_box,.answer_box,.pc_examtest_content')) cancelled = true;
};
doc.addEventListener('click', manual, true);
const pause = milliseconds => new Promise(resolve => {
const started = Date.now();
const step = () => {
if (!active()) return resolve(false);
const remaining = milliseconds - (Date.now() - started);
if (remaining <= 0) return resolve(true);
root.setTimeout(step, Math.min(100, remaining));
};
step();
});
const waitFor = async predicate => {
for (let waited = 0; waited <= timeout; waited += 100) {
if (!active()) return { cancelled: true };
const value = predicate(); if (value) return value;
if (waited < timeout && !await pause(100)) return { cancelled: true };
}
return null;
};
const click = node => { if (!active() || !visible(node) || node.disabled || node.getAttribute('aria-disabled') === 'true') return false; node.click(); return true; };
try {
let view = detect(doc);
if (!view || view.ambiguous || view.kind === 'portalAnswer') return result('waiting', '未发现唯一且可完整核验的课中题,等待人工检查');
for (let questionIndex = 0; questionIndex < 100; questionIndex++) {
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
const original = read(view, context);
if (original.error) return result('waiting', original.error);
const matched = await matcher.match(original.question, context, options.snapshot);
if (!active()) return result('waiting', '课中答题已取消,等待重新启动');
if (!matched || matched.status !== 'matched') return result(['missing', 'conflict'].includes(matched?.status) ? matched.status : 'unsupported', matched?.reason || '统一题库没有唯一准确答案');
if (!Array.isArray(matched.correctTexts) || !matched.correctTexts.length || !same(matched.correctTexts, original.question.options.filter(option => matched.correctLabels?.includes(option.label)).map(option => option.text))) return result('waiting', '正确答案全文不能对应当前选项,已停止');
const current = () => {
const seen = detect(doc);
if (!seen || seen.ambiguous || seen.container !== view.container || seen.kind !== view.kind) return null;
const latest = read(seen, context); return !latest.error && latest.signature === original.signature ? latest : null;
};
const wanted = original.question.options.map(option => matched.correctTexts.some(text => clean(text) === clean(option.text)));
const plan = [];
original.nodes.forEach((node, i) => { if (!wanted[i] && node.classList.contains(CHECKED)) plan.push({ i, checked: false }); });
original.nodes.forEach((node, i) => { if (wanted[i] && !node.classList.contains(CHECKED)) plan.push({ i, checked: true }); });
if (original.question.type !== '多选') {
if (wanted.filter(Boolean).length !== 1) return result('waiting', '单选或判断题正确答案数量不明');
plan.splice(0, plan.length, ...(original.nodes.every((node, i) => node.classList.contains(CHECKED) === wanted[i]) ? [] : [{ i: wanted.indexOf(true), checked: true }]));
}
for (const step of plan) {
const latest = current();
if (!latest || !click(latest.nodes[step.i])) return result('waiting', '题目或任务状态已变化,停止课中答题');
const changed = await waitFor(() => { const next = current(); return next && next.nodes[step.i].classList.contains(CHECKED) === step.checked ? next : null; });
if (!changed || changed.cancelled) return result('waiting', '网站未确认选中状态或任务已取消');
if (!await pause(150)) return result('waiting', '课中答题已取消');
}
const evidence = current();
if (!evidence || !same(evidence.question.options.filter((_, i) => evidence.nodes[i].classList.contains(CHECKED)).map(option => option.text), matched.correctTexts)) return result('waiting', '课中题实际选中集合与正确答案全文不一致');
const submits = buttons(view.container, /^(查看答案|提交答案|确认答案)$/);
if (submits.length !== 1 || !current() || !click(submits[0])) return result('waiting', '课中题提交入口无法唯一确认');
// The real template replaces the question with an answer overlay immediately;
// absence of the question alone does not imply that the grade request succeeded.
const graded = await waitFor(() => {
const next = detect(doc);
if (next?.ambiguous) return { ambiguous: true };
if (next?.kind === 'portalAnswer') {
const grade = clean(next.container.querySelector('.model_dec')?.textContent);
if (/回答错误/.test(grade)) return { wrong: true };
if (/回答正确/.test(grade)) return next;
}
if (view.kind === 'legacy' && !next && !view.container.isConnected) return { closed: true };
return null;
});
if (!graded || graded.cancelled || graded.ambiguous || graded.wrong) return result('waiting', graded?.wrong ? '网站反馈课中题回答错误,等待人工核对' : '网站尚未确认课中题结果,等待人工核对');
verifiedCount++;
if (graded.closed) return result('complete', '答案全文核验通过,正常提交后弹题已关闭');
const advances = buttons(graded.container, /^(下一题|关闭)$/);
const isLast = advances.length === 1 && clean(advances[0].textContent) === '关闭';
if (advances.length !== 1 || !click(advances[0])) return result('waiting', '课中题已判定正确,但下一题或关闭按钮无法唯一确认');
const next = await waitFor(() => {
const seen = detect(doc);
// CDK detach/attach can briefly render neither overlay between questions.
// Only the actual final 关闭 control authorizes completion by disappearance.
if (!seen) return isLast ? { closed: true } : null;
if (seen.ambiguous) return { ambiguous: true };
if (seen.kind !== 'portalAnswer' && seen.container !== graded.container) return seen;
return null;
});
if (!next || next.cancelled || next.ambiguous) return result('waiting', '课中题已判定正确,等待网页关闭弹题或切到下一题');
if (next.closed) return result('complete', '课中题均核验正确,网站已关闭弹题');
if (!await pause(interval)) return result('waiting', '课中答题已取消');
view = next;
}
return result('waiting', '本组课中题数量超过适配上限,请人工核对');
} catch (error) { return result('waiting', '课中题处理失败:' + String(error.message || error)); }
finally { doc.removeEventListener('click', manual, true); }
}
function solve(options) {
options = options || {}; const doc = options.document || root.document;
if (!doc) return Promise.resolve({ status: 'waiting', verified: false, reason: '当前页面未就绪' });
if (jobs.has(doc)) return jobs.get(doc);
const job = run(options).finally(() => { if (jobs.get(doc) === job) jobs.delete(doc); });
jobs.set(doc, job); return job;
}
root.YianQuizAdapter = { solve };
if (typeof module !== 'undefined' && module.exports) module.exports = root.YianQuizAdapter;
})(typeof window !== 'undefined' ? window : globalThis);
+46 -16
View File
@@ -1,4 +1,4 @@
/* Merge each role's CSV files without losing course-specific question records. */
/* Build one bank with every original source cell and validate its import identity. */
'use strict';
const fs = require('node:fs');
const path = require('node:path');
@@ -32,10 +32,7 @@ function csv(rows) {
return '\uFEFF' + rows.map(row => row.map(cell).join(',')).join('\r\n') + '\r\n';
}
function comparable(records) {
// sourceFile changes to the merged filename on CSV import; all answer data must remain identical.
return Array.from(records, ({ sourceFile, ...record }) => JSON.stringify(record)).sort();
}
function comparable(records) { return Array.from(records, record => JSON.stringify(record)).sort(); }
async function main() {
const inputPath = path.resolve(process.argv[2] || path.join(rootDir, '题库.zip'));
@@ -48,6 +45,7 @@ async function main() {
if (!entries.length) throw new Error('ZIP 内没有 CSV 题库');
const parser = runtime();
const groups = new Map(roles.map(role => [role, { files: [], rows: [] }]));
const originalCells = [];
const headers = ['岗位', '来源文件', ...originalHeaders];
for (const entry of entries) {
const role = entry.name.replace(/\\/g, '/').split('/').find(part => roles.includes(part));
@@ -62,35 +60,67 @@ async function main() {
group.files.push({ name: entry.name, total: rows.length - 1 });
for (const [index, row] of rows.slice(1).entries()) {
if (row.length !== sourceHeaders.length) throw new Error(entry.name + ' 第 ' + (index + 2) + ' 条记录列数不匹配');
group.rows.push({ 岗位: role, 来源文件: entry.name, ...Object.fromEntries(sourceHeaders.map((name, column) => [name, row[column]])) });
const cells = { 岗位: role, 来源文件: entry.name, ...Object.fromEntries(sourceHeaders.map((name, column) => [name, row[column]])) };
group.rows.push(cells);
originalCells.push({ role, sourceFile: entry.name, sourceHeaders, cells: row.slice() });
}
}
// Use the assistant's actual import validator to compare the source ZIP with all three results.
// Keep the previous three files compatible, but recommend the single unified output.
const original = await runtime().importFiles([file(path.basename(inputPath), input)]);
const outputs = roles.map(role => {
const compatibilityOutputs = roles.map(role => {
const group = groups.get(role);
if (!group.rows.length) throw new Error('缺少岗位题库:' + role);
return { role, name: role + '_合并题库.csv', bytes: Buffer.from(csv([headers, ...group.rows.map(row => headers.map(name => row[name] ?? ''))])) };
});
const merged = await runtime().importFiles(outputs.map(output => file(output.name, output.bytes)));
const unifiedRows = roles.flatMap(role => groups.get(role).rows);
const unified = { role: '', name: '统一题库.csv', bytes: Buffer.from(csv([headers, ...unifiedRows.map(row => headers.map(name => row[name] ?? ''))])) };
const outputs = [unified, ...compatibilityOutputs];
const generatedRows = parser.parseCSV(decode(unified.bytes));
const generatedHeaders = generatedRows[0];
const generatedBySource = new Map();
generatedRows.slice(1).forEach(row => {
assert.equal(row.length, generatedHeaders.length, '生成 CSV 的列数不一致');
const role = row[generatedHeaders.indexOf('岗位')], sourceFile = row[generatedHeaders.indexOf('来源文件')];
const key = JSON.stringify([role, sourceFile]);
if (!generatedBySource.has(key)) generatedBySource.set(key, []);
generatedBySource.get(key).push(row);
});
const sourceOffsets = new Map();
for (const originalRow of originalCells) {
const key = JSON.stringify([originalRow.role, originalRow.sourceFile]);
const offset = sourceOffsets.get(key) || 0;
const generatedRow = generatedBySource.get(key)?.[offset];
assert(generatedRow, '生成 CSV 缺少来源行:' + originalRow.sourceFile);
originalRow.sourceHeaders.forEach((name, column) => {
assert.equal(generatedRow[generatedHeaders.indexOf(name)], originalRow.cells[column], originalRow.sourceFile + ' 原字段改变:' + name + ' 第 ' + (offset + 1) + ' 行');
});
sourceOffsets.set(key, offset + 1);
}
const merged = await runtime().importFiles([file(unified.name, unified.bytes)]);
const sourceRowCount = roles.reduce((total, role) => total + groups.get(role).rows.length, 0);
assert.equal(merged.total, sourceRowCount, '合并后导入丢失了题目记录');
const originalRecords = comparable(original.records);
assert(comparable(merged.records).every((record, index) => record === originalRecords[index]), '合并前后题干、选项、答案或课程来源发生变化');
assert.deepEqual(comparable(merged.records), originalRecords, '合并前后题干、选项、答案、原岗位或课程来源发生变化');
const compatible = await runtime().importFiles(compatibilityOutputs.map(output => file(output.name, output.bytes)));
assert.deepEqual(comparable(compatible.records), originalRecords, '兼容文件与统一文件的数据不一致');
const report = [
'# 按岗位合并题库', '',
'由原始 `题库.zip` 中各岗位的 CSV 合并生成。三个文件均可在“谊安学助手 2.0”的“导入题库”中直接选择,也可同时导入;文件内已有岗位字段,无需靠当前岗位选择归类。', '',
'# 统一题库', '',
'推荐下载并导入 [统一题库.csv](统一题库.csv)。由原始 `题库.zip` 的 28 个 CSV 合成,共 5520 条来源记录,主操、副操、班长共用检索和答案匹配,不再需要选择岗位。原岗位仅作为来源信息保留。', '',
'导入后的相同题型、题干、完整选项及答案会聚合展示,并保留全部来源;不同答案分别展示并标记冲突,自动答题不会多数决。可以通过明确的课程/来源信息消歧。', '',
'统一题库共有 ' + merged.uniqueTotal + ' 个题目答案组合、' + merged.questionTotal + ' 道唯一题目,其中 ' + merged.conflictTotal + ' 道存在跨来源答案冲突。', '',
'## 兼容文件', '',
'下面三个旧文件继续保留,内容均已包含在统一题库中,无需另外导入。', '',
'| 文件 | 原文件数 | 记录数 | 单选 | 多选 | 判断 |',
'|---|---:|---:|---:|---:|---:|'
];
for (const output of outputs) {
for (const output of compatibilityOutputs) {
const records = merged.records.filter(record => record.role === output.role);
const count = type => records.filter(record => record.type === type).length;
report.push('| [' + output.name + '](' + output.name + ') | ' + groups.get(output.role).files.length + ' | ' + records.length + ' | ' + count('单选') + ' | ' + count('多选') + ' | ' + count('判断') + ' |');
}
report.push('', '合计 ' + merged.total + ' 条记录。统一保留选项 A–H,原文件没有的选项为空;原字段逐格保留,并增加“岗位”和“来源文件”。题号保持原课程题号,不同课程可有相同题号。CSV 使用 UTF-8 BOM,可用 Excel 打开。', '',
report.push('', '统一保留选项 A–H,原文件没有的选项为空;原字段逐格保留,并增加“岗位”和“来源文件”。题号保持原课程题号,不同课程可有相同题号。CSV 使用 UTF-8 BOM,可用 Excel 打开。', '',
'合并保留全部记录,不按题干去重,避免丢失课程来源或不同选项。同题在不同课程出现答案差异时也原样保留;搜索时可按课程核对。“常压罐组收料作业中,以下哪些属于工程控制措施?”在风险管理题库中为 ABC,在重大危险源题库中为 ABCD。', '',
'负数等原有防公式单引号转义保持不变,助手导入时会按原规则恢复选项文字。', '',
'## 来源文件', '', '| 岗位 | 原 CSV | 记录数 |', '|---|---|---:|');
@@ -99,13 +129,13 @@ async function main() {
}
report.push('', '## 重新生成', '', '在仓库根目录运行(原始 ZIP 不提交到 Git):', '', '```powershell', 'npm run merge:banks', '```', '',
'也可指定输入和输出目录:', '', '```powershell', 'node scripts/merge-question-banks.cjs "题库.zip" "题库"', '```', '',
'脚本在写入前调用助手的实际导入逻辑,逐条核对合并前后的题干、选项、答案、岗位、题号及课程来源。无需安装额外依赖。', '',
'脚本在写入前独立逐格核对全部原始字段,并调用助手的实际导入逻辑,逐条核对合并前后的题干、选项、答案、原岗位、题号、来源文件及课程来源。重复导入同一题库不会增加来源记录。无需安装额外依赖。', '',
'原始 ZIP SHA-256:`' + crypto.createHash('sha256').update(input).digest('hex') + '`。', '');
fs.mkdirSync(outputDir, { recursive: true });
outputs.forEach(output => fs.writeFileSync(path.join(outputDir, output.name), output.bytes));
fs.writeFileSync(path.join(outputDir, 'README.md'), report.join('\n'));
console.log('已验证并合并 ' + entries.length + ' 个原始 CSV,共 ' + merged.total + ' 条记录:');
outputs.forEach(output => console.log(output.role + ':' + merged.counts[output.role] + ' 条 → ' + path.join(outputDir, output.name)));
outputs.forEach(output => console.log((output.role || '统一题库') + ':' + (output.role ? merged.counts[output.role] : merged.total) + ' 条 → ' + path.join(outputDir, output.name)));
}
main().catch(error => { console.error(error.message); process.exitCode = 1; });
+27 -14
View File
@@ -47,6 +47,11 @@ function documentHTML(practice) {
document.addEventListener('click',function(event){const n=event.target.closest('.pc_examtest_content_list_des');if(n){const box=n.closest('.pc_examtest_content');if(box.dataset.showanswer==='true')return;const label=n.querySelector('.examtest_content_list_des_optionsort').textContent;window.optionClicks.push(box.dataset.number+':'+label);if(box.dataset.kind==='多选')n.classList.toggle('examtest_content_list_des_checked');else{box.querySelectorAll('.pc_examtest_content_list_des').forEach(x=>x.classList.remove('examtest_content_list_des_checked'));n.classList.add('examtest_content_list_des_checked');box.dataset.showanswer='true';box.querySelector('.pc_examtest_content_question_showanswer').remove();box.querySelectorAll('.pc_examtest_content_list_child1').forEach(x=>x.className='pc_examtest_content_list_child2');}}if(event.target.closest('.pc_examtest_content_question_showanswer'))window.parseClicks++;if(event.target.id==='submit')window.submitClicks++;});
</script></body>`;
}
async function activate(page, key) {
const expand = page.getByRole('button', { name: '展开题库助手', exact: true });
if (await expand.isVisible()) await expand.click();
await page.getByTestId('tab-' + key).click();
}
(async () => {
const memory = new Map([['yianxue.assistant.setting.v2.autoExport', true], ['yianxue.assistant.setting.v2.exportEnabled', true]]);
@@ -116,53 +121,58 @@ function documentHTML(practice) {
const startURL = origin + '/clientcn/main?from=assistant-test';
await page.goto(startURL); await page.addScriptTag({ content: sandboxScript });
await page.evaluate(() => YianAssistant.ready);
await page.evaluate(() => YianAssistant.setLearningSetting('answerInterval', 0));
assert.equal(page.url(), startURL); assert.equal(await page.title(), '主站主页验证');
assert(await page.getByRole('heading', { name: '主站主页的题库搜索' }).isVisible());
await page.getByRole('button', { name: '展开题库助手' }).click();
assert(await page.locator('#yian-question-assistant .brand').textContent() === '谊安学助手 2.0');
await activate(page, 'settings');
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
await activate(page, 'exam');
assert(await page.getByRole('switch', { name: '自动选择答案' }).isDisabled());
assert.equal(await page.evaluate(() => typeof window.GM_getValue), 'undefined');
console.log('PASS 主站 /clientcn/main 查询参数下显示搜索面板,非练习页禁止自动选择,沙箱 API 兼容');
const input = await bankInput();
await activate(page, 'bank');
await page.locator('#yian-question-assistant input[type=file]').setInputFiles(input.file);
await page.waitForFunction(total => YianAssistant.getSnapshot().total === total, input.total);
assert.deepEqual(await page.evaluate(() => YianAssistant.getSnapshot().counts), input.counts);
assert.deepEqual(await page.evaluate(() => YianAssistant.getSnapshot().counts), Object.assign({ 未标明: 0 }, input.counts));
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().storage), 'userscript');
await page.getByRole('radio', { name: '主操', exact: true }).check();
assert.equal(await page.getByRole('radio').count(), 0, '统一题库已取消岗位选择');
await page.getByRole('textbox', { name: '搜索题目' }).fill('工程控制措施');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert(await page.locator('#yian-question-assistant .result .conflict').count() > 0);
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('主操'));
console.log('PASS ' + (input.real ? '实际ZIP导入5520条' : '合成ZIP导入90条') + '、岗位数量与冲突来源显示');
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('统一题库'));
console.log('PASS ' + (input.real ? '实际ZIP导入5520条' : '合成ZIP导入90条') + '、旧岗位来源数量与统一冲突显示');
// 未点击搜索按钮的新输入,也应在换岗位时立即检索。
// 三种岗位的同题聚合为统一结果,切栏保留尚未提交的关键词。
const query = '广西华谊新材料有限公司涉及几种高危工艺';
await page.getByRole('textbox', { name: '搜索题目' }).fill(query);
await page.getByRole('radio', { name: '副操', exact: true }).check();
await activate(page, 'console'); await activate(page, 'bank');
assert.equal(await page.getByRole('textbox', { name: '搜索题目' }).inputValue(), query);
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('副操'));
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('统一题库'));
assert(await page.locator('#yian-question-assistant .result').count() > 0);
assert((await page.locator('#yian-question-assistant .sources').first().innerText()).includes('副操'));
const unifiedSources = await page.locator('#yian-question-assistant .sources').first().innerText();
for (const source of ['主操', '班长', '副操']) assert(unifiedSources.includes(source), '统一结果应保留来源:' + source);
await page.getByRole('button', { name: '复制题目与答案' }).first().click();
assert(clipboard.includes('正确答案:')); assert(clipboard.includes('高危工艺'));
await page.getByRole('radio', { name: '班长', exact: true }).check();
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('班长'));
await page.getByRole('radio', { name: '主操', exact: true }).check();
await page.getByRole('textbox', { name: '搜索题目' }).fill('作业');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert.equal(await page.locator('#yian-question-assistant .result').count(), 20);
await page.getByRole('button', { name: '下一页', exact: true }).click();
assert((await page.locator('#yian-question-assistant .pagination').innerText()).includes('21–'));
assert(await page.locator('#yian-question-assistant .result').count() > 0);
console.log('PASS 三岗位隔离、换岗位保留当前关键词、结果复制与20条分页');
console.log('PASS 跨岗位来源统一检索、切栏保留关键词、结果复制与20条分页');
const restrictions = () => page.evaluate(() => ({ select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), blocked: !document.body.dispatchEvent(new Event('selectstart', { bubbles: true, cancelable: true })), style: getComputedStyle(document.querySelector('#copy-target')).userSelect }));
assert.deepEqual(await restrictions(), { select: null, context: null, blocked: false, style: 'text' });
await activate(page, 'settings');
await page.getByRole('switch', { name: '允许复制文字' }).uncheck();
assert.deepEqual(await restrictions(), { select: 'return false', context: 'return false', blocked: true, style: 'none' });
await page.getByRole('switch', { name: '允许复制文字' }).check();
await activate(page, 'bank');
await page.evaluate(() => { const range = document.createRange(); range.selectNodeContents(document.getElementById('copy-target')); const selection = getSelection(); selection.removeAllRanges(); selection.addRange(range); });
await page.getByRole('button', { name: '用选中文字搜索', exact: true }).click();
assert.equal(await page.getByRole('textbox', { name: '搜索题目' }).inputValue(), query);
@@ -178,10 +188,13 @@ function documentHTML(practice) {
const second = await context.newPage(); capture(second);
const secondURL = origin + '/clientcn/exam/nested/fixture/questions?flag=exercise';
await second.goto(secondURL); await second.addScriptTag({ content: sandboxScript }); await second.evaluate(() => YianAssistant.ready);
await second.evaluate(() => YianAssistant.setLearningSetting('answerInterval', 0));
await second.waitForFunction(() => YianAssistant.getState().pageReady);
await activate(second, 'settings');
assert.equal(await second.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false, '旧版导出偏好不能开启新版开关');
assert.equal(await second.evaluate(() => YianAssistant.getSnapshot().total), input.total);
assert.equal(await second.evaluate(() => YianAssistant.getState().role), '主操');
assert.equal(await second.evaluate(() => YianAssistant.getState().role), '');
await activate(second, 'exam');
assert.equal(await second.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForFunction(() => YianAssistant.getState().metrics.selected === 3);
@@ -193,7 +206,7 @@ function documentHTML(practice) {
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForTimeout(500); assert.equal(await second.evaluate(() => window.optionClicks.length), 5);
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
console.log('PASS 同站不同 /clientcn 路径题库岗位共用,实际选项点击、换序、多选差集与无重复');
console.log('PASS 同站不同 /clientcn 路径统一题库共用,实际选项点击、换序、多选差集与无重复');
await second.evaluate(html => { history.pushState({}, '', '/clientcn/exam/another/random-route?flag=exercise'); const doc = new DOMParser().parseFromString(html, 'text/html'); document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest')); window.optionClicks = []; }, practiceHTML());
await second.waitForFunction(() => YianAssistant.getContext().ready);
+12 -8
View File
@@ -103,7 +103,7 @@ function csv(rows) {
const sameStemDifferentAnswers = [semanticRecords[0], store.validateRecord({ ...semanticRecords[0], course: '另一课程', answer: 'B', correctTexts: undefined })];
assert.equal(matcher.match(pressurePage, { role: '主操' }, { records: sameStemDifferentAnswers, revision: 1 }).status, 'conflict', '不同大小写的正确文本应保留为答案冲突');
// Existing v2 data retains its source texts; recompute stale loose keys in memory.
// Existing v2 data retains its source texts and backup while upgrading to v3.
const oldMemory = new Map([['yianxue.assistant.bank.v2', {
records: semanticRecords.map(item => ({ ...item, questionKey: 'old-loose-key', optionKey: 'old-loose-options', id: 'old-id' })),
revision: 7, updatedAt: '2026-10-10T00:00:00.000Z'
@@ -118,7 +118,9 @@ function csv(rows) {
assert.notEqual(item.id, 'old-id');
});
assert.notEqual(upgraded.records[0].questionKey, upgraded.records[1].questionKey);
assert.equal(oldMemory.size, 1, '不更换稳定存储键或清空旧库');
assert.equal(oldMemory.size, 2, '写入新的 v3 键并保留原始 v2 备份');
assert.equal(oldMemory.get('yianxue.assistant.bank.v2').records[0].id, 'old-id', '升级不应改写原始 v2 数据');
assert.equal(upgraded.schemaVersion, 3);
const escaped = record({ options: [{ label: 'A', text: "'-50" }, { label: 'B', text: "'引用" }, { label: 'C', text: "''-50" }] });
let state = await store.importFiles([file('风险管理_题库_1题.csv', csv([escaped]))], { role: '主操' });
@@ -137,7 +139,9 @@ function csv(rows) {
const revision = state.revision;
await assert.rejects(store.importFiles([file('坏.csv', csv([record({ answer: 'H' })]))], { role: '主操' }), /不存在的选项/);
assert.equal((await store.load()).revision, revision, '无效导入应保持原题库不变');
await assert.rejects(store.importFiles([file('没有岗位.csv', csv([record()]))]), /选择主操/);
const roleFree = await runtime().store.importFiles([file('没有岗位.csv', csv([record()]))]);
assert.equal(roleFree.total, 1, '无岗位的旧导出可直接导入统一题库');
assert.equal(roleFree.counts.未标明, 1);
await assert.rejects(store.importFiles([file('坏.json', JSON.stringify([record({ correctTexts: ['作业甲'] })]))]), /不一致/);
await assert.rejects(store.importFiles([file('空.json', '{"records":[]}')]), /没有题目/);
await store.setSetting('role', '班长');
@@ -156,7 +160,7 @@ function csv(rows) {
assert.equal(matcher.match({ ...q, question: q.question.replace('不应', '应') }, { role: '主操' }, state).status, 'missing');
assert.equal(matcher.match({ ...q, options: q.options.slice(0, 2) }, { role: '主操' }, state).status, 'missing');
assert.equal(matcher.match({ ...q, type: '多选' }, { role: '主操' }, state).status, 'missing');
assert.equal(matcher.match(q, {}, state).status, 'unsupported');
assert.equal(matcher.match(q, {}, state).status, 'matched');
assert.equal(matcher.match({ ...q, questionImages: ['image.png'] }, { role: '主操' }, state).status, 'unsupported');
assert.equal(matcher.match({ ...q, options: [q.options[0], { label: 'B', text: q.options[0].text }] }, { role: '主操' }, state).status, 'unsupported');
assert.deepEqual(Array.from(matcher.match({ ...q, options: q.options.map(option => ({ ...option, label: option.label + '.' })) }, { role: '主操' }, state).correctLabels), ['A']);
@@ -171,13 +175,13 @@ function csv(rows) {
assert.equal(answer.status, 'matched'); assert.equal(answer.records.length, 1);
answer = matcher.match(q, { role: '主操', course: '重大危险源' }, groupedState);
assert.equal(answer.status, 'matched'); assert.deepEqual(Array.from(answer.correctLabels), ['C']);
assert.equal(matcher.search('温度', {}, groupedState).total, 0);
assert.equal(matcher.search('温度', {}, groupedState).total, 2);
assert.equal(matcher.search('', { role: '主操' }, groupedState).total, 0);
let found = matcher.search('温度 -50.5 作业乙', { role: '主操' }, groupedState);
assert.equal(found.total, 2, '同题同选项同答案合并,冲突答案分别展示');
assert(found.items.every(item => item.conflict));
assert(found.items.some(item => item.sources.length === 2));
assert.equal(matcher.search('温度', { role: '班长' }, groupedState).total, 1);
assert(found.items.some(item => item.sources.length === 3));
assert.equal(matcher.search('温度', { role: '班长' }, groupedState).total, 2, '遗留岗位设置不应过滤统一搜索');
assert.equal(matcher.search('温度', { role: '主操', course: '风险管理' }, groupedState).items[0].conflict, false);
assert.equal(matcher.search('温度不存在', { role: '主操' }, groupedState).total, 0);
found = matcher.search('温度', { role: '主操', limit: 1, offset: 0 }, groupedState);
@@ -216,7 +220,7 @@ function csv(rows) {
await assert.rejects(store.importFiles([file('损坏.zip', await invalidZip.generateAsync({ type: 'nodebuffer' }))]), /不存在的选项/);
assert.equal((await store.load()).total, 1);
await assert.rejects(store.importFiles([file('题库.zip', await zip.generateAsync({ type: 'nodebuffer' })), file('一份.csv', csv([record()]))]), /只选择一个/);
console.log('PASS 严格语义匹配与宽松搜索分离、原答案证据、旧库重算键、本地存储和原子导入、岗位隔离、换序/冲突/搜索分页');
console.log('PASS 严格语义匹配与宽松搜索分离、原答案证据、旧库安全升级、本地存储和原子导入、统一题库、换序/冲突/搜索分页');
const realZip = path.join(rootDir, '题库.zip');
if (fs.existsSync(realZip)) {
+37 -8
View File
@@ -48,6 +48,11 @@ function documentHTML(fixture) {
body{font:14px/1.6 system-ui,"Microsoft YaHei",sans-serif;margin:24px;background:#f3f6fa;color:#23334c}h1{font-size:24px;margin:0 0 12px}.pc_examtest{display:grid;grid-template-columns:minmax(0,1fr) 205px;gap:20px;max-width:1120px}.pc_examtest_left{min-width:0}.pc_examtest_right{background:white;padding:15px;align-self:start;border-radius:9px}.pc_examtest_title{font-size:20px;margin-bottom:12px}.pc_examtest_content{background:white;padding:14px 18px;margin-bottom:9px;border:1px solid #e1e7ef;border-radius:8px;overflow-wrap:anywhere}.pc_examtest_content_question p{margin:0 0 8px}.pc_examtest_content_question_value,.pc_examtest_content_question_difficulty{color:#8792a3;font-size:11px}.pc_examtest_content_list_des{padding:6px 9px;cursor:pointer;border-radius:5px}.examtest_content_list_des_optionsort{display:inline-block;margin-right:8px}.examtest_content_list_des_checked{background:#e3f5e9;color:#246240}.hidden,[hidden]{display:none!important}.modal:not(.show):not(.in){display:none}.examine_box.show{position:fixed;z-index:1000;inset:130px auto auto 160px;background:white;border:1px solid #ccd6e2;padding:24px;border-radius:10px}.pc_examtest_countdown{margin-bottom:15px}.pc_examtest_card{display:flex;flex-wrap:wrap;gap:5px;margin-top:12px}.examtestcard_count_defult{border:1px solid #d7dee7;border-radius:4px;padding:2px 5px;font-size:11px}.examtestcard_count_assignment{background:#e4f4e9;color:#296343}.pc_examtest_commit{margin-top:14px;background:#285fd3;color:white;padding:8px;border-radius:6px;text-align:center;cursor:pointer}.pc_examtest_right_col{display:flex;gap:15px}.pc_examtest_right_col h4{margin:4px 0}.pc_examtest_content img{max-width:100%}@media(max-width:700px){body{margin:12px}.pc_examtest{display:block}.pc_examtest_right{display:none}.pc_examtest_content{padding:12px}}
</style></head><body onselectstart="return false" oncontextmenu="return false"><h1>考试答案全文核对</h1><p>本地验证:40 道保存题目,交卷由用户操作。</p><app-examtest>${fixture.html}</app-examtest></body></html>`;
}
async function activate(page, key) {
const expand = page.getByRole('button', { name: '展开题库助手', exact: true });
if (await expand.isVisible()) await expand.click();
await page.getByTestId('tab-' + key).click();
}
// 模拟题目组件自己的作答状态。点击先更新独立模型,再根据模型渲染 class 与答题卡。
// 不加载原站 Angular,也不实现提交接口。
@@ -152,9 +157,12 @@ function installExamModel() {
await page.addScriptTag({ content: sandboxScript });
await page.evaluate(installExamModel);
await page.evaluate(() => YianAssistant.ready);
await page.evaluate(() => YianAssistant.setLearningSetting('answerInterval', 0));
await activate(page, 'exam');
}
await open(page, examURL);
assert.equal(await page.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
await activate(page, 'settings');
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
assert.equal(await page.locator('#yian-question-assistant .section-title').filter({ hasText: '当前考试' }).count(), 1);
const extracted = await page.evaluate(() => YianAssistant.collectCurrent().questions);
@@ -164,6 +172,7 @@ function installExamModel() {
assert(!/(?:难度低|难度中|难度高)/.test(question.question), '题干应去除难度');
});
const bankFiles = realSample ? bankPaths : { name: '三岗位考试题库.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(fixture.records)) };
await activate(page, 'bank');
await page.locator('#yian-question-assistant input[type=file]').setInputFiles(bankFiles);
await page.waitForFunction(() => YianAssistant.getSnapshot().counts.主操 > 0 && YianAssistant.getSnapshot().counts.班长 > 0 && YianAssistant.getSnapshot().counts.副操 > 0);
console.log('PASS ' + (realSample ? '真实保存考试 DOM 与仓库三岗位 CSV 导入' : '可独立运行的 40 题 / 三岗位内部 fixture') + '、考试识别、默认关闭、分值与难度去除');
@@ -171,9 +180,16 @@ function installExamModel() {
const pages = [page, await context.newPage(), await context.newPage()];
pages.slice(1).forEach(capture);
await Promise.all(pages.map(async (target, index) => {
if (index) await open(target, examURL + '&rolecase=' + index);
await target.getByRole('radio', { name: roles[index], exact: true }).check();
if (index) await open(target, examURL + '&sessioncase=' + index);
await target.evaluate(async () => {
const title = YianAssistant.collectCurrent().title;
const courses = Array.from(new Set(YianAssistant.getSnapshot().records.map(record => record.course))).filter(Boolean);
await YianAssistant.setLearningSetting('associations', { [title]: courses });
});
await activate(target, 'exam');
assert.equal(await target.getByRole('radio').count(), 0, '统一题库没有岗位选择');
await target.waitForFunction(() => YianAssistant.getContext().ready);
assert((await target.evaluate(() => YianAssistant.getContext().courses || [])).length > 0, '明确关联后须确认可用题库主题,再启动答题');
const expectations = await target.evaluate(() => {
const context = YianAssistant.getContext(), snapshot = YianAssistant.getSnapshot();
return YianAssistant.collectCurrent().questions.map(question => {
@@ -181,14 +197,24 @@ function installExamModel() {
return { number: question.number, type: question.type, status: matched.status, reason: matched.reason,
originalAnswer: matched.records[0] && matched.records[0].answer,
bankTexts: matched.records[0] && matched.records[0].correctTexts,
currentLabels: matched.correctLabels, evidenceTexts: matched.correctTexts };
currentLabels: matched.correctLabels, evidenceTexts: matched.correctTexts,
sourceRoles: Array.from(new Set((matched.records || []).map(record => record.role))).sort() };
});
});
assert.equal(expectations.filter(item => item.status === 'matched').length, 40, roles[index] + '应匹配 40 / 40;' + JSON.stringify(expectations.filter(item => item.status !== 'matched')));
assert.equal(expectations.filter(item => item.status === 'matched').length, 40, '统一题库应匹配 40 / 40;' + JSON.stringify(expectations.filter(item => item.status !== 'matched')));
expectations.forEach(item => assert.deepEqual(item.sourceRoles, roles.slice().sort(), '同题聚合保留三个岗位来源'));
assert.equal(expectations.filter(item => item.currentLabels.join('') !== item.originalAnswer).length, 20, '应有 20 题需要按全文重新映射字母');
target.__expectations = expectations;
await target.getByRole('switch', { name: '自动选择答案' }).check();
await target.waitForFunction(() => YianAssistant.getState().metrics.selected === 40, undefined, { timeout: 35000 });
await target.waitForFunction(() => YianAssistant.getState().metrics.selected === 40, undefined, { timeout: 35000 }).catch(async error => {
console.error('考试超时诊断:' + JSON.stringify(await target.evaluate(() => {
const state = YianAssistant.getState(), context = YianAssistant.getContext();
return { autoAnswer: state.autoAnswer, metrics: state.metrics, examStage: state.exam.stage, examMessage: state.exam.message,
ready: context.ready, courses: context.courses, clicks: examModel.clicks.length,
notice: document.querySelector('#yian-question-assistant').shadowRoot.querySelector('.notice').textContent };
})));
throw error;
});
const actual = await target.evaluate(() => {
const bank = YianAssistant.collectCurrent();
const rows = Array.from(document.querySelectorAll('.pc_examtest_content'));
@@ -204,12 +230,12 @@ function installExamModel() {
assert.equal(actual.cards, 40, '独立模型应更新全部答题卡');
assert.equal(actual.evidence.length, 40);
expectations.forEach((expected, qIndex) => {
assert.deepEqual(sortedTexts(actual.selectedTexts[qIndex]), sortedTexts(expected.bankTexts), roles[index] + '第 ' + expected.number + ' 题 DOM 应等于题库全文');
assert.deepEqual(sortedTexts(actual.modelTexts[qIndex]), sortedTexts(expected.bankTexts), roles[index] + '第 ' + expected.number + ' 题独立作答模型应等于题库全文');
assert.deepEqual(sortedTexts(actual.selectedTexts[qIndex]), sortedTexts(expected.bankTexts), '会话 ' + (index + 1) + ' 第 ' + expected.number + ' 题 DOM 应等于题库全文');
assert.deepEqual(sortedTexts(actual.modelTexts[qIndex]), sortedTexts(expected.bankTexts), '会话 ' + (index + 1) + ' 第 ' + expected.number + ' 题独立作答模型应等于题库全文');
expected.evidenceTexts.forEach((text, optionIndex) => assert(normalize(actual.evidence[qIndex]).includes(normalize(expected.currentLabels[optionIndex] + '. ' + text)), '核对面板应显示原库全文与对应当前字母'));
});
await target.getByRole('switch', { name: '自动选择答案' }).uncheck();
console.log('PASS ' + roles[index] + ':40 / 40 已选择,20 题字母变化;所有 DOM、独立模型及核对面板均与原题库正确答案全文一致');
console.log('PASS 统一题库会话 ' + (index + 1) + ':40 / 40 已选择,20 题字母变化;三岗位来源聚合,所有 DOM、独立模型及核对面板均与正确答案全文一致');
}));
await Promise.all(pages.slice(1).map(target => target.close()));
const evidenceDetails = page.locator('#yian-question-assistant .answer-check');
@@ -224,10 +250,12 @@ function installExamModel() {
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), '小屏页面不得横向溢出');
await page.screenshot({ path: path.join(qaDir, 'exam-mobile.png') });
await page.setViewportSize({ width: 1366, height: 900 });
await activate(page, 'settings');
await page.evaluate(() => YianAssistant.setExportEnabled(true));
assert.equal(await page.getByRole('button', { name: '导出 CSV', exact: true }).isDisabled(), true);
assert.equal(await page.evaluate(() => { try { YianAssistant.exportCurrent('csv'); return ''; } catch (error) { return error.message; } }), '请先进入支持的练习页面');
await page.evaluate(() => YianAssistant.setExportEnabled(false));
await activate(page, 'exam');
console.log('PASS 答案核对展开状态 / 节点复用、桌面与小屏截图、考试禁用导出');
let sequence = 0;
@@ -246,6 +274,7 @@ function installExamModel() {
await page.evaluate(installExamModel);
if (waitReady) await page.waitForFunction(() => YianAssistant.getContext().ready);
else await page.waitForTimeout(1600);
await activate(page, 'exam');
}
await reset();
await page.getByRole('switch', { name: '自动选择答案' }).check();
+362
View File
@@ -0,0 +1,362 @@
/* Real DOM exam evidence and submit-state tests. No business scripts or requests. */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const rootDir = path.resolve(__dirname, '..');
const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', 'answer-runner.js', 'exam-controller.js'];
function paperHTML(total = 25) {
const questions = Array.from({ length: total }, (_, i) => `<div app-exam-question><div class="pc_examtest_content" data-number="${i + 1}">
<div class="pc_examtest_content_question"><p>[单选] ${i + 1}. 安全核对题${i + 1}应该怎样操作?<span class="pc_examtest_content_question_value">(${100 / total}分)</span></p><div class="pc_examtest_content_question_difficulty">难度低</div></div>
<div class="pc_examtest_content_list"><div class="pc_examtest_content_list_des examtest_content_list_des_checked" data-label="A"><span class="examtest_content_list_des_optionsort">A</span>核对措施${i + 1}</div><div class="pc_examtest_content_list_des" data-label="B"><span class="examtest_content_list_des_optionsort">B</span>任意操作${i + 1}</div></div>
</div></div>`).join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>body{font:14px system-ui;margin:12px}.pc_examtest_content{padding:3px}.pc_examtest_content_question p{margin:0}.pc_examtest_content_list_des{padding:2px;cursor:pointer}.pc_examtest_content_list_des_checked{background:#ddf2e3}.pc_examtest_commit{padding:12px;background:#ddd;cursor:pointer}.dialog_box{position:fixed;inset:20px auto auto 20px;background:white;border:1px solid;padding:12px}[aria-hidden="true"]{display:none!important}</style></head><body><app-examtest><div class="pc_examtest_title">安全核对考试</div>${questions}<div class="pc_examtest_countdown"><span class="time">00:30:00</span></div><div class="pc_examtest_card">${Array.from({ length: total }, (_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div><button class="pc_examtest_commit">交卷</button></app-examtest></body></html>`;
}
(async () => {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const errors = [], requests = [];
try {
const context = await browser.newContext({ viewport: { width: 1000, height: 800 } });
await context.route('**/*', route => { requests.push(route.request().url()); return route.abort(); });
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
for (const name of modules) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') });
async function setup({ matched = 25, unknownPoint = false, confirm = false, showResult = false, total = 25 } = {}) {
await page.evaluate(({ html, matched, unknownPoint, confirm, showResult }) => {
window.qaExam?.destroy(); window.qaRunner?.destroy();
document.documentElement.innerHTML = html.replace(/^<!doctype html>/, '').replace(/^<html>/, '').replace(/<\/html>$/, '');
window.qaClock = { now: 0 };
window.qaSetClock = value => {
qaClock.now = value;
const remaining = Math.max(0, 1800 - Math.floor(value / 1000));
document.querySelector('.pc_examtest_countdown .time').textContent = [Math.floor(remaining / 3600), Math.floor(remaining / 60) % 60, remaining % 60].map(part => String(part).padStart(2, '0')).join(':');
};
window.qaStorage = new Map(); window.qaLogs = []; window.qaStates = []; window.qaStorageHook = null; window.qaReadGate = null; window.qaStorageReads = 0;
window.qaModel = { commitClicks: 0, confirmClicks: 0, optionClicks: [], confirm, showResult };
window.qaContext = { mode: 'test', exam: true, practice: false, ready: true, local: false,
sourceUrl: 'http://127.0.0.1/clientcn/exam/qa-paper?flag=test', course: '安全核对考试', revision: 1,
root: document.querySelector('app-examtest'), routeKey: 'qa-paper', pageSignature: 'qa-stable', topicKey: 'qa-theme' };
window.qaTopic = { status: 'matched', courses: ['安全知识题库'] };
const extracted = QuestionBankCore.extract(document);
window.qaSnapshot = { revision: 1, records: extracted.questions.slice(0, matched).map(question => YianBankStore.validateRecord({
...question, role: '', course: '安全知识题库', sourceFile: '测试题库.json', answer: 'A', correctTexts: undefined
})) };
window.qaRunnerState = 'complete';
window.qaSettings = { minSubmitMinutes: 6 };
if (unknownPoint) document.querySelector('.pc_examtest_content_question_value').textContent = '分值待定';
const boxes = Array.from(document.querySelectorAll('.pc_examtest_content'));
boxes.forEach((box, index) => box.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.addEventListener('click', () => {
box.querySelectorAll('.pc_examtest_content_list_des').forEach(option => option.classList.remove('examtest_content_list_des_checked'));
node.classList.add('examtest_content_list_des_checked');
qaModel.optionClicks.push({ number: index + 1, label: node.dataset.label });
})));
document.querySelector('.pc_examtest_commit').addEventListener('click', () => {
qaModel.commitClicks++;
if (!qaModel.confirm) return;
const dialog = document.createElement('div'); dialog.className = 'dialog_box';
dialog.innerHTML = '<div role="dialog"><p>还有未答题,是否确认交卷?</p><button class="qa-cancel">取消</button><button class="qa-confirm">确认交卷</button></div>';
document.body.appendChild(dialog);
dialog.querySelector('.qa-confirm').addEventListener('click', () => {
qaModel.confirmClicks++;
dialog.remove();
if (qaModel.showResult) {
const result = document.createElement('div'); result.className = 'exam_submited_box'; result.textContent = '交卷成功,考试成绩:84分'; document.body.appendChild(result);
}
});
});
window.qaMakeController = () => YianExamController.create({
document, now: () => qaClock.now,
storage: { get: async (key, fallback) => { qaStorageReads++; if (qaReadGate) await qaReadGate; return qaStorage.has(key) ? structuredClone(qaStorage.get(key)) : fallback; },
set: async (key, value) => { if (qaStorageHook) await qaStorageHook(key, value); qaStorage.set(key, structuredClone(value)); } },
getContext: () => qaContext, getTopic: () => qaTopic, getSnapshot: () => qaSnapshot,
getSettings: () => qaSettings, getRunnerState: () => qaRunnerState,
onState: state => qaStates.push(state), onLog: (level, message) => qaLogs.push({ level, message })
});
window.qaExam = qaMakeController();
}, { html: paperHTML(total), matched, unknownPoint, confirm, showResult });
return page.evaluate(async () => { await qaExam.observe(); const enabled = await qaExam.enable(); await qaExam.tick(); return { enabled, ...qaExam.getState() }; });
}
async function tickAt(seconds) {
return page.evaluate(async value => { qaSetClock(value * 1000); await qaExam.tick(); return { state: qaExam.getState(), model: qaModel }; }, seconds);
}
let initial = await setup({ matched: 20 });
assert.equal(initial.total, 25);
assert.equal(initial.totalScore, 100, '按实际 DOM 分值计算 25×4=100');
assert.equal(initial.scoreValid, true);
assert.equal(initial.predictedScore, 80);
assert.equal(initial.matchedCount, 20);
assert.equal(initial.missing, 5);
assert.equal(initial.waitSeconds, 360, 'now 从 0 开始且无 totaltime 参数可记录首次入卷');
let result = await tickAt(360);
assert.equal(result.model.commitClicks, 0, '正好 80 分不得自动交卷');
assert.equal(result.state.stage, 'waiting');
assert.match(result.state.message, /小于或等于 80/);
initial = await setup({ matched: 21 });
assert.equal(initial.predictedScore, 84);
assert.equal(initial.verifiedCount, 21);
assert.equal(initial.missing, 4, '部分题目缺失仍可用已核验题目的实际分值计算');
result = await tickAt(240);
assert.equal(result.model.commitClicks, 0, '84 分但只满 4 分钟不得提交');
assert.equal(result.state.waitSeconds, 120);
assert.equal(result.state.stage, 'waitingSubmit');
result = await tickAt(360);
assert.equal(result.model.commitClicks, 1, '84 分且满 6 分钟点击一次真实交卷入口');
await tickAt(365);
assert.equal((await tickAt(372)).model.commitClicks, 1, '提交后等待结果时不得重复点击');
result = await tickAt(373);
assert.equal(result.state.stage, 'unknown');
assert.equal(result.state.enabled, false);
assert.equal(result.model.commitClicks, 1);
assert.equal((await tickAt(600)).model.commitClicks, 1, '超过 12 秒未确认后仍禁止重复交卷');
assert.equal(await page.evaluate(() => qaExam.enable()), false, '未知提交状态不得通过重复启动绕过');
console.log('PASS 实际分值与部分命中、80 分等待、84 分 4/6 分钟门槛、未知结果有界停止并禁止重复提交');
initial = await setup({ matched: 25, unknownPoint: true });
assert.equal(initial.scoreValid, false);
assert.match(initial.reason, /分值/);
assert.equal((await tickAt(600)).model.commitClicks, 0, '分值不明,即使命中全部题目也不得提交');
await setup({ matched: 21 });
await page.evaluate(() => { document.querySelector('.pc_examtest_content_list_des').classList.remove('examtest_content_list_des_checked'); });
result = await tickAt(360);
assert.equal(result.state.verifiedCount, 20);
assert.equal(result.state.predictedScore, 80);
assert.equal(result.model.commitClicks, 0, '匹配记录存在但实际选中内容未回读核验时不计分');
console.log('PASS 分值未知与实际选中证据变化均阻止自动交卷');
await setup({ matched: 21 });
await page.locator('.pc_examtest_content').first().locator('[data-label="B"]').click();
result = await tickAt(360);
assert.equal(result.state.enabled, false);
assert.equal(result.state.stage, 'paused');
assert.match(result.state.message, /人工/);
assert.equal(result.model.commitClicks, 0, '可信人工点击立即取消交卷计划');
await setup({ matched: 21 });
await page.evaluate(() => { qaContext.revision = 2; qaSnapshot.revision = 2; });
result = await tickAt(360);
assert.equal(result.state.enabled, false);
assert.equal(result.model.commitClicks, 0, '题库 revision 更新使旧授权失效');
assert.match(result.state.message, /题库|变更/);
console.log('PASS 人工作答取消与题库 revision 变化取消旧交卷授权');
await setup({ matched: 21, confirm: true, showResult: true });
result = await tickAt(360);
assert.equal(result.model.commitClicks, 1);
assert.equal(result.model.confirmClicks, 0);
result = await tickAt(361);
assert.equal(result.model.confirmClicks, 1, '单个真实确认按钮只点击一次');
result = await tickAt(362);
assert.equal(result.state.stage, 'submitted');
assert.equal(result.state.submitted, true, '只有网站结果 DOM 才确认提交成功');
assert.equal(result.state.enabled, false);
assert.equal((await tickAt(363)).model.confirmClicks, 1);
console.log('PASS 交卷确认弹窗及网站成功结果,不从点击动作推断成功');
await setup({ matched: 21 });
await tickAt(240);
const restored = await page.evaluate(async () => {
qaExam.destroy(); qaExam = qaMakeController();
await qaExam.observe(); await qaExam.tick();
return qaExam.getState();
});
assert.equal(restored.elapsedSeconds, 240);
assert.equal(restored.waitSeconds, 120, '同场重建 controller 复用原始 startedAt=0,计时不归零');
assert.equal(restored.enabled, false, '刷新恢复仍需明确启动');
await page.evaluate(() => qaExam.enable());
assert.equal((await tickAt(360)).model.commitClicks, 1, '同场恢复后仍按原始入卷 6 分钟交卷');
await setup({ matched: 21 });
await page.evaluate(() => {
qaExam.destroy(); qaStorage.clear(); qaClock.now = 240000;
qaContext.sourceUrl += '&totaltime=30';
document.querySelector('.pc_examtest_countdown .time').textContent = '00:26:00';
qaExam = qaMakeController();
});
const inferred = await page.evaluate(async () => { await qaExam.observe(); await qaExam.tick(); return qaExam.getState(); });
assert.equal(inferred.elapsedSeconds, 240, '首次注入可由明确考试时长和剩余时间反推入卷时间');
assert.equal(inferred.waitSeconds, 120);
console.log('PASS 原始入卷时间持久化、0 时间值和明确倒计时反推');
for (const mutation of ['ready', 'route', 'revision', 'answers', 'points', 'topic', 'pause']) {
await setup({ matched: 21 });
const blocked = await page.evaluate(async mutation => {
qaSetClock(360000);
qaStorageHook = async (_key, value) => {
if (value.stage !== 'submitting') return;
if (mutation === 'ready') qaContext.ready = false;
if (mutation === 'route') qaContext.sourceUrl = 'http://127.0.0.1/clientcn/exam/another?flag=test';
if (mutation === 'revision') { qaContext.revision++; qaSnapshot.revision++; }
if (mutation === 'answers') document.querySelector('.pc_examtest_content_list_des').classList.remove('examtest_content_list_des_checked');
if (mutation === 'points') {
const nodes = document.querySelectorAll('.pc_examtest_content_question_value');
nodes[0].textContent = '(6分)'; nodes[21].textContent = '(2分)';
}
if (mutation === 'topic') qaTopic = { status: 'matched', courses: ['其他主题'] };
if (mutation === 'pause') qaExam.disable('用户已暂停');
};
await qaExam.tick(); return { model: qaModel, state: qaExam.getState() };
}, mutation);
assert.equal(blocked.model.commitClicks, 0, '异步保存期间发生 ' + mutation + ' 时禁止旧提交点击');
assert.equal(blocked.state.enabled, false);
}
for (const mutation of ['revision', 'duplicateButton', 'pause']) {
await setup({ matched: 21, confirm: true });
await tickAt(360);
const blocked = await page.evaluate(async mutation => {
qaStorageHook = async (_key, value) => {
if (value.stage !== 'confirming') return;
if (mutation === 'revision') { qaContext.revision++; qaSnapshot.revision++; }
if (mutation === 'duplicateButton') { const button = document.createElement('button'); button.textContent = '确认交卷'; document.querySelector('.dialog_box').append(button); }
if (mutation === 'pause') qaExam.disable('用户已暂停');
};
await qaExam.tick(); return { model: qaModel, state: qaExam.getState() };
}, mutation);
assert.equal(blocked.model.confirmClicks, 0, '二次确认保存期间发生 ' + mutation + ' 时禁止旧确认点击');
assert.equal(blocked.state.enabled, false);
}
console.log('PASS 最后点击前复核:异步保存期间页面、题库、选中集合、主题、暂停及确认入口变化均阻止旧动作');
await setup({ matched: 21 });
const interruptedRead = await page.evaluate(async () => {
qaExam.destroy(); qaStorageReads = 0;
let release; qaReadGate = new Promise(resolve => { release = resolve; });
qaExam = qaMakeController();
const first = qaExam.observe(), second = qaExam.observe();
const shared = first === second;
const enabling = qaExam.enable();
qaExam.disable('用户暂停'); release();
const returned = await enabling; await Promise.all([first, second]); qaReadGate = null;
return { returned, shared, reads: qaStorageReads, state: qaExam.getState() };
});
assert.equal(interruptedRead.shared, true, '同场并行 observe 共用一个进行中的 Promise');
assert.equal(interruptedRead.reads, 1);
assert.equal(interruptedRead.returned, false);
assert.equal(interruptedRead.state.enabled, false, 'storage.get 返回后不得重新启用已暂停的考试');
await setup({ matched: 21 });
const interruptedWrite = await page.evaluate(async () => {
let release; const gate = new Promise(resolve => { release = resolve; });
qaStorageHook = async (_key, value) => { if (value.stage === 'answering') { qaExam.disable('用户停止'); await gate; } };
const enabling = qaExam.enable();
release(); return { returned: await enabling, state: qaExam.getState() };
});
assert.equal(interruptedWrite.returned, false);
assert.equal(interruptedWrite.state.enabled, false);
console.log('PASS observe 共享异步读取及 enable 在读写期间被暂停后不复活');
await setup({ matched: 21 });
const retake = await page.evaluate(async () => {
qaExam.destroy();
const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key);
qaStorage.set(key, { ...saved, startedAt: -1200000, stage: 'submitted' });
qaClock.now = 60000; qaContext.sourceUrl += '&totaltime=30';
document.querySelector('.pc_examtest_countdown .time').textContent = '00:29:00';
qaExam = qaMakeController(); await qaExam.observe(); await qaExam.tick();
const before = qaExam.getState();
const accepted = await qaExam.enable(); await qaExam.tick();
return { before, accepted, after: qaExam.getState(), model: qaModel };
});
assert.equal(retake.before.submitted, false, '缓存已交卷不能当作当前场次网站成功');
assert.equal(retake.before.stage, 'awaitingAttempt');
assert.equal(retake.accepted, true, '明确启动可为当前可作答试卷建立新场次');
assert.equal(retake.after.elapsedSeconds, 60, '当前30分钟/剩29分钟证据优先,禁止沿用上一场20分钟前的时间');
assert.equal(retake.after.waitSeconds, 300);
assert.equal(retake.model.commitClicks, 0);
assert.equal((await tickAt(360)).model.commitClicks, 1);
await setup({ matched: 21 });
const missingIdentity = await page.evaluate(async () => {
qaExam.destroy();
const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key);
qaStorage.set(key, { ...saved, startedAt: -1200000, stage: 'submitted' });
qaClock.now = 1000000; qaExam = qaMakeController();
await qaExam.observe(); const accepted = await qaExam.enable(); await qaExam.tick();
return { accepted, state: qaExam.getState(), model: qaModel };
});
assert.equal(missingIdentity.accepted, true);
assert.equal(missingIdentity.state.elapsedSeconds, 0, '没有明确时长时,新场次从本次明确启动时开始计时');
assert.equal(missingIdentity.state.waitSeconds, 360);
assert.equal(missingIdentity.model.commitClicks, 0);
await setup({ matched: 21 });
const equalCountdownRetake = await page.evaluate(async () => {
qaExam.destroy(); qaClock.now = 360000;
// Both old checkpoint and new attempt start at 1800. Compare elapsed wall time,
// not merely whether the new raw value exceeds the old raw value.
document.querySelector('.pc_examtest_countdown .time').textContent = '00:30:00';
qaExam = qaMakeController(); await qaExam.observe(); await qaExam.tick();
const before = qaExam.getState();
const accepted = await qaExam.enable(); await qaExam.tick();
return { before, accepted, after: qaExam.getState(), model: qaModel };
});
assert.equal(equalCountdownRetake.before.stage, 'awaitingAttempt');
assert.equal(equalCountdownRetake.accepted, true);
assert.equal(equalCountdownRetake.after.elapsedSeconds, 0, '旧1800秒检查点六分钟后看到新1800秒,不能算本场已过六分钟');
assert.equal(equalCountdownRetake.after.waitSeconds, 360);
assert.equal(equalCountdownRetake.model.commitClicks, 0);
await setup({ matched: 21 });
const noClockEvidence = await page.evaluate(async () => {
qaExam.destroy(); qaClock.now = 360000;
document.querySelector('.pc_examtest_countdown').remove();
qaExam = qaMakeController(); await qaExam.observe(); await qaExam.tick();
const before = qaExam.getState();
const accepted = await qaExam.enable(); await qaExam.tick();
return { before, accepted, after: qaExam.getState(), model: qaModel };
});
assert.equal(noClockEvidence.before.stage, 'awaitingAttempt', '没有倒计时或真实场次ID,不能默复用旧时长');
assert.match(noClockEvidence.before.message, /计时连续性|新场次/);
assert.equal(noClockEvidence.accepted, true);
assert.equal(noClockEvidence.after.elapsedSeconds, 0);
assert.equal(noClockEvidence.after.waitSeconds, 360);
assert.equal(noClockEvidence.model.commitClicks, 0);
await setup({ matched: 21 });
const pendingAttempt = await page.evaluate(async () => {
qaExam.destroy();
const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key);
qaStorage.set(key, { ...saved, stage: 'submitting', submitAt: 0 });
qaExam = qaMakeController(); await qaExam.observe();
const blocked = await qaExam.enable(); const stage = qaExam.getState().stage;
qaContext.sourceUrl += '&attemptId=second-real-attempt';
const fresh = await qaExam.enable(); await qaExam.tick();
const queryIsolated = YianExamController.paperKey(qaContext.sourceUrl) !== YianExamController.paperKey(qaContext.sourceUrl.replace('second-real-attempt', 'third-real-attempt'));
const first = YianExamController.paperKey(qaContext.sourceUrl, qaContext.root);
qaContext.root.setAttribute('data-attempt-id', 'server-dom-attempt');
const domIsolated = first !== YianExamController.paperKey(qaContext.sourceUrl, qaContext.root);
return { blocked, stage, fresh, queryIsolated, domIsolated, model: qaModel };
});
assert.equal(pendingAttempt.blocked, false, '未知提交状态禁止按无ID重考自动复用');
assert.equal(pendingAttempt.stage, 'unknown');
assert.equal(pendingAttempt.fresh, true, '真实新 attemptId 使用独立场次');
assert.equal(pendingAttempt.queryIsolated, true);
assert.equal(pendingAttempt.domIsolated, true);
assert.equal(pendingAttempt.model.commitClicks, 0);
console.log('PASS 重考与旧场次隔离:缓存 submitted 不算当前完成、倒计时连续性/无时钟须重确认、未知提交禁止复用及 query/DOM 场次标识');
await setup({ total: 3, matched: 3 });
const interval = await page.evaluate(async () => {
qaExam.destroy();
document.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.classList.remove('examtest_content_list_des_checked'));
const nativeSetTimeout = window.setTimeout;
const timers = [];
window.setTimeout = (callback, delay, ...args) => { timers.push(delay); return nativeSetTimeout(callback, 0, ...args); };
let runnerState = 'idle';
qaRunner = YianAnswerRunner.create({ getContext: () => qaContext,
match: question => YianAnswerMatcher.match(question, { courses: qaTopic.courses }, qaSnapshot), getInterval: () => 4,
onState: state => { runnerState = state; } });
try { await qaRunner.start(); return { timers, clicks: qaModel.optionClicks.length, state: runnerState }; }
finally { window.setTimeout = nativeSetTimeout; qaRunner.destroy(); }
});
assert.equal(interval.state, 'complete');
assert.equal(interval.clicks, 3, 'runner 通过真实 DOM 点击完成三题');
assert.equal(interval.timers.filter(delay => delay === 4000).length, 2, 'getInterval=4 按相邻题目设置两个 4000ms 间隔');
assert.equal(requests.length, 0, '整组测试不得请求业务站或其他网络资源');
assert.deepEqual(errors, [], '测试过程无浏览器脚本错误');
console.log('PASS 真实 runner 点击与默认 4 秒题间隔;全部测试零网络请求');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+286
View File
@@ -0,0 +1,286 @@
/* 本机隔离浏览器学习调度回归;所有业务地址均被 route 拦截,无真实学习记录。 */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const ZIP = require('../vendor/jszip.min.js');
const workspace = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(workspace, 'learning-engine.js'), 'utf8');
const core = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
const origin = 'http://10.119.67.7/clientcn/';
const videoHTML = '<app-course><div class="course-courseware-other-top-left">测试课程</div><li class="cn_courseware_img active" data-courseware-id="v1" data-required-seconds="100"><p>视频一</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习20秒</div><div class="courseware_finished_status"><img class="en_courseware_finished en_courseware_noFinished"></div></li><app-course-courseware-video><video id="player1"></video></app-course-courseware-video></app-course>';
async function main() {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', route => route.request().isNavigationRequest() ? route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><meta charset="utf-8"><body></body>' }) : route.abort());
await page.goto(origin + 'course/test');
await page.addScriptTag({ content: core });
await page.addScriptTag({ content: source });
let checks = 0;
try {
async function setup(html = videoHTML, config = {}, settings = {}) {
await page.evaluate(async ({ html, config, settings }) => {
window.qaEngine?.destroy();
history.replaceState(null, '', '/clientcn/course/test');
document.body.innerHTML = html;
window.qaClock = 100000; window.qaMemory = new Map(); window.qaReloads = 0; window.qaNavigations = []; window.qaWorkerURLs = []; window.qaPlayCalls = 0; window.qaPauseCalls = 0; window.qaExamCalls = 0; window.qaQuizCalls = 0;
window.qaVideo = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 };
window.qaExam = { stage: 'answering', total: 40, verifiedCount: 0 };
window.qaSnapshot = { document, url: location.href, accountId: 'fixture-account', bankVersion: 1, planId: 'fixture-plan', courses: ['测试课程'] };
window.qaStorage = { get: async key => qaMemory.has(key) ? JSON.parse(JSON.stringify(qaMemory.get(key))) : null, set: async (key, value) => { qaMemory.set(key, JSON.parse(JSON.stringify(value))); } };
window.qaInstallVideo = () => {
const video = document.querySelector('video'); if (!video) return;
for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => qaVideo[key], set: v => { qaVideo[key] = v; } });
video.play = async () => { qaPlayCalls++; if (settings.rejectPlay) throw new Error('autoplay'); qaVideo.paused = false; };
video.pause = () => { qaPauseCalls++; qaVideo.paused = true; };
};
qaInstallVideo();
window.qaCreate = (extra = {}) => YianLearningEngine.create(Object.assign({
getSnapshot: () => Object.assign({}, qaSnapshot, { document, url: location.href }),
getExamState: () => qaExam,
onExamStart: async () => { qaExamCalls++; return settings.examNotReady ? false : true; },
onExamPause: () => {},
onState: state => { window.qaLastState = state; },
storage: qaStorage, now: () => qaClock,
reload: async () => { qaReloads++; }, navigate: async url => { qaNavigations.push(url); },
openWorker: async url => { qaWorkerURLs.push(url); return settings.blockWorker ? false : {}; },
solveQuiz: settings.noQuizAdapter ? undefined : async () => { qaQuizCalls++; if (settings.solveQuiz) { document.querySelector('[data-learning-quiz],.question-modal-pc')?.remove(); return { status: 'completed', verified: true }; } return { status: 'waiting', reason: 'fixture无精确答案' }; }
}, extra));
window.qaEngine = qaCreate(); await qaEngine.configure(config);
}, { html, config, settings });
}
async function read() { return page.evaluate(() => qaEngine.getState()); }
async function tick(delta = 0) { return page.evaluate(async delta => { qaClock += delta; return qaEngine.tick(); }, delta); }
// 安全解析实际7页,只读取DOM,不执行任何归档脚本和媒体。
const samples = [];
const archiveAvailable = fs.existsSync(path.join(workspace, '谊安学网页.zip'));
if (archiveAvailable) {
const zip = await ZIP.loadAsync(fs.readFileSync(path.join(workspace, '谊安学网页.zip')));
for (const file of Object.values(zip.files).filter(f => /\.html$/i.test(f.name))) samples.push({ name: file.name, html: await file.async('string') });
} else {
// 仓库不提交原站ZIP;新克隆仍用最小DOM覆盖同一适配器。
samples.push({ name: 'fixture/01/page.html', html: '<app-learning-map-activity-list>' + Array.from({ length: 26 }, (_, i) => '<div class="activity_content"><div class="activity_name">课程' + i + '</div><div class="acitvity_other_info">必修课程</div></div>').join('') + '</app-learning-map-activity-list>' });
samples.push({ name: 'fixture/02/page.html', html: videoHTML.replace('测试课程', '1-1安全生产方针和目标').replace('</li>', '<p class="related_test">关联考试</p></li>') });
samples.push({ name: 'fixture/05/page.html', html: videoHTML.replace('已学习20秒', '已学习27秒') });
samples.push({ name: 'fixture/06/page.html', html: '<app-examdetail><div class="examdetail_header">测试课程 已答0/40</div></app-examdetail>' });
}
const inspected = await page.evaluate(samples => samples.map(({ name, html }) => {
const doc = QuestionBankCore.parseHTML(html); const p = YianLearningEngine.describePage(doc, location.href); const tasks = YianLearningEngine.discoverTasks(doc, location.href);
return { name, kind: p.kind, title: p.title, learned: p.learnedSeconds, complete: p.platformComplete, quiz: Boolean(p.quiz), tasks: tasks.map(t => ({ kind: t.kind, title: t.title, status: t.status })) };
}), samples);
const map = inspected.find(x => x.name.includes('/01/'));
assert.equal(map.tasks.length, 26, '实际26课程不能把9阶段当作任务数');
assert.equal(map.tasks.filter(t => t.kind === 'course').length, 26);
const detail = inspected.find(x => x.name.includes('/02/'));
assert.equal(detail.title, '1-1安全生产方针和目标');
assert.deepEqual(detail.tasks.map(t => t.kind), ['video', 'exam']);
const played = inspected.find(x => x.name.includes('/05/'));
assert.equal(played.complete, false, 'noFinished不能因共同class前缀误认为完成');
assert.equal(played.learned, 27); assert.equal(played.quiz, false);
assert.equal(inspected.find(x => x.name.includes('/06/')).kind, 'exam');
checks++;
const identities = await page.evaluate(() => {
const d = new DOMParser().parseFromString('<a data-kind="video" href="/clientcn/course/one">同名</a><a data-kind="video" href="/clientcn/course/two">同名</a><a href="http://example.com/clientcn/exam/other">外站</a>', 'text/html');
return YianLearningEngine.discoverTasks(d, location.href);
});
assert.equal(identities.length, 2); assert.notEqual(identities[0].id, identities[1].id); checks++;
const topics = await page.evaluate(() => {
const d = new DOMParser().parseFromString('<app-examdetail><div class="examdetail_header">安全课程 已答0/40</div></app-examdetail>', 'text/html');
return [YianLearningEngine.preflightTopic(d, location.href, { courses: [] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全课程'] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全'] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全领导力'] }, { 安全课程: ['安全领导力'] }), YianLearningEngine.preflightTopic(d, location.href, { records: [{ course: '安全课程' }] }, {})];
});
assert.deepEqual(topics.map(t => t.status), ['missing', 'matched', 'waiting', 'matched', 'matched']); checks++;
await setup(videoHTML, { rate: 4, mode: 'videoQuiz' });
await page.evaluate(() => qaEngine.start());
let state = await read();
assert.equal(state.rate, 2); assert.equal(state.answerInterval, 4); assert.equal(state.video.actualRate, 2);
assert.equal(state.total, 1); assert.equal(state.completed, 0); assert.equal(state.etaSeconds, 80);
await page.evaluate(() => { qaVideo.currentTime = 100; qaVideo.ended = true; });
state = await tick(1000); assert.equal(state.tasks[0].status, 'waitingPlatform'); assert.equal(state.completed, 0);
await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0);
await page.evaluate(() => document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true'));
state = await tick(); assert.equal(state.completed, 1); assert.equal(state.tasks[0].status, 'completed'); checks++;
await setup(); await page.evaluate(() => qaEngine.start());
await page.evaluate(() => qaEngine.configure({ videoPaused: true })); await tick();
assert.equal(await page.evaluate(() => qaVideo.paused), true);
await page.evaluate(() => qaEngine.resume()); assert.equal((await read()).videoPaused, false);
await page.evaluate(() => qaEngine.pause()); const prior = await page.evaluate(() => qaPlayCalls);
await tick(180000); assert.equal(await page.evaluate(() => qaPlayCalls), prior); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(videoHTML + '<div data-learning-quiz><p data-question>强制题</p></div>', { mode: 'video' });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting');
await tick(180000); assert.equal(await page.evaluate(() => qaQuizCalls), 0); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(videoHTML + '<div class="cdk-overlay-pane question-modal-pc"><div class="question_box"><p>真实portal题</p></div></div>', { mode: 'videoQuiz' }, { solveQuiz: true });
await page.evaluate(() => qaEngine.start()); await tick();
assert.equal(await page.evaluate(() => qaQuizCalls), 1); assert.equal((await read()).tasks[0].status, 'playing'); checks++;
await setup(videoHTML + '<div data-learning-quiz><p data-question>未知题</p></div>', { mode: 'videoQuiz' });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting');
assert.match(state.tasks[0].reason, /无精确答案/); await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(); await page.evaluate(() => qaEngine.start()); await tick(60000);
assert.equal(await page.evaluate(() => qaReloads), 0); assert.equal((await read()).tasks[0].status, 'retrying');
await tick(15000); assert.equal(await page.evaluate(() => qaReloads), 1); assert.equal((await read()).tasks[0].retryCount, 1);
const recovery = await page.evaluate(async () => { qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); qaEngine = qaCreate(); return qaEngine.tick(); });
assert.equal(recovery.status, 'running'); assert.equal(recovery.tasks[0].retryCount, 1);
for (let i = 0; i < 3; i++) { await tick(60000); await tick(15000); }
assert.equal(await page.evaluate(() => qaReloads), 3); assert.equal((await read()).tasks[0].status, 'failed'); checks++;
await setup(); await page.evaluate(() => qaEngine.start());
state = await page.evaluate(async () => { qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); qaEngine = qaCreate(); return qaEngine.tick(); });
assert.equal(state.status, 'paused'); assert.match(state.message, /人工刷新/);
await page.evaluate(() => qaEngine.resume()); assert.equal((await read()).status, 'running');
await page.evaluate(() => { qaSnapshot.accountId = 'other-account'; });
state = await tick(); assert.equal(state.status, 'paused'); assert.match(state.message, /账号/); checks++;
// 正常任务跳转有一次性授权;人工刷新同一地址没有该授权。
await setup('<app-learning-map-activity-list><div class="activity_content"><div class="activity_name">测试课程</div><div class="acitvity_other_info">课程</div></div></app-learning-map-activity-list>');
await page.evaluate(() => qaEngine.start());
state = await page.evaluate(async html => {
qaEngine.destroy(); await new Promise(r => setTimeout(r, 0));
history.replaceState(null, '', '/clientcn/course/authorized-target'); document.body.innerHTML = html; qaInstallVideo(); qaEngine = qaCreate(); return qaEngine.tick();
}, videoHTML);
assert.equal(state.status, 'running'); assert.equal(state.tasks.filter(t => t.kind === 'course')[0].status, 'expanded'); assert.equal(state.video.title, '视频一'); checks++;
// 播放器内的用户主动暂停不触发卡顿恢复。
await setup(videoHTML); await page.evaluate(() => qaEngine.start());
await page.evaluate(() => { qaClock += 1000; const v = document.querySelector('video'); v.style.cssText = 'width:200px;height:100px'; v.onclick = () => { qaVideo.paused = true; v.dispatchEvent(new Event('pause', { bubbles: true })); }; });
await page.locator('video').click(); state = await tick(); assert.equal(state.tasks[0].status, 'waiting'); assert.match(state.tasks[0].reason, /主动暂停/);
await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(videoHTML, {}, { rejectPlay: true });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting');
await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
const examDetail = '<app-examdetail><div class="examdetail_header">测试课程 已答0/40</div><div class="examdetail_button" onclick="window.qaEnterCount=(window.qaEnterCount||0)+1">开始答题</div></app-examdetail>';
await setup(examDetail, { mode: 'exam' });
await page.evaluate(() => qaEngine.start()); assert.equal(await page.evaluate(() => qaEnterCount), 1);
await page.evaluate(() => { document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>'; });
await tick(); assert.equal(await page.evaluate(() => qaExamCalls), 1);
await page.evaluate(() => { qaExam = { stage: 'waiting', predictedScore: 80, message: '预计得分正好80分等待人工' }; });
state = await tick(); assert.equal(state.tasks[0].status, 'waiting');
await tick(600000); assert.equal(await page.evaluate(() => qaExamCalls), 1); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup('<app-examdetail><div class="examdetail_header">没有题库的考试</div><div class="examdetail_button" onclick="window.qaWrongEnter=true">开始答题</div></app-examdetail>', { mode: 'exam' });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'skipped'); assert.equal(await page.evaluate(() => Boolean(window.qaWrongEnter)), false); checks++;
await setup(videoHTML + '<a data-kind="exam" href="/clientcn/exam/e1">测试课程</a>', { execution: 'parallel' }, { blockWorker: true });
state = await page.evaluate(() => qaEngine.start());
assert.equal(await page.evaluate(() => qaWorkerURLs.length), 1); assert.equal(state.tasks.find(t => t.kind === 'exam').status, 'waiting');
assert.match(state.tasks.find(t => t.kind === 'exam').reason, /拦截/); checks++;
await setup(videoHTML + '<a data-kind="exam" href="/clientcn/exam/e1">测试课程</a>', { execution: 'parallel' });
await page.evaluate(() => qaEngine.start());
const opened = await page.evaluate(() => qaWorkerURLs[0]); assert.match(opened, /__yian_worker=/);
state = await tick(31000); assert.equal(state.tasks.find(t => t.kind === 'exam').status, 'waiting'); assert.match(state.tasks.find(t => t.kind === 'exam').reason, /未确认启动/); checks++;
await setup(); await page.evaluate(() => qaEngine.start());
const exclusive = await page.evaluate(async () => { window.qaSecond = qaCreate(); await qaSecond.tick(); await qaSecond.resume(); return { first: qaEngine.getState(), second: qaSecond.getState(), plays: qaPlayCalls }; });
assert.equal(exclusive.plays, 1, '同类租约阻止第二页面重复播放'); assert.match(exclusive.second.tasks[0].reason, /另一个页面/); checks++;
// 两个实际标签通过同一GM式存储确认工作页,暂停信号同步,完全隔离真实业务网络。
const shared = new Map();
await context.exposeBinding('__learningGet', (_, key) => shared.has(key) ? JSON.parse(JSON.stringify(shared.get(key))) : null);
await context.exposeBinding('__learningSet', (_, key, value) => { shared.set(key, JSON.parse(JSON.stringify(value))); });
const relatedHTML = videoHTML.replace('</li>', '<p class="related_test">测试课程</p></li>');
await setup(relatedHTML, { execution: 'parallel' });
await page.evaluate(async () => { qaEngine.destroy(); qaStorage = { get: key => __learningGet(key), set: (key, value) => __learningSet(key, value) }; qaEngine = qaCreate(); await qaEngine.configure({ execution: 'parallel' }); await qaEngine.start(); });
const workerURL = await page.evaluate(() => qaWorkerURLs[0]);
const workerPage = await context.newPage();
try {
await workerPage.route('**/*', route => route.request().isNavigationRequest() ? route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><meta charset="utf-8">' + relatedHTML }) : route.abort());
await workerPage.goto(workerURL); await workerPage.addScriptTag({ content: source });
let workerState = await workerPage.evaluate(async examDetail => {
window.fixtureExamCalls = 0; window.fixtureExamPause = 0;
document.querySelector('.related_test').onclick = () => { document.body.innerHTML = examDetail; };
window.fixtureEngine = YianLearningEngine.create({ getSnapshot: () => ({ document, url: location.href, courses: ['测试课程'], accountId: 'fixture-account', bankVersion: 1, planId: 'fixture-plan' }),
storage: { get: key => __learningGet(key), set: (key, value) => __learningSet(key, value) }, now: () => 100000,
onExamStart: async () => { fixtureExamCalls++; return true; }, getExamState: () => window.fixtureExamState || ({ stage: 'answering', predictedScore: 84, verifiedCount: 32, total: 40, elapsedSeconds: 120, waitSeconds: 240 }), onExamPause: () => { fixtureExamPause++; } });
return fixtureEngine.tick();
}, examDetail);
assert.equal(workerState.status, 'running'); assert.equal(workerState.tasks.find(t => t.kind === 'exam').status, 'loading');
assert.equal(await workerPage.evaluate(() => Boolean(document.querySelector('app-examdetail'))), true, '无href时从真实课程页点击原关联考试入口');
await workerPage.evaluate(() => { history.replaceState(null, '', '/clientcn/examtest/e1?flag=test'); document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>'; });
workerState = await workerPage.evaluate(() => fixtureEngine.tick()); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 1);
state = await tick(); assert.match(state.message, /并行执行器已确认/); assert.equal(state.tasks.filter(t => t.kind === 'exam').length, 1, '详情转试卷不得复制同一考试');
assert.equal(state.exam.predictedScore, 84); assert.equal(state.exam.verified, 32); assert.equal(state.etaSeconds, 240, '并行总览显示另一场考试证据和剩余时间');
await page.evaluate(() => qaEngine.pause()); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
assert.equal(workerState.status, 'paused'); assert.ok(await workerPage.evaluate(() => fixtureExamPause) > 0);
await page.evaluate(() => qaEngine.resume()); await workerPage.evaluate(() => fixtureEngine.tick()); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
assert.equal(workerState.status, 'running'); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 2, '全局继续后重新核验考试');
await workerPage.evaluate(() => fixtureEngine.pause()); state = await tick(); assert.equal(state.status, 'paused', '工作页的全局暂停同步主页面');
await workerPage.evaluate(() => fixtureEngine.resume()); await tick(); state = await tick(); assert.equal(state.status, 'running');
await page.evaluate(() => document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true'));
state = await tick(); assert.equal(state.completed, 1); assert.equal(state.status, 'running', '视频先完成仍等待另一页考试');
await workerPage.evaluate(() => { window.fixtureExamState = { stage: 'submitted', predictedScore: 84, total: 40, verifiedCount: 32 }; });
await workerPage.evaluate(() => fixtureEngine.tick()); state = await tick();
assert.equal(state.completed, 2); assert.equal(state.status, 'finished', '本地任务完成后仍合并远端终态');
await page.evaluate(() => qaEngine.start()); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
// 旧执行器已完成,不会因新一轮开始重新答题。
assert.ok(['finished', 'paused'].includes(workerState.status)); checks++;
} finally { await workerPage.close(); }
await setup(); await page.evaluate(() => qaEngine.start());
await page.evaluate(() => { for (let i = 0; i < 520; i++) qaEngine.log('info', '日志' + i); });
assert.equal((await read()).logs.length, 500);
await page.evaluate(() => qaEngine.clearLogs()); state = await read(); assert.equal(state.logs.length, 0); assert.equal(state.tasks.length, 1); checks++;
await setup(); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.pause());
const initializedRace = await page.evaluate(async () => {
qaEngine.destroy();
const previous = qaStorage; let release; const gate = new Promise(resolve => { release = resolve; });
qaStorage = { get: async key => { if (key === 'session.v1') await gate; return previous.get(key); }, set: previous.set };
qaEngine = qaCreate(); const first = qaEngine.tick(); const update = qaEngine.configure({ rate: 2, mode: 'video' });
await new Promise(r => setTimeout(r, 10)); release(); await Promise.all([first, update]); return qaEngine.getState();
});
assert.equal(initializedRace.rate, 2); assert.equal(initializedRace.mode, 'video'); checks++;
// 长时间课中题异步流程也须续租,超过18秒后仍只有原执行器操作。
await setup(videoHTML + '<div data-learning-quiz><p data-question>异步核验题</p></div>', { mode: 'videoQuiz' });
await page.evaluate(() => {
qaEngine.destroy(); window.qaQuizCommitted = 0;
qaEngine = qaCreate({ solveQuiz: async () => {
await new Promise(resolve => { window.qaReleaseQuiz = resolve; });
if (qaEngine.getState().status !== 'running') return { status: 'waiting', reason: '任务已失效' };
qaQuizCommitted++; document.querySelector('[data-learning-quiz]').remove(); return { status: 'completed', verified: true };
} });
window.qaHeldStart = qaEngine.start();
});
await page.waitForFunction(() => typeof qaReleaseQuiz === 'function');
await tick(10000); await tick(10000);
const heldLease = await page.evaluate(async () => ({ lease: await qaStorage.get('lease:video'), clock: qaClock, status: qaEngine.getState().status }));
assert.equal(heldLease.status, 'running'); assert.ok(heldLease.lease.expires > heldLease.clock, '异步题超过18秒仍续租');
await page.evaluate(async () => { qaReleaseQuiz(); await qaHeldStart; });
assert.equal(await page.evaluate(() => qaQuizCommitted), 1); checks++;
// 租约被另一执行器取得后,旧异步题不得继续提交,也不得清除新租约。
await setup(videoHTML + '<div data-learning-quiz><p data-question>异步核验题</p></div>', { mode: 'videoQuiz' });
await page.evaluate(() => {
qaEngine.destroy(); window.qaReleaseQuiz = null; window.qaQuizCommitted = 0;
qaEngine = qaCreate({ solveQuiz: async () => {
await new Promise(resolve => { window.qaReleaseQuiz = resolve; });
if (qaEngine.getState().status !== 'running') return { status: 'waiting', reason: '任务已失效' };
qaQuizCommitted++; return { status: 'completed', verified: true };
} });
window.qaHeldStart = qaEngine.start();
});
await page.waitForFunction(() => typeof qaReleaseQuiz === 'function');
await page.evaluate(async () => { const lease = await qaStorage.get('lease:video'); lease.owner = 'another-worker'; lease.expires = qaClock + 18000; await qaStorage.set('lease:video', lease); });
state = await tick(1000); assert.equal(state.status, 'paused'); assert.match(state.message, /租约/);
assert.equal(await page.evaluate(() => qaVideo.paused), true);
await page.evaluate(async () => { qaReleaseQuiz(); await qaHeldStart; });
assert.equal(await page.evaluate(() => qaQuizCommitted), 0); assert.equal(await page.evaluate(async () => (await qaStorage.get('lease:video')).owner), 'another-worker'); checks++;
assert.deepEqual(errors, []);
console.log('learning-engine:', checks, 'checks passed (' + (archiveAvailable ? '真实归档DOM' : '合成DOM(未提供原站归档)') + '、平台完成、课中题、恢复、主题预检、租约、并行确认)');
} finally { await context.close(); await browser.close(); }
}
main().catch(error => { console.error(error); process.exitCode = 1; });
+5 -1
View File
@@ -34,7 +34,7 @@ async function main() {
await page.addScriptTag({ content: panelSource });
await page.evaluate(() => {
window.qaCalls = { positions: [], auto: [], copy: [], searches: [] };
window.qaBase = { role: '主操', bankTotal: 60, roleCounts: { 主操: 20, 班长: 20, 副操: 20 }, courses: ['安全课程'], canAnswer: true, canExport: true, pageMode: 'exercise', pageStatus: '已识别练习,可以选择答案。', storageMode: 'userscript' };
window.qaBase = { bankTotal: 60, uniqueTotal: 20, conflictTotal: 0, courses: ['安全课程'], canAnswer: true, canExport: true, pageMode: 'exercise', pageStatus: '已识别练习,可以选择答案。', storageMode: 'userscript' };
window.qaMakePanel = partial => {
window.qaPanel?.destroy();
window.qaPanel = YianAssistantPanel.create({
@@ -154,8 +154,11 @@ async function main() {
const beforeControls = await rect();
const controlsSaves = await positionSaves();
await host.getByTestId('tab-exam').click();
await host.getByRole('switch', { name: '自动选择答案', exact: true }).click();
await host.getByTestId('tab-settings').click();
await host.getByRole('switch', { name: '允许复制文字', exact: true }).click();
await host.getByTestId('tab-bank').click();
await host.getByRole('textbox', { name: '搜索题目' }).fill('安全控制');
await host.getByRole('textbox', { name: '搜索题目' }).press('Enter');
assert.deepEqual(await page.evaluate(() => [qaCalls.auto, qaCalls.copy]), [[true], [false]]);
@@ -177,6 +180,7 @@ async function main() {
assert(clamped.top >= 0 && clamped.right <= clamped.viewportWidth + 1, '超出视口的恢复位置应钳制');
await page.setViewportSize({ width: 390, height: 844 });
await visible();
await host.getByTestId('tab-bank').click();
await page.evaluate(() => window.qaPanel.renderSearch({ total: 20, items: Array.from({ length: 20 }, (_, index) => ({ number: index + 1, type: '单选', question: '检索结果内容变高后仍应在视口内。'.repeat(4), options: [{ label: 'A', text: '安全控制' }, { label: 'B', text: '其他选项' }], answer: 'A', role: '主操', course: '安全课程' })) }));
const grown = await visible();
assert(grown.height <= grown.viewportHeight + 1);
+199
View File
@@ -0,0 +1,199 @@
/* Browser plugin not available. 独立六栏面板:本机 HTTP + Chrome,不访问业务站点。 */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { chromium } = require('playwright');
const workspace = path.resolve(__dirname, '..');
const panelSource = fs.readFileSync(path.join(workspace, 'assistant-panel.js'), 'utf8');
const qaDir = process.env.YIAN_TABS_QA_DIR || path.join(os.tmpdir(), 'yianxue-tabs-qa');
fs.mkdirSync(qaDir, { recursive: true });
async function main() {
const server = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
response.end('<!doctype html><meta charset="utf-8"><title>谊安学六栏面板验证</title><style>body{margin:0;background:#edf2f8;color:#203451;font:17px system-ui}main{padding:40px}p{color:#65758c}</style><main><h1>谊安学助手</h1><p>离线模拟:按功能分栏,共用运行状态与全局暂停。</p></main>');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let browser;
try {
browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const errors = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); });
const origin = `http://127.0.0.1:${server.address().port}`;
await page.goto(origin + '/panel-six-tabs');
assert.equal(page.url(), origin + '/panel-six-tabs');
assert.equal(await page.title(), '谊安学六栏面板验证');
await page.addScriptTag({ content: panelSource });
await page.evaluate(() => {
window.qaCalls = { actions: [], settings: [], logs: [], search: [], auto: [], copy: [], export: [], exportEnabled: [], courses: [], imports: [], positions: [] };
window.qaPanel = YianAssistantPanel.create({
onTaskAction: action => qaCalls.actions.push(action),
onLearningSetting: (key, value) => qaCalls.settings.push([key, JSON.parse(JSON.stringify(value))]),
onLogAction: action => qaCalls.logs.push(action),
onSearch: (query, settings) => qaCalls.search.push({ query, settings }),
onAutoChange: value => qaCalls.auto.push(value),
onCopyChange: value => qaCalls.copy.push(value),
onExport: format => qaCalls.export.push(format),
onExportEnabledChange: value => qaCalls.exportEnabled.push(value),
onCourseChange: value => qaCalls.courses.push(value),
onImport: files => qaCalls.imports.push(files.map(file => file.name)),
onPositionChange: position => qaCalls.positions.push(position)
});
qaPanel.setState({ expanded: true, canAnswer: true, canExport: true, bankTotal: 5520, uniqueTotal: 1810, conflictTotal: 1,
courses: ['安全领导力', '风险管理'], pageMode: 'test', pageStatus: '考试已就绪,按答案全文匹配。',
metrics: { selected: 1, missing: 1, details: [{ number: 1, status: 'selected', question: '正确答案文字必须准确。', course: '安全领导力', correctTexts: ['技术控制'], currentLabels: ['D'] }, { number: 2, status: 'missing', reason: '题库未匹配' }] },
learning: { status: 'running', mode: 'all', execution: 'sequential', total: 9, completed: 2, skipped: 1, waiting: 1, failed: 0, etaSeconds: 362,
message: '当前等待考试核验,视频正常学习。', currentTitle: '1-1安全生产方针和目标',
tasks: [{ id: 'v1', type: 'video', title: '1-1安全生产方针和目标', status: 'playing' }, { id: 'e1', type: 'exam', title: '安全考试', status: 'waitingHuman', message: '预计得分 80 分,等待人工' }],
video: { title: '1-1安全生产方针和目标', status: 'playing', currentTime: 45, duration: 633, learnedTime: 27, actualRate: 1.5, refreshCount: 1 },
exam: { title: '安全考试', status: 'waitingHuman', predictedScore: 80, remainingSeconds: 1000, elapsedSeconds: 361, preflightMessage: '已关联安全领导力', message: '正好 80 分,等待人工操作。' },
logs: [{ time: '10:00:00', level: 'info', message: '已识别学习任务' }, { time: '10:00:04', level: 'warning', message: '预计 80 分,等待人工' }, { time: '10:00:05', level: 'error', message: '模拟恢复失败' }]
} });
});
const host = page.locator('#yian-question-assistant');
const tab = id => host.getByTestId('tab-' + id);
const pane = id => host.getByTestId('pane-' + id);
async function screenshot(name) { await page.screenshot({ path: path.join(qaDir, name), fullPage: false }); }
async function visible() {
await page.waitForFunction(() => {
const box = qaPanel.host.getBoundingClientRect();
return box.width > 0 && box.height > 0 && box.left >= -1 && box.top >= -1 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1;
});
}
await visible();
assert.equal(await host.getByRole('tab').count(), 6);
assert.equal(await tab('overview').getAttribute('aria-selected'), 'true');
assert(await pane('overview').isVisible());
assert.equal(await host.getByRole('radio').count(), 0, '统一题库不再有岗位选择');
assert(await host.getByTestId('learning-status').textContent().then(text => text.includes('等待人工')));
assert.equal(await host.getByTestId('setting-mode').inputValue(), 'all');
assert.equal(await host.getByTestId('setting-execution').inputValue(), 'sequential');
await screenshot('01-desktop-overview.png');
console.log('PASS six accessible tabs, unified bank, persistent status/progress and desktop layout');
const callsBeforeNavigation = await page.evaluate(() => JSON.stringify(qaCalls));
for (const key of ['video', 'exam', 'bank', 'console', 'settings', 'overview']) {
await tab(key).click();
assert.equal(await tab(key).getAttribute('aria-selected'), 'true');
assert(await pane(key).isVisible());
assert(await host.getByTestId('global-pause').isVisible());
assert.equal(await host.getByRole('tabpanel').filter({ visible: true }).count(), 1);
}
assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsBeforeNavigation, '切栏不能修改设置、答题、导出或启动任务');
await tab('overview').focus(); await tab('overview').press('ArrowRight');
assert.equal(await tab('video').getAttribute('aria-selected'), 'true');
await tab('video').press('End'); assert.equal(await tab('settings').getAttribute('aria-selected'), 'true');
await tab('settings').press('Home'); assert.equal(await tab('overview').getAttribute('aria-selected'), 'true');
console.log('PASS navigation changes visibility only, supports keyboard and keeps global pause visible');
await tab('bank').click();
await host.getByRole('textbox', { name: '搜索题目' }).fill('安全控制');
await host.getByRole('textbox', { name: '搜索题目' }).press('Enter');
assert.equal(await page.evaluate(() => qaCalls.search.at(-1).query), '安全控制');
await host.getByRole('combobox', { name: '选择课程' }).selectOption('安全领导力');
assert.equal(await page.evaluate(() => qaCalls.courses.at(-1)), '安全领导力');
await page.evaluate(() => qaPanel.renderSearch({ total: 1, items: [{ type: 'single', question: '安全生产方针是什么?', options: [{ label: 'A', text: '安全第一' }, { label: 'B', text: '效率第一' }], answer: 'A', sources: [{ role: '主操', course: '安全领导力', file: '原题库.csv' }] }] }));
assert.equal(await host.locator('.result').count(), 1);
assert(await host.locator('.answer').textContent().then(text => text.includes('安全第一')));
await tab('console').click(); await tab('bank').click();
assert.equal(await host.getByRole('textbox', { name: '搜索题目' }).inputValue(), '安全控制');
assert.equal(await host.getByRole('combobox', { name: '选择课程' }).inputValue(), '安全领导力');
assert.equal(await host.locator('.result').count(), 1);
await host.locator('input[type=file]').setInputFiles({ name: '统一题库.csv', mimeType: 'text/csv', buffer: Buffer.from('题目,答案\n题目,A') });
assert.deepEqual(await page.evaluate(() => qaCalls.imports), [['统一题库.csv']]);
await host.getByText('课程与考试主题关联', { exact: true }).click();
await host.getByRole('textbox', { name: '关联题库课程' }).fill('安全领导力\n风险管理');
await host.getByRole('button', { name: '保存关联', exact: true }).click();
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['associations', { '1-1安全生产方针和目标': ['安全领导力', '风险管理'] }]);
await host.getByRole('button', { name: '删除关联', exact: true }).click();
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['associations', {}]);
console.log('PASS search/import without role, persistent query/filter/results and exact theme associations');
await tab('exam').click();
assert.equal(await host.getByTestId('setting-answerInterval').inputValue(), '4');
assert.equal(await host.getByTestId('setting-minSubmitMinutes').inputValue(), '6');
assert(await pane('exam').textContent().then(text => text.includes('80 分(题库估计)')));
await host.getByTestId('setting-answerInterval').fill('5'); await host.getByTestId('setting-answerInterval').press('Tab');
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['answerInterval', 5]);
await host.getByRole('switch', { name: '自动选择答案', exact: true }).check();
assert.deepEqual(await page.evaluate(() => qaCalls.auto), [true]);
await host.locator('.answer-check summary').click();
assert(await host.locator('.answer-check-options').textContent().then(text => text.includes('D. 技术控制')));
await tab('overview').click(); await page.evaluate(() => qaPanel.setState({ learning: { etaSeconds: 359 } })); await tab('exam').click();
assert(await host.getByRole('switch', { name: '自动选择答案', exact: true }).isChecked());
assert(await host.locator('.answer-check').getAttribute('open') !== null);
assert.equal(await host.getByTestId('setting-answerInterval').inputValue(), '5');
await host.getByTestId('task-examResume').click();
assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'examResume');
await screenshot('02-desktop-exam.png');
console.log('PASS exam defaults/score/full answer evidence, partial learning updates and persistent switch/details state');
await tab('video').click();
await host.getByTestId('setting-rate').selectOption('2');
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['rate', 2]);
await host.getByTestId('task-videoPause').click();
assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'videoPause');
assert(await pane('video').textContent().then(text => text.includes('00:27') && text.includes('尚未确认')));
await tab('settings').click();
assert(!await host.locator('.export-content').isVisible());
await host.getByRole('switch', { name: '允许复制文字', exact: true }).uncheck();
assert.deepEqual(await page.evaluate(() => qaCalls.copy), [false]);
await host.getByRole('switch', { name: '导出题库', exact: true }).check();
assert(await host.locator('.export-content').isVisible());
await host.getByRole('button', { name: '导出 JSON', exact: true }).click();
assert.deepEqual(await page.evaluate(() => qaCalls.export), ['json']);
await host.getByTestId('setting-maxRefresh').fill('2'); await host.getByTestId('setting-maxRefresh').press('Tab');
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['maxRefresh', 2]);
await tab('bank').click(); await tab('settings').click();
assert.deepEqual(await page.evaluate(() => qaCalls.export), ['json'], '切栏不会再次导出');
assert.equal(await host.getByTestId('setting-maxRefresh').inputValue(), '2');
console.log('PASS video rate/local pause, copy and gated folded export, configurable bounded recovery');
await tab('console').click();
await host.getByRole('combobox', { name: '日志级别' }).selectOption('warning');
assert.equal(await host.locator('.log-entry').count(), 1);
await host.getByRole('button', { name: '复制日志', exact: true }).click();
await host.getByRole('button', { name: '清空显示', exact: true }).click();
assert.deepEqual(await page.evaluate(() => qaCalls.logs), ['copy', 'clear']);
await page.evaluate(() => qaPanel.setState({ learning: { logs: Array.from({ length: 510 }, (_, index) => ({ time: '10:00:00', level: 'info', message: '任务状态 ' + index })) } }));
await host.getByRole('combobox', { name: '日志级别' }).selectOption('all');
assert.equal(await host.locator('.log-entry').count(), 500);
await host.getByTestId('global-pause').click();
assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'pause');
await page.evaluate(() => qaPanel.setState({ learning: { status: 'paused' } }));
assert.equal(await host.getByTestId('global-pause').textContent(), '继续全部');
await host.getByTestId('global-pause').click();
assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'resume');
console.log('PASS filtered/capped logs and shared global pause/resume');
await tab('overview').click();
await page.evaluate(() => qaPanel.setState({ position: { left: 100, top: 20 }, learning: { logs: [], status: 'running' } }));
await visible();
const box = await host.boundingBox(); const brand = await host.locator('.brand').boundingBox();
await page.mouse.move(brand.x + 20, brand.y + brand.height / 2); await page.mouse.down();
await page.mouse.move(brand.x + 55, brand.y + brand.height / 2 + 20, { steps: 5 }); await page.mouse.up();
assert.equal(await host.locator('.toggle').getAttribute('aria-expanded'), 'true');
assert.equal(await page.evaluate(() => qaCalls.positions.length), 1);
const moved = await host.boundingBox(); assert(Math.abs(moved.x - box.x - 35) <= 2);
await page.setViewportSize({ width: 390, height: 844 }); await visible();
assert.equal(await host.locator('.navigation').evaluate(element => getComputedStyle(element).flexDirection), 'row');
await tab('bank').click(); await screenshot('03-mobile-bank.png');
await page.setViewportSize({ width: 320, height: 480 }); await visible();
await tab('overview').click(); await screenshot('04-small-overview.png');
await host.locator('.toggle').click();
assert.equal(await host.locator('.toggle').getAttribute('aria-expanded'), 'false');
assert(await host.locator('.collapse-summary').isVisible()); await visible();
await host.locator('.toggle').click(); await visible();
assert.equal(await tab('overview').getAttribute('aria-selected'), 'true');
assert.deepEqual(errors, []);
assert.equal(await page.locator('vite-error-overlay, nextjs-portal, #webpack-dev-server-client-overlay').count(), 0);
console.log('PASS desktop drag, mobile horizontal tabs, small viewport clamp, collapse summary and no console/runtime errors');
console.log('截图:' + qaDir);
} finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); }
}
main().catch(error => { console.error(error); process.exitCode = 1; });
+138
View File
@@ -0,0 +1,138 @@
/* Template-backed courseware popups with independent selection/grade models. */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const JSZip = require('../vendor/jszip.min.js');
const rootDir = path.resolve(__dirname, '..');
(async () => {
const savedZip = path.join(rootDir, '谊安学网页.zip');
if (fs.existsSync(savedZip)) {
const zip = await JSZip.loadAsync(fs.readFileSync(savedZip));
const bundle = Object.values(zip.files).find(file => /yianxue\/01\/.*\/5-es2015\./.test(file.name));
assert(bundle, '保存网页应包含课中题模板所在 bundle');
const source = await bundle.async('string');
for (const evidence of ['selectors:[["app-question-modal"]]', 'question-modal-pc', 'question_box', 'answer_box', 'examtest_content_question', 'examtest_content_list_des_optionsort', 'examtest_content_list_des_checked', 'doCheck(e,t)', 'this.getAnswer(s,n,a,r),this.showAnswerDialog()', 'this.continuePlay.emit(!0)', '查看答案', '回答正确', '下一题', '关闭']) {
assert(source.includes(evidence), '真实模板证据缺少:' + evidence);
}
console.log('PASS 保存 ZIP 静态确认 app-question-modal、PC overlay、文字/选中状态、判分及继续播放模板;未执行原脚本');
}
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const requests = [], errors = [];
try {
const context = await browser.newContext({ viewport: { width: 1000, height: 800 } });
await context.route('**/*', route => { requests.push(route.request().url()); return route.abort(); });
const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
for (const name of ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', 'quiz-adapter.js']) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') });
async function setup(config = {}) {
await page.evaluate(config => {
document.body.innerHTML = '<style>.question_box,.answer_box{display:block;padding:20px;border:1px solid}.hidden,[hidden],[aria-hidden="true"]{display:none!important}.examtest_content_list_des{padding:8px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}.modal-footer{padding:8px}</style><app-question-modal></app-question-modal><div class="cdk-overlay-container"><div class="cdk-overlay-pane question-modal-pc"></div></div>';
window.qaActive = true; window.qaAbort = new AbortController();
window.qaModel = { optionClicks: [], submits: 0, advances: 0, grading: false, index: 0, selected: [], config };
const single = { type: '单选', question: '压力达到 2 MPa 时应该怎样操作?', bank: ['保持 MPa 单位并核对', '切换 mPa 单位', '任意操作'], page: ['任意操作', '切换 mPa 单位', '保持 MPa 单位并核对'], wanted: ['保持 MPa 单位并核对'] };
const multi = { type: '多选', question: '下列哪些属于准确的完整措施?', bank: ['措施甲(2)', '措施乙', '错误措施'], page: ['错误措施', '措施乙', '措施甲(2)'], wanted: ['措施甲(2)', '措施乙'] };
const judge = { type: '判断', question: 'MPa 与 mPa 应当区分。', bank: ['对', '错'], page: ['错', '对'], wanted: ['对'] };
window.qaQuestions = config.multiple ? [single, multi, judge] : [config.type === 'multi' ? multi : config.type === 'judge' ? judge : single];
window.qaSnapshot = { revision: 1, records: qaQuestions.map((q, i) => YianBankStore.validateRecord({ role: '', course: '安全课中题', number: i + 1, type: q.type, question: q.question,
options: q.bank.map((text, index) => ({ label: String.fromCharCode(65 + index), text })), answer: q.wanted.map(text => String.fromCharCode(65 + q.bank.indexOf(text))).join('') })) };
if (config.conflict) qaSnapshot.records.push(YianBankStore.validateRecord({ ...qaSnapshot.records[0], course: '安全课中题', sourceFile: '冲突题库.json', answer: 'B', correctTexts: undefined }));
if (config.missing) qaSnapshot.records = [];
const pane = document.querySelector('.question-modal-pc');
const escape = text => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const grade = () => {
const q = qaQuestions[qaModel.index]; qaModel.grading = true;
const correct = !config.wrongResult && JSON.stringify(qaModel.selected.slice().sort()) === JSON.stringify(q.wanted.slice().sort());
pane.innerHTML = '<div class="answer"><div class="answer_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="model_dec"><span>' + (correct ? '恭喜你' : '对不起') + '</span><span class="model_dec_span">' + (correct ? '回答正确' : '回答错误') + '</span></div><div class="model_answer_wrapper"><div class="model_answer">参考答案:' + escape(q.wanted.join(';')) + '</div></div></div><div class="modal-footer"><button type="button"><span aria-hidden="true">' + (qaModel.index < qaQuestions.length - 1 ? '下一题' : '关闭') + '</span></button></div></div></div></div></div>';
pane.querySelector('button').addEventListener('click', () => {
qaModel.advances++;
if (qaModel.index < qaQuestions.length - 1) {
qaModel.index++;
if (config.deferNext) { pane.innerHTML = ''; setTimeout(render, 150); }
else render();
}
else pane.innerHTML = '';
});
};
const render = () => {
const q = qaQuestions[qaModel.index];
qaModel.selected = config.type === 'multi' || qaModel.index === 1 ? ['错误措施'] : [];
pane.innerHTML = '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question">' + ['单选', '多选', '判断'].map(type => '<p class="' + (q.type === type ? 'show' : 'hidden') + '"><span>[' + type + ']</span>' + escape(q.question) + '</p>').join('') + '</div><div class="examtest_content_list">' + q.page.map((text, i) => '<div class="option-row"><div class="examtest_content_list_des' + (qaModel.selected.includes(text) ? ' examtest_content_list_des_checked' : '') + '"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + i) + '</span>' + escape(text) + ' </div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
const footer = pane.querySelector('.modal-footer');
const submit = () => {
if (footer.querySelector('button')) return;
const button = document.createElement('button'); button.type = 'button'; button.innerHTML = '<span aria-hidden="true">查看答案</span>'; footer.append(button);
button.addEventListener('click', () => {
qaModel.submits++;
if (config.noGrade) return;
if (config.deferGrade) { pane.innerHTML = ''; window.qaReleaseGrade = grade; }
else grade();
});
};
pane.querySelectorAll('.option-row').forEach((row, i) => row.addEventListener('click', () => {
qaModel.optionClicks.push({ index: qaModel.index, text: q.page[i], label: String.fromCharCode(65 + i) });
if (config.noSelection) return;
if (q.type === '多选') qaModel.selected = qaModel.selected.includes(q.page[i]) ? qaModel.selected.filter(text => text !== q.page[i]) : qaModel.selected.concat(q.page[i]);
else qaModel.selected = [q.page[i]];
pane.querySelectorAll('.examtest_content_list_des').forEach((node, j) => node.classList.toggle('examtest_content_list_des_checked', qaModel.selected.includes(q.page[j])));
submit();
if (config.cancelAfterClick) qaActive = false;
}));
};
render();
if (config.image) { const image = document.createElement('img'); image.alt = '需要人工识别的图片'; pane.querySelector('.examtest_content_question').append(image); }
if (config.unknown) pane.innerHTML = '<div class="unrecognized-popup"><p>新题型</p><button>提交</button></div>';
window.qaSolve = () => YianQuizAdapter.solve({ document, snapshot: qaSnapshot, matcher: YianAnswerMatcher,
context: { course: '当前视频标题', courses: ['安全课中题'], answerInterval: 0 }, signal: qaAbort.signal, getActive: () => qaActive, timeoutMs: config.timeoutMs || 300 });
}, config);
}
await setup();
let result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'complete'); assert.equal(result.verified, true);
let model = await page.evaluate(() => qaModel);
assert.deepEqual(model.optionClicks.map(item => item.label), ['C'], '按原答案全文映射到打乱后的 C,不使用原 A');
assert.equal(model.submits, 1); assert.equal(model.advances, 1);
assert.equal(await page.locator('.question_box,.answer_box').count(), 0, '正确结果后正常关闭弹题');
await setup({ multiple: true, deferNext: true });
result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'complete'); assert.equal(result.verifiedCount, 3);
model = await page.evaluate(() => qaModel);
assert.deepEqual(model.optionClicks.filter(item => item.index === 1).map(item => item.label), ['A', 'B', 'C'], '多选先去除错误 A,再补选 B、C');
assert.equal(model.submits, 3); assert.equal(model.advances, 3);
console.log('PASS 空 app-question-modal + 真实 PC portal:乱序全文映射、单选/多选/判断、完整集合回读、判分及下一题异步空隙/关闭');
for (const config of [{ conflict: true }, { missing: true }, { image: true }, { unknown: true }]) {
await setup(config); result = await page.evaluate(() => qaSolve());
assert.equal(result.status, config.conflict ? 'conflict' : config.missing ? 'missing' : 'waiting');
model = await page.evaluate(() => qaModel);
assert.equal(model.optionClicks.length, 0); assert.equal(model.submits, 0);
}
console.log('PASS 冲突、未命中、图片与未知 DOM 均不猜选、不提交');
await setup({ cancelAfterClick: true }); result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 0);
await setup();
result = await page.evaluate(() => { qaAbort.abort(); return qaSolve(); });
assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).optionClicks.length, 0);
await setup({ noSelection: true }); result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 0);
console.log('PASS getActive 失效、signal 中止、网站选中状态无回读时立即停止');
await setup({ deferGrade: true, timeoutMs: 2000 });
await page.evaluate(() => { window.qaDone = false; window.qaPromise = qaSolve().then(result => { qaDone = true; return result; }); });
await page.waitForFunction(() => typeof window.qaReleaseGrade === 'function');
assert.equal(await page.evaluate(() => qaDone), false, '题干消失但网站尚未返回结果时不得视为完成');
await page.evaluate(() => qaReleaseGrade());
result = await page.evaluate(() => qaPromise);
assert.equal(result.status, 'complete'); assert.equal(result.verified, true);
await setup({ wrongResult: true }); result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'waiting'); assert.match(result.reason, /错误/);
assert.equal((await page.evaluate(() => qaModel)).advances, 0, '网站判错时不自动点击下一题');
await setup({ noGrade: true }); result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 1, '结果未确认只提交一次');
console.log('PASS 题干消失不冒充结果、待网站明确判分、判错等待人工与未知结果不重复提交');
assert.equal(requests.length, 0, '所有适配测试不得连接业务或任何网络');
assert.deepEqual(errors, []);
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+1 -1
View File
@@ -1,6 +1,6 @@
const { spawnSync } = require('node:child_process');
const path = require('node:path');
for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs']) {
for (const file of ['bank-engine.cjs', 'unified-bank.cjs', 'runner-browser.cjs', 'verify.cjs', 'learning-engine.cjs', 'quiz-adapter.cjs', 'exam-controller.cjs', 'panel-tabs.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs', 'workflow.cjs', 'video-workflow.cjs']) {
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
if (result.status !== 0) process.exit(result.status || 1);
}
+2 -2
View File
@@ -134,12 +134,12 @@ async function main() {
await setup([{ type: '单选' }], 'delayed');
await delayedStart();
await page.evaluate(() => { qaContext.role = '班长'; });
await page.evaluate(() => { qaContext.topicKey = '新主题关联'; });
await finishMatch();
assert.equal(await page.evaluate(() => qaClicks.length), 0);
await page.evaluate(() => { window.qaMode = 'normal'; return qaRunner.start(); });
assert.equal(await page.evaluate(() => qaClicks.length), 1);
console.log('PASS role change cancels stale match and restart uses new role');
console.log('PASS topic association change cancels stale match and restart revalidates');
for (const field of ['revision', 'routeKey', 'sourceUrl']) {
await setup([{ type: '单选' }], 'delayed');
+123
View File
@@ -0,0 +1,123 @@
/* Unified-bank identity, provenance, topic boundaries and durable v2 migration. */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const rootDir = path.resolve(__dirname, '..');
const JSZip = require(path.join(rootDir, 'vendor', 'jszip.min.js'));
const V2 = 'yianxue.assistant.bank.v2', V3 = 'yianxue.assistant.bank.v3';
function runtime(memory = new Map(), failWrites = false) {
const environment = {
TextDecoder, URL, URLSearchParams, JSZip,
GM_getValue: (key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback,
GM_setValue: (key, value) => { if (failWrites) throw new Error('quota exhausted'); memory.set(key, structuredClone(value)); }
};
vm.createContext(environment);
for (const name of ['bank-store.js', 'answer-matcher.js']) vm.runInContext(fs.readFileSync(path.join(rootDir, name), 'utf8'), environment);
return { store: environment.YianBankStore, matcher: environment.YianAnswerMatcher, memory };
}
function file(name, content) {
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
return { name, size: bytes.length, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) };
}
function row(overrides = {}) {
return { role: '主操', course: '安全领导力-1', sourceUrl: 'http://old/clientcn/exam/leadership/questions', sourceFile: '题库/主操/领导力.csv',
number: 1, type: '单选', question: '技术措施应当怎样执行?',
options: [{ label: 'A', text: '先核对要求' }, { label: 'B', text: '任意操作' }], answer: 'A', ...overrides };
}
(async () => {
const { store, matcher } = runtime();
const origins = [row(), row({ role: '副操', sourceFile: '题库/副操/领导力.csv' }), row({ role: '班长', sourceFile: '题库/班长/领导力.csv' }),
row({ role: '', sourceFile: '独立题库.json' })];
let state = await store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]);
assert.equal(state.schemaVersion, 3);
assert.equal(state.total, 4, '保留四份来源记录');
assert.equal(state.uniqueTotal, 1, '跨岗位同题同答案仅聚合为一项');
assert.equal(state.questionTotal, 1);
assert.equal(state.conflictTotal, 0);
assert.equal(state.counts.未标明, 1);
for (const role of ['', '主操', '班长', '副操', '旧版遗留值']) {
const found = matcher.search('技术措施', { role }, state);
assert.equal(found.total, 1);
assert.equal(found.items[0].sources.length, 4, '聚合显示全部原岗位和来源文件');
assert.equal(matcher.match(row(), { role }, state).status, 'matched');
}
const reordered = { ...row(), options: [{ label: 'A', text: '任意操作' }, { label: 'B', text: '先核对要求' }] };
const mapped = matcher.match(reordered, {}, state);
assert.deepEqual(Array.from(mapped.correctLabels), ['B']);
assert.deepEqual(Array.from(mapped.correctTexts), ['先核对要求']);
state = await store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]);
assert.equal(state.total, 4, '重复导入不增加记录');
const conflict = row({ role: '副操', course: '另一主题', sourceUrl: 'http://old/clientcn/exam/other/questions', sourceFile: '另一主题.csv', answer: 'B' });
state = await store.importFiles([file('另一主题.json', JSON.stringify({ records: [conflict] }))]);
assert.equal(state.total, 5);
assert.equal(state.uniqueTotal, 2);
assert.equal(state.questionTotal, 1);
assert.equal(state.conflictTotal, 1);
assert.equal(matcher.match(reordered, {}, state).status, 'conflict', '四比一也不得多数决');
assert(matcher.search('技术措施', {}, state).items.every(item => item.conflict));
assert.equal(matcher.match(reordered, { course: '安全领导力-1' }, state).status, 'matched');
assert.equal(matcher.match(reordered, { sourceUrl: 'http://new/clientcn/exam/other/questions' }, state).correctLabels[0], 'A');
assert.equal(matcher.match(reordered, { courses: ['安全领导力-1'], course: '1-1安全生产方针和目标' }, state).correctLabels[0], 'B');
assert.equal(matcher.match(reordered, { courses: ['另一主题'] }, state).correctLabels[0], 'A');
assert.equal(matcher.match(reordered, { courses: ['不存在'], sourceUrl: origins[0].sourceUrl }, state).status, 'missing', '主题名单没有匹配记录不得退回其他来源');
assert.equal(matcher.match(reordered, { courses: [] }, state).status, 'missing');
assert.equal(matcher.match(reordered, { courses: ['安全领导力'] }, state).status, 'missing', '课程名单必须为全名,关键词不能授权作答');
assert.equal(matcher.match(reordered, { courses: ['安全领导力-1', '另一主题'] }, state).status, 'conflict');
const legacyValue = { schemaVersion: 2, records: origins.concat(origins[0]), revision: 8, updatedAt: '2026-10-17T00:00:00.000Z' };
const oldMemory = new Map([[V2, JSON.stringify(legacyValue)]]);
const oldBackup = oldMemory.get(V2);
const migrated = await runtime(oldMemory).store.load();
assert.equal(migrated.schemaVersion, 3);
assert.equal(migrated.total, 4, '升级过程去掉完全重复的来源记录');
assert.equal(migrated.uniqueTotal, 1);
assert.equal(migrated.revision, 8);
assert.equal(migrated.updatedAt, legacyValue.updatedAt);
assert.equal(migrated.migration.status, 'complete');
assert.equal(oldMemory.get(V2), oldBackup, '升级成功依然保留旧库原值');
assert.equal((await runtime(oldMemory).store.load()).total, 4, '重复加载不会再次追加 v2');
const migratedTwice = await runtime(oldMemory).store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]);
assert.equal(migratedTwice.total, 4);
const quotaMemory = new Map([[V2, structuredClone(legacyValue)]]);
const quotaBefore = JSON.stringify(quotaMemory.get(V2));
const quota = await runtime(quotaMemory, true).store.load();
assert.equal(quota.total, 4, '迁移保存失败时仍可读取已经校验的旧库');
assert.equal(quota.migration.status, 'pending');
assert.match(quota.migration.error, /quota/);
assert.equal(JSON.stringify(quotaMemory.get(V2)), quotaBefore);
assert.equal(quotaMemory.has(V3), false, '失败不得写入半份新库');
for (const damaged of ['not json', { records: [row({ answer: 'H' })] }, { records: 'bad' }]) {
const memory = new Map([[V2, damaged]]);
const before = JSON.stringify(memory.get(V2));
await assert.rejects(runtime(memory).store.load(), /损坏|无效/);
assert.equal(JSON.stringify(memory.get(V2)), before);
assert.equal(memory.has(V3), false);
}
const corruptedV3 = new Map([[V2, legacyValue], [V3, { schemaVersion: 3, records: [row({ correctTexts: ['任意操作'] })] }]]);
await assert.rejects(runtime(corruptedV3).store.load(), /无效/, 'v3 损坏时不得静默恢复较旧题库并覆盖新数据');
assert.deepEqual(corruptedV3.get(V2), legacyValue);
const unifiedPath = path.join(rootDir, '题库', '统一题库.csv');
if (fs.existsSync(unifiedPath)) {
const real = runtime();
const unifiedFile = file('统一题库.csv', fs.readFileSync(unifiedPath));
const once = await real.store.importFiles([unifiedFile]);
const twice = await real.store.importFiles([unifiedFile]);
assert.equal(once.total, 5520);
assert.equal(twice.total, once.total);
assert.equal(twice.uniqueTotal, once.uniqueTotal);
assert.equal(new Set(twice.records.map(record => record.sourceFile)).size, 28);
assert(twice.conflictTotal > 0, '真实跨来源冲突必须保留');
console.log('PASS 统一 CSV:5520 条来源 / ' + twice.uniqueTotal + ' 个答案组合 / ' + twice.questionTotal + ' 道题 / ' + twice.conflictTotal + ' 道冲突题,重复导入记录数不变');
}
console.log('PASS 统一题库跨岗位与无岗位、文字映射、来源聚合、冲突不多数决、准确主题名单、v2 安全迁移及损坏/配额保护');
})().catch(error => { console.error(error); process.exitCode = 1; });
+1
View File
@@ -87,6 +87,7 @@ function parseCSV(text) {
await page.evaluate(() => YianAssistant.ready);
await page.waitForFunction(() => YianAssistant.getState().pageReady);
await page.waitForTimeout(800);
await page.getByTestId('tab-settings').click();
assert.equal(downloads.length, 0, '导出开关默认关闭,不应自动下载');
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
assert(await page.getByRole('button', { name: '导出 CSV', exact: true, includeHidden: true }).isHidden());
+144
View File
@@ -0,0 +1,144 @@
/* Browser plugin not available. 完整 bundle 视频→真实模板课中题→平台完成;全部业务 URL 被本地响应拦截。 */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { chromium } = require('playwright');
const workspace = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8');
const qaDir = process.env.YIAN_VIDEO_QA_DIR || path.join(os.tmpdir(), 'yianxue-video-workflow-qa');
fs.mkdirSync(qaDir, { recursive: true });
const fixtureURL = 'http://10.119.67.7/clientcn/course/video-workflow';
const course = '视频联动安全课程';
const fixture = `<!doctype html><meta charset="utf-8"><title>谊安学视频联动验证</title><style>
body{margin:26px;background:#edf2f8;color:#203451;font:15px/1.5 system-ui}h1{font-size:24px}app-course{display:block;max-width:700px}li{list-style:none;background:white;padding:14px;border-radius:8px;margin-bottom:15px}li>p{font-weight:600}video{display:block;width:640px;max-width:100%;height:220px;background:#172a49;border-radius:10px}.menu_courseware_left{font-size:12px;color:#64758e}.cn_courseware_img_icon{color:#64758e}.hidden,[hidden],[aria-hidden="true"]{display:none!important}.question-modal-pc{position:fixed;inset:130px auto auto 60px;width:530px;max-width:calc(100vw - 50px);padding:20px;background:white;box-shadow:0 10px 60px #172a4955;border-radius:12px;z-index:1000}.examtest_content_list_des{padding:10px;margin:6px 0;border:1px solid #dae3ef;border-radius:6px;cursor:pointer}.examtest_content_list_des_optionsort{margin-right:10px}.examtest_content_list_des_checked{background:#e0f3e7}.modal-footer button{padding:7px 16px;background:#225aca;color:white;border:0;border-radius:6px}
</style><h1>视频学习与课中答题联动</h1><p>独立模拟播放与网站判分,所有网络已拦截。</p><app-course><div data-course-title>${course}</div><li class="cn_courseware_img active" data-courseware-id="workflow-video" data-required-seconds="100"><p>${course}</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习10秒</div><div class="courseware_finished_status"><span class="en_courseware_finished en_courseware_noFinished">尚未完成</span></div></li><app-course-courseware-video><app-video-base><video id="player1" controls></video></app-video-base></app-course-courseware-video><app-question-modal></app-question-modal></app-course><div class="cdk-overlay-container"></div>`;
async function main() {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const memory = new Map(), requests = [], blocked = [], errors = [];
try {
const context = await browser.newContext({ viewport: { width: 1366, height: 900 } });
await context.exposeBinding('__gmRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__gmWrite', (_, key, value) => { memory.set(key, structuredClone(value)); });
await context.route('**/*', route => {
requests.push(route.request().url());
if (route.request().isNavigationRequest() && route.request().url() === fixtureURL) return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: fixture });
blocked.push(route.request().url()); return route.abort();
});
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); });
await page.goto(fixtureURL);
assert.equal(page.url(), fixtureURL); assert.equal(await page.title(), '谊安学视频联动验证');
await page.evaluate(() => {
window.qaMedia = { currentTime: 10, duration: 100, ended: false, paused: true, playbackRate: 1, playCalls: 0, pauseCalls: 0 };
const video = document.querySelector('video');
for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => qaMedia[key], set: value => { qaMedia[key] = value; } });
video.play = async () => { qaMedia.playCalls++; qaMedia.paused = false; };
video.pause = () => { qaMedia.pauseCalls++; qaMedia.paused = true; };
window.qaQuiz = { clicks: [], selected: [], submits: 0, correct: false, closes: 0, videoPausedAtSelection: false };
window.qaShowQuiz = () => {
const pane = document.createElement('div'); pane.className = 'cdk-overlay-pane question-modal-pc';
const texts = ['任意操作', '切换 mPa 单位', '保持 MPa 单位并核对'];
pane.innerHTML = '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question"><p class="show"><span>[单选]</span>压力达到 2 MPa 时应该怎样操作?</p><p class="hidden">[多选]隐藏模板</p><p class="hidden">[判断]隐藏模板</p></div><div class="examtest_content_list">' + texts.map((text, index) => '<div class="option-row"><div class="examtest_content_list_des"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + index) + '</span>' + text + '</div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
document.querySelector('.cdk-overlay-container').append(pane);
const grade = () => {
qaQuiz.submits++;
qaQuiz.correct = JSON.stringify(qaQuiz.selected) === JSON.stringify(['保持 MPa 单位并核对']);
pane.innerHTML = '<div class="answer"><div class="answer_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="model_dec"><span>网站判分</span><span class="model_dec_span">' + (qaQuiz.correct ? '回答正确' : '回答错误') + '</span></div><div class="model_answer_wrapper"><div class="model_answer">参考答案:保持 MPa 单位并核对</div></div></div><div class="modal-footer"><button type="button"><span aria-hidden="true">关闭</span></button></div></div></div></div></div>';
pane.querySelector('button').addEventListener('click', () => { qaQuiz.closes++; pane.remove(); });
};
pane.querySelectorAll('.option-row').forEach((row, index) => row.addEventListener('click', () => {
qaQuiz.clicks.push({ label: String.fromCharCode(65 + index), text: texts[index] });
qaQuiz.videoPausedAtSelection = qaMedia.paused;
qaQuiz.selected = [texts[index]];
pane.querySelectorAll('.examtest_content_list_des').forEach((node, i) => node.classList.toggle('examtest_content_list_des_checked', i === index));
const footer = pane.querySelector('.modal-footer');
if (!footer.querySelector('button')) { const button = document.createElement('button'); button.type = 'button'; button.innerHTML = '<span aria-hidden="true">查看答案</span>'; button.addEventListener('click', grade); footer.append(button); }
if (window.qaPauseAfterSelection) { qaPauseAfterSelection = false; YianAssistant.taskAction('pause'); }
}));
};
});
const sandbox = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);const GM_setValue=(key,value)=>window.__gmWrite(key,value);\n' + source + '\n})();';
await page.addScriptTag({ content: sandbox }); await page.evaluate(() => YianAssistant.ready);
const host = page.locator('#yian-question-assistant');
await host.getByRole('button', { name: '展开题库助手', exact: true }).click();
await host.getByTestId('tab-bank').click();
await host.locator('input[type=file]').setInputFiles({ name: '视频课中题.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify([{ course, type: '单选', question: '压力达到 2 MPa 时应该怎样操作?', options: ['保持 MPa 单位并核对', '切换 mPa 单位', '任意操作'].map((text, index) => ({ label: String.fromCharCode(65 + index), text })), answer: 'A' }])) });
await page.waitForFunction(() => YianAssistant.getSnapshot().total === 1);
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().records[0].role), '', '不带岗位的统一题库可导入');
await page.evaluate(async () => {
await YianAssistant.setLearningSetting('mode', 'videoQuiz');
await YianAssistant.setLearningSetting('rate', 2);
await YianAssistant.setLearningSetting('answerInterval', 0);
await YianAssistant.taskAction('start');
});
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'running' && !qaMedia.paused && qaMedia.playbackRate === 2);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().total), 1);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0);
console.log('PASS 完整 bundle:无岗位题库导入、videoQuiz 调度、独立播放器真实 2X 状态');
const beforeTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id }));
for (const key of ['overview', 'video', 'exam', 'bank', 'console', 'settings', 'video']) await host.getByTestId('tab-' + key).click();
const afterTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id }));
assert.deepEqual(afterTabs, beforeTabs, '切栏不得重新播放、暂停或领取新任务');
await host.getByTestId('global-pause').click();
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'paused' && qaMedia.paused);
await page.evaluate(async () => {
await YianAssistant.setLearningSetting('stallSeconds', 1);
await YianAssistant.setLearningSetting('retryDelaySeconds', 1);
qaShowQuiz();
});
const pausedPlays = await page.evaluate(() => qaMedia.playCalls);
await page.waitForTimeout(2400);
assert.equal(await page.evaluate(() => qaMedia.playCalls), pausedPlays, '全局暂停后不能自动续播');
assert.equal(await page.evaluate(() => qaQuiz.clicks.length + qaQuiz.submits), 0, '暂停期间不操作课中题');
assert.equal(requests.length, 1, '暂停期间没有故障刷新或其他业务请求');
assert.equal(await page.locator('.question_box').count(), 1);
await page.evaluate(async () => {
await YianAssistant.setLearningSetting('stallSeconds', 60);
await YianAssistant.setLearningSetting('retryDelaySeconds', 15);
window.qaPauseAfterSelection = true;
});
await host.getByTestId('global-pause').click();
await page.waitForFunction(() => qaQuiz.clicks.length === 1 && YianAssistant.getLearningState().status === 'paused');
await page.waitForTimeout(400);
assert.equal(await page.evaluate(() => qaQuiz.submits), 0, '选中后暂停必须取消后续查看答案/提交');
assert.equal(await page.evaluate(() => qaMedia.paused), true);
await host.getByTestId('global-pause').click();
await page.waitForFunction(() => qaQuiz.closes === 1 && !qaMedia.paused);
const quiz = await page.evaluate(() => qaQuiz);
assert.deepEqual(quiz.clicks, [{ label: 'C', text: '保持 MPa 单位并核对' }], '原库 A 按正确文字映射到当前 C,保留 MPa/mPa 大小写差异');
assert.deepEqual(quiz.selected, ['保持 MPa 单位并核对']);
assert(quiz.videoPausedAtSelection, '课中答题前实际暂停播放器');
assert.equal(quiz.submits, 1); assert.equal(quiz.correct, true); assert.equal(quiz.closes, 1);
assert.equal(await page.locator('.question_box,.answer_box,.question-modal-pc').count(), 0);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0);
assert(await page.evaluate(() => qaMedia.playCalls) > pausedPlays, '正常关闭课中题后继续播放器');
console.log('PASS 六栏不影响任务;全局暂停停止课中答题和恢复;继续后真实 portal 全文选中→查看答案→网站判正确→关闭→续播');
await page.evaluate(() => { qaMedia.currentTime = qaMedia.duration; qaMedia.ended = true; document.querySelector('.menu_courseware_left').textContent = '已学习90秒'; });
await page.waitForFunction(() => YianAssistant.getLearningState().tasks[0].status === 'waitingPlatform');
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0, '播放到末尾但平台未确认时不得完成');
await host.getByTestId('tab-overview').click();
await page.screenshot({ path: path.join(qaDir, '01-waiting-platform.png') });
await page.evaluate(() => {
document.querySelector('app-course-courseware-video').setAttribute('data-is-learned', 'true');
document.querySelector('.menu_courseware_left').textContent = '已学习100秒';
});
await page.waitForFunction(() => YianAssistant.getLearningState().completed === 1);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().tasks[0].status), 'completed');
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'finished');
await page.screenshot({ path: path.join(qaDir, '02-platform-completed.png') });
await host.getByTestId('tab-video').click();
assert(await host.getByTestId('pane-video').textContent().then(text => text.includes('已确认完成')), '视频栏必须同步平台已完成状态');
assert.equal(requests.length, 1); assert.deepEqual(blocked, []); assert.deepEqual(errors, []);
assert.equal(await page.locator('vite-error-overlay,nextjs-portal,#webpack-dev-server-client-overlay').count(), 0);
console.log('PASS 视频末尾等待平台;明确 data-is-learned 后完成任务并更新面板;零业务网络/刷新、无控制台错误');
console.log('截图:' + qaDir);
} finally { await browser.close(); }
}
main().catch(error => { console.error(error); process.exitCode = 1; });
+92
View File
@@ -0,0 +1,92 @@
/* Full userscript workflow, simulated business URL only: no business requests. */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const root = path.resolve(__dirname, '..');
const script = fs.readFileSync(path.join(root, '谊安学助手 2.0.user.js'), 'utf8');
function fixture() {
const questions = Array.from({ length: 25 }, (_, i) => `<div app-exam-question><div class="pc_examtest_content"><div class="pc_examtest_content_question"><p>[单选] ${i + 1}. 联动验证题${i}<span class="pc_examtest_content_question_value">(4分)</span></p></div><div class="pc_examtest_content_list"><div class="pc_examtest_content_list_des"><span class="examtest_content_list_des_optionsort">A</span>错误${i}</div><div class="pc_examtest_content_list_des"><span class="examtest_content_list_des_optionsort">B</span>正确${i}</div></div></div></div>`).join('');
return `<!doctype html><meta charset="utf-8"><title>助手完整流程验证</title><style>body{font:14px system-ui}.pc_examtest_left{max-width:700px}.pc_examtest_content{padding:8px}.pc_examtest_content_list_des{padding:5px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}.modal[aria-hidden=true]{display:none}</style><app-examtest><div class="pc_examtest"><div class="pc_examtest_left"><div class="pc_examtest_title">完整流程考试</div>${questions}</div><div class="pc_examtest_card">${Array.from({ length: 25 }, (_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div><div class="pc_examtest_countdown"><span class="time">00:30:00</span></div><div class="pc_examtest_commit">交卷</div></div></app-examtest><div role="dialog" class="modal" aria-hidden="true"><p>仍有题目未答,是否确认交卷?</p><button>确认交卷</button><button>取消</button></div><div data-exam-result hidden>交卷成功,成绩 84</div>`;
}
(async () => {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
try {
const memory = new Map(), errors = [], blocked = [];
const context = await browser.newContext({ viewport: { width: 1366, height: 900 } });
await context.exposeBinding('__read', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__write', (_, key, value) => memory.set(key, structuredClone(value)));
const url = 'http://10.119.67.7/clientcn/exam/workflow/questions?flag=test&totaltime=30&title=' + encodeURIComponent('完整流程考试');
await context.route('**/*', route => {
if (route.request().isNavigationRequest() && route.request().url() === url) return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: fixture() });
blocked.push(route.request().url()); return route.abort();
});
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
await page.goto(url);
await page.evaluate(() => {
window.offset = 0; const nativeNow = Date.now.bind(Date); Date.now = () => nativeNow() + window.offset;
window.advanceExamTime = milliseconds => {
window.offset += milliseconds;
const remaining = Math.max(0, 1800 - Math.floor(window.offset / 1000));
document.querySelector('.pc_examtest_countdown .time').textContent = '00:' + String(Math.floor(remaining / 60)).padStart(2, '0') + ':' + String(remaining % 60).padStart(2, '0');
};
window.commitClicks = 0; window.confirmClicks = 0;
document.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.addEventListener('click', () => {
node.parentElement.querySelectorAll('.pc_examtest_content_list_des').forEach(n => n.classList.remove('examtest_content_list_des_checked'));
node.classList.add('examtest_content_list_des_checked');
}));
document.querySelector('.pc_examtest_commit').addEventListener('click', () => {
window.commitClicks++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'false');
});
document.querySelector('[role=dialog] button').addEventListener('click', () => {
window.confirmClicks++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'true'); document.querySelector('[data-exam-result]').hidden = false;
});
});
await page.addScriptTag({ content: '(function(){const GM_getValue=(k,v)=>window.__read(k,v);const GM_setValue=(k,v)=>window.__write(k,v);\n' + script + '\n})();' });
await page.evaluate(() => YianAssistant.ready);
await page.evaluate(() => { YianAssistant.getState(); });
const records = Array.from({ length: 21 }, (_, i) => ({ role: i % 2 ? '主操' : '班长', course: '流程主题库', type: '单选', number: i + 1,
question: '联动验证题' + i, options: [{ label: 'A', text: '正确' + i }, { label: 'B', text: '错误' + i }], answer: 'A' }));
await page.evaluate(async records => {
const bytes = new TextEncoder().encode(JSON.stringify(records));
await YianAssistant.importFiles([{ name: '统一流程.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
await YianAssistant.setLearningSetting('mode', 'exam');
await YianAssistant.setLearningSetting('answerInterval', 0);
}, records);
await page.waitForFunction(() => YianAssistant.getState().pageReady);
assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false, '没有明确主题关联不能答题');
await page.evaluate(async () => {
await YianAssistant.setLearningSetting('associations', { 完整流程考试: ['流程主题库'] });
await YianAssistant.taskAction('start');
});
await page.waitForFunction(() => YianAssistant.getState().metrics.selected === 21, null, { timeout: 15000 });
await page.waitForFunction(() => YianAssistant.getExamState().predictedScore === 84);
assert.equal(await page.evaluate(() => window.commitClicks), 0);
await page.getByTestId('tab-bank').click();
await page.getByRole('textbox', { name: '搜索题目', exact: true }).fill('联动验证题0');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true, includeHidden: true }).isChecked(), false);
await page.getByTestId('tab-console').click();
assert.equal(await page.evaluate(() => YianAssistant.getExamState().enabled), true, '切栏不取消交卷授权');
await page.evaluate(() => window.advanceExamTime(4 * 60000));
await page.waitForTimeout(750);
assert.equal(await page.evaluate(() => window.commitClicks), 0, '4分钟不得交卷');
await page.evaluate(() => window.advanceExamTime(2 * 60000 + 500));
await page.waitForFunction(() => YianAssistant.getExamState().submitted === true, null, { timeout: 8000 }).catch(async error => {
console.error(await page.evaluate(() => ({ stage: YianAssistant.getExamState().stage, message: YianAssistant.getExamState().message,
score: YianAssistant.getExamState().predictedScore, tasks: YianAssistant.getLearningState().tasks,
commits: window.commitClicks, confirms: window.confirmClicks, ready: YianAssistant.getContext().ready })));
throw error;
});
assert.equal(await page.evaluate(() => window.commitClicks), 1);
assert.equal(await page.evaluate(() => window.confirmClicks), 1);
await page.waitForFunction(() => YianAssistant.getLearningState().completed === 1);
await page.waitForTimeout(1300);
assert.equal(await page.evaluate(() => window.commitClicks), 1);
assert.deepEqual(errors, []);
assert.deepEqual(blocked, []);
console.log('PASS 完整用户脚本:无主题阻止答题→明确关联→统一题库21题全文回读=84分→切栏不扰动→4分钟等待→6分钟正常交卷→网站确认→任务完成,零业务请求');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+59
View File
@@ -0,0 +1,59 @@
# 谊安学助手实施开发方案
日期:2026-10-18。依据 [需求.md](需求.md),在现有用户脚本上增量开发,完成后构建、验证、提交并推送当前仓库。
## 1. 开发顺序与交付
| 阶段 | 实施内容 | 验收重点 |
|---|---|---|
| 统一题库 | 三岗位来源统一存储、检索与精确匹配;旧数据迁移;生成统一 CSV | 5520 条来源记录不丢失;无岗位文件可用;重复导入不增生;冲突不覆盖 |
| 六栏面板 | 总览、视频、考试、题库、控制台、其他设置;保留拖拽与折叠 | 窄屏可操作;全局暂停常驻;切栏不启动任务或导出 |
| 学习调度 | 从实际页面发现任务,按稳定标识排队;四种模式,顺序与并行 | 不猜业务接口;已完成不重做;受前置条件约束;独立视频与考试任务不重复领取 |
| 视频与恢复 | 真实播放器倍速、平台记录、课中题检测、受限恢复及检查点 | 播放结束不误判完成;主动暂停/弹题不刷新;重试有上限;人工重开默认暂停 |
| 考试流程 | 主题关联预检、整卷预检、逐题间隔、分值读取、选中全文核验、交卷 | 严格大于 80 分;至少 6 分钟;80 分等待人工;真实提交结果确认 |
| 集成发布 | 既有复制/搜索/导出/范围/拖拽回归,更新文档与用户脚本 | 本地模拟业务页面完成交互测试;无外网业务请求;提交并推送远程 |
## 2. 模块边界
- `bank-store.js`、`answer-matcher.js`:统一题库与证据匹配,岗位仅作来源信息。
- `assistant-panel.js`:六个功能栏及状态展示,仅通过回调发出用户操作。
- `learning-engine.js`:任务、视频、恢复、检查点、日志及多页面协调。
- `exam-controller.js`:当前考试的主题依据、原始进入时间、分值核算及交卷状态机。
- `answer-runner.js`:按配置间隔点击真实选项,保持每步上下文及全文核验。
- `practice-exporter.js`:统一协调页面识别、用户设置、模块生命周期及站点范围。
- `build-userscript.mjs`:发布单文件用户脚本,名称继续为“谊安学助手 2.0”。
## 3. 状态与操作约束
- 用户启动任务才调度。切栏、搜索、读取日志不改变运行状态。
- 课中题归视频任务;考试交卷规则不应用到课中题或练习。
- 考试先核对主题关联;仅相似词不能授权自动答题。完整题干预检发生在试卷加载后,不能将已进入试卷的场次称为未开考。
- 预计得分仅计入答案唯一、当前选中全文一致的题目实际分值。未知、冲突及分值不明不算正分。
- 用户手动作答、关停自动选择、暂停、题库/主题变更均取消旧交卷计划;继续前重新核验。
- 提交过程保存状态;结果不明先等待检查,不重复点击。网站倒计时独立于脚本暂停。
- 同一任务只由一个有效执行页持有;并行受阻时显示原因,不显示虚假的运行成功。
## 4. 样本与验证策略
- 保存网页按静态 DOM 解析,不运行其中原站脚本,也不向业务服务器发送请求。
- 使用真实考试样本验证分值标记、40 题全文匹配及选项换序,使用独立本地模型验证网站选中状态回读。
- 使用可控制时钟与本地提交模型覆盖 80/大于 80、6 分钟前后、人工暂停、未知分值、重复提交及恢复。
- 使用课程/视频模拟页面覆盖倍率、平台完成条件、课中弹题阻塞、卡顿恢复和多页协调。
- 用桌面与窄屏浏览器验证六栏、拖拽、切换状态及控制台;截图保存在系统临时目录。
## 5. 真实环境验收边界
当前包未包含实际课中弹题内容,也没有完整登录后的课程解锁、平台后台播放及正式交卷结果流程。实现采用保守 DOM 识别:结构或结果无法确认时进入等待人工,不猜答案、不伪造学习进度、不绕过网站验证。发布记录须分别说明本地已验证能力和需要在真实环境核实的流程。
验收完成后将需求、实施方案、源代码、测试、统一题库和构建后的脚本一并提交,推送至 `ssh://git@192.168.10.31:2222/qishizhe/yianxue-shauti.git`。
## 6. 本次实现与验证记录
- 发布版本 2.1.0,脚本名称保持“谊安学助手 2.0”。以上模块已接入构建后的单文件脚本,六栏共享全局控制及状态。
- 统一题库保留 5520 条来源记录,2458 道唯一题、2459 个答案组合、1 道冲突题;合并脚本核对原 28 个 CSV 的字段和真实导入结果。
- 增加统一题库、课程状态机、课中题、考试交卷、六栏及完整考试/视频流程回归,连同既有用例共 13 个测试套件。
- 浏览器验证使用本地 Chrome / Playwright,覆盖桌面、390px 窄屏及 320×480 小窗口;Browser 插件在当前环境不可用。截图位于系统临时目录,不提交至仓库。
- 实际保存题目用于验证全文、类型、分值与选项乱序。课程任务、平台完成、并行心跳、课中题判分和提交结果通过独立本地模型验证,没有参加真实考试或调用业务提交接口。
- 补充回归保护异步保存期间的暂停/题库/页面/分值变化、重考计时隔离、未知提交防重复、课中题选中后立即暂停、视频先结束后的并行汇总及平台完成状态展示。
代码交付并不替代第 5 节的真实账号环境验收;未识别的页面结构、身份验证、结果或后台限制会停止受影响任务并提示人工处理。
+1680 -113
View File
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
# 谊安学助手:完整学习与答题需求
整理日期:2026-10-18。本文为开发需求基线,实施顺序和验证范围见 [实施开发方案.md](实施开发方案.md)。
## 1. 目标与范围
在现有“谊安学助手 2.0”基础上,增加任务调度,串联“学习地图 → 课程 → 视频及课中答题 → 关联考试 → 结果确认”,显示总进度、当前任务进度和预计剩余时间。
- 生效范围沿用 `http://10.119.67.7/clientcn/*`,覆盖主页面、课程、练习和考试页面。
- 将原主操、副操、班长题库合成一个统一题库,搜索、课中答题和考试共用,面板不再要求选择岗位;保留题库搜索、严格答案全文匹配、复制开关及面板拖拽与位置保存。
- “所有任务”指用户选定的学习地图或学习计划范围;网站原有岗位归属可作为任务来源信息,但不限制统一题库的检索或匹配。启动前展示任务清单,默认只处理未完成任务,避免把学习地图阶段数直接当成任务数。
- 支持一个课程包含多个章节、多个视频及关联考试。用课程、课件、考试的稳定标识区分任务,不能仅按标题去重。
- 非视频课件、未适配题型和网站未开放的任务单独列出原因,不计为完成。
## 2. 已确认规则与建议默认值
| 项目 | 规则 | 确认状态 |
|---|---|---|
| 视频倍速 | 面板可选择,最高 2X;建议提供 1X、1.25X、1.5X、2X,默认 1X | 上限已明确,档位与默认值为建议 |
| 答题间隔 | 默认 4 秒,定义为相邻两题之间的等待;多选内部选项调整不分别等待 4 秒 | 默认值来自原需求,间隔口径为补充建议 |
| 交卷计时 | 从本次实际进入试卷、开始考试起计算,默认至少 6 分钟;页面刷新或续作不重新起算 | 用户已确认 |
| 得分门槛 | 预计得分严格大于 80 分才允许自动交卷;正好 80 分或更低,等待用户操作 | 用户已确认 |
| 题库结构 | 原主操、副操、班长题库统一合并使用,取消岗位选择及岗位隔离 | 用户已确认 |
| 题库对应 | 考试前检查当前考试主题与统一题库中课程/主题是否对应;没有对应题库则跳过该考试 | 用户已明确 |
| 执行方式 | 顺序、并行可切换,默认顺序 | 用户已确认 |
| 面板布局 | 按功能分栏,桌面采用左侧功能导航、右侧内容区,窄屏改为顶部标签切换 | 按功能分栏已确认,具体排布为补充方案 |
| 并行数量 | 同时最多 1 个视频任务和 1 场考试;课中题属于视频任务 | 补充建议 |
| 故障刷新 | 仅异常停播或卡住触发;建议每任务最多自动刷新 3 次 | 刷新功能来自原需求,识别及上限为补充建议 |
当前样本满分 100 分,门槛按原始分值计算,不能把“80 分”擅自解释为其他分制的“80%”。总分或分制不明确时暂停自动交卷,显示原因。
## 3. 模式与执行方式
“任务模式”与“顺序/并行”分开设置,避免同一个开关含义不清。
| 任务模式 | 视频 | 课中答题 | 独立或关联考试 |
|---|---|---|---|
| 仅视频 | 自动播放 | 遇到强制题时等待人工处理 | 不进入 |
| 视频+课中答题 | 自动播放 | 自动精确匹配并作答 | 不进入 |
| 仅考试 | 不播放 | 不处理 | 主题预检、作答、计分和按条件交卷 |
| 视频+考试 | 自动播放 | 自动精确匹配并作答 | 主题预检、作答、计分和按条件交卷 |
- 顺序:依照任务清单完成视频及其课中题,再处理满足前置条件的关联考试。
- 并行:一个受控标签页处理视频及课中题,另一个处理已开放的考试;两个页面共享总任务进度,不能重复领取同一任务。
- 并行也须遵守网站的解锁顺序。需要先完成视频才能参加的考试,不能提前启动。
- 全局暂停应同时暂停两个执行任务;单个任务进入“等待人工”时,其他没有依赖关系的任务可以继续。
- 切换模式或执行方式时先停止当前调度并保留检查点,重新核对任务状态后继续,不能遗留旧页面继续点击或交卷。
## 4. 任务清单、进度和预计时间
- 任务状态区分:待开始、加载中、播放中、课中答题、考试答题、等待交卷、等待人工、重试中、已完成、已跳过、失败。
- 显示课程名、当前课件或考试、完成数/总数、跳过数、失败数、等待人工数;跳过与失败不能计入完成率。
- 视频分别显示播放位置/总时长、实际生效倍速、平台已记录学习时长及平台完成状态。
- 考试显示总题数、已核验题数、未匹配/冲突题数、预计得分、交卷门槛、进入试卷后的经过时间、最早交卷倒计时及网站剩余考试时间。
- 预计剩余时间根据剩余视频播放时间、网站要求的剩余学习时长、剩余题数与答题间隔、交卷等待时间综合估算,不能只用视频长度除以 2。
- 顺序执行累加剩余任务时间;并行根据两条执行队列及前置依赖估算,不能把两边耗时直接相加。
- 人工等待、缺少视频时长或未知完成条件应标注“暂无法准确估算”;不能输出虚假的精确完成时间。
- 倍速、进度、暂停、卡顿或队列变化后更新估算。只有平台确认的完成任务才能计入已完成。
- 任务结束展示完成、跳过、失败及需人工处理清单。存在未完成项目时不能显示“全部完成”。
## 5. 视频播放与课中答题
- 自动进入所选任务的课程,读取完整章节与课件目录,继续尚未完成的视频,已完成的视频默认跳过。
- 倍速设置应应用到实际播放器,并在面板显示实际生效值。网站不允许所选倍率时提示并使用可用倍率,不能面板显示 2X 而播放器仍是 1X。
- 播放到末尾后,继续检查平台学习时长与完成条件;视频结束不等于任务完成。
- 课中弹题出现时记录当前课件与播放位置,进入答题状态,不能当作卡顿刷新页面。
- “视频+课中答题”模式复用统一题库,以题型、完整题干、全部选项及正确答案全文严格匹配;核对所选集合并确认弹题已关闭或提交成功后继续播放。
- 仅视频模式遇到强制弹题,或课中题无匹配、答案冲突、图片题或未适配题型时,暂停该课件并提示人工处理,保留已播放记录。
- 课中题暂不套用考试的 6 分钟交卷规则及 80 分门槛;是否答题正确、如何继续播放以实际弹题流程为准。
## 6. 统一题库与考试主题预检
### 6.1 统一题库与旧数据迁移
- 仓库提供一个可直接导入的 `题库/统一题库.csv`,汇总原主操、副操、班长三个题库。现有三个文件合计 5520 条来源记录,合并时完整保留题型、题干、选项、答案、课程/主题、原题号及来源信息。
- 不再按岗位分库、建立岗位匹配索引或显示岗位选择按钮。旧文件中的“岗位”字段仅作为原始来源信息保留,不作为搜索或自动答题的筛选条件,也不作为答案冲突的裁决依据。
- 搜索默认覆盖全部统一题库,可按课程/主题筛选。只有在题型、题干、全部选项和正确答案全文集合一致时,才可将重复题聚合为一条结果,并保留其全部来源;选项字母不同但答案文字一致不算冲突。
- 同题干、同选项但正确答案文字不同的记录必须保留并标记冲突,不能按多数答案、导入顺序或原岗位覆盖。只有当前课程/来源的明确对应能够消除歧义时才允许自动作答,否则等待人工处理且不计入可核验得分。
- 同题干但题型或选项不同的记录分别保留,不能仅按题干去重。显示原始来源记录数、合并后的题目数及冲突数,避免把 5520 条来源记录误称为 5520 道不重复题。
- 升级时将浏览器已保存的三个岗位题库自动迁入统一题库,核验成功后再启用新数据;迁移失败保留旧数据并说明原因,不要求用户手动重新导入。旧岗位选择设置不再影响结果。
- 兼容导入原三个岗位 CSV、原题库 ZIP 及新统一 CSV/JSON;缺少岗位字段也能导入。迁移或重复导入同一来源不应重复增加记录,新增题库须先完整校验再合入。
### 6.2 考试主题预检
1. 在考试详情页读取考试主题、课程所属阶段/科目、考试标识及可见限制,从统一题库显示候选课程/主题和对应依据。
2. 支持课程与题库名称不同的明确关联,例如“1-1安全生产方针和目标”可以对应“安全领导力”主题下的题库;可关联统一题库中的多个主题,不要求岗位一致。
3. 已有明确来源、所属主题或用户确认的关联时可继续。仅有相似关键词时只显示候选,不能把相似主题当成正确答案依据。
4. 统一题库中没有对应课程/主题,或主题不对应时,跳过自动进入该考试并记录原因;无法确认关联时等待用户确认。
5. 进入试卷后等待题目和答题卡总数完整,在首次自动点击答案之前,对整卷逐题执行严格匹配、读取各题分值并估算可核验得分。
考试详情页通常没有题干,因此开考前只能做主题与候选题库预检,完整逐题预检须在试卷加载后进行。进入试卷后可能已经产生考试记录并启动网站倒计时;此时发现缺题不能声称“未开考”或“已经撤销考试”。
## 7. 答题、预计得分与交卷
### 7.1 答案准确性
- 在统一题库中使用题型、完整题干和全部选项文字精确匹配,结合明确的课程/来源信息消歧,以题库正确答案全文重新定位网页当前选项,字母只表示当前页面位置。
- 分值、难度和显示题号与真实题干分开读取,不能混入匹配文字;题目本身的括号、数字、否定词、单位、大小写、指数及标点保持原意。
- 无匹配、答案冲突或无法唯一映射的题目不猜答,保持原作答并列入未处理清单。
- 默认每两题之间等待 4 秒;单题多选按集合差异串行调整并回读核对。修改间隔从下一道题生效。
### 7.2 预计得分
- 预计得分是按本地题库和网页分值计算的估算,不能标为网站官方成绩或保证得分。
- 只累计“答案精确唯一匹配,且网页当前选中内容已回读核验一致”的题目分值。
- 未匹配、冲突、未核验、无效分值及无法识别的题目暂计 0 分并标注原因,不把用户原来随意选中的选项算成正确。
- 各题分值优先读取网页明确标注,不能简单按总分除以题数,单选、多选、判断可能分值不同。
- 多选只有整个选中集合与题库答案一致时才累计该题分值;不能假设漏选或部分选对一定有部分分。
- 无法核对试卷总分、题数或分值合理性时暂停自动交卷,等待人工检查。
- 展示得分构成:哪些题已核验并计分,哪些题未计分及原因,便于用户检查。
### 7.3 自动交卷条件(须同时满足)
1. 考试主题与题库对应关系已确认,整卷加载和预检完成。
2. 所有可自动匹配的题目已经处理,并完成当前选中内容的回读核对。
3. 可核验预计得分严格大于 80 分;正好 80 分不自动提交。
4. 从本次实际进入试卷起至少经过 6 分钟,或达到用户设置的其他最早交卷时长。
5. 当前试卷仍有效、未提交、未结束,且本任务没有暂停或等待人工操作。
- 可存在未匹配题,只要已核验预计得分严格大于 80 分,且以上其他条件满足,才可自动交卷;未匹配题仍不猜答。
- 预计得分小于或等于 80 分时进入“等待人工”,即使已满 6 分钟也不自动提交、不自动刷新。用户处理后须重新核验;不确定的人工答案仍不能自动计为正确。
- 自动交卷只操作当前场次的正常交卷及确认流程,包括正常的未答完提醒;出现异常、身份验证或结果不明确时停止并提示,不能盲目重复确认。
- 以网站显示提交成功或成绩结果确认完成,不能仅凭点击交卷按钮就标记完成。提交结果不明时先检查状态,避免重复提交。
- 用户取消交卷、手动修改答案或暂停任务时,取消原自动交卷计划,并使相应旧核验结果失效。
- 脚本暂停不会暂停网站考试倒计时;若脚本最早交卷时长与网站截止时间冲突,提前提示并等待用户处理。网站自身到时自动交卷仍可能生效。
示例:满分 100 分时,预计 84 分但只经过 4 分钟,应继续等待;预计 84 分且满 6 分钟、核验完成,可以自动交卷;预计 80 分或 78 分,满 6 分钟后仍等待人工。
## 8. 异常恢复、刷新和后台运行
- “暂停自动刷新”解释为意外停播的恢复;用户主动暂停、课中弹题、等待平台完成确认、等待交卷及等待人工不能被识别为故障。
- 建议连续 60 秒没有播放进度变化时先检查原因并尝试续播,15 秒后仍失败才刷新;单任务最多自动刷新 3 次,两次刷新至少间隔 30 秒,次数跨刷新保存。
- 刷新前保存任务标识、播放/学习记录、执行模式、倍率、已核验题目、考试原始开始时间和重试次数;恢复后以网站实际状态重新核对。
- 视频故障刷新应限制在对应视频页面。并行运行时,不能刷新另一场正在答题或等待交卷的考试。
- 登录失效、自动播放被浏览器拒绝、验证弹窗、提交结果不明或达到重试上限时,暂停受影响任务并提示人工处理。
- 全局“暂停”停止后续选择、交卷、任务跳转及自动刷新;“停止本轮”退出调度但保留已发生的学习与作答记录,两者不能假装撤销网站操作。
- 脚本自己发起的恢复刷新可继续同一已授权任务;人工刷新、重新打开浏览器时恢复任务清单但默认暂停,提供“继续上次任务”。恢复前核对账号、学习计划、任务、题库版本和网站状态。
- 后台指切换标签页或窗口最小化后尽量继续播放和执行;计时与进度要在恢复前台时校准,不能靠定时器回调次数虚增学习时长。
- 不承诺关闭浏览器、设备休眠或标签被浏览器冻结期间仍持续执行。后台被网站或浏览器中断时,显示真实状态并走受限恢复流程,不能显示“正常学习”却没有平台记录。
## 9. 面板、控制台与其他设置
### 9.1 按功能分栏
面板采用一个窗口、六个功能栏。桌面左侧为功能导航,右侧只显示当前栏内容;窄屏改为顶部可横向滚动的标签栏,避免把所有功能挤在一页。
| 功能栏 | 主要内容与操作 |
|---|---|
| 任务总览 | 学习计划与任务清单、四种任务模式、顺序/并行、开始/停止本轮、总进度、完成/跳过/失败/等待人工统计、预计剩余时间;显示当前视频与考试摘要,点击可进入对应功能栏 |
| 视频学习 | 当前课程和课件、播放位置/总时长、平台已记录学习时长与完成状态、最高 2X 的倍速设置、视频任务暂停/继续、课中题匹配及处理状态、卡顿恢复状态 |
| 考试答题 | 当前考试主题与题库预检结果、自动选择答案开关、默认 4 秒的答题间隔、默认 6 分钟的最早交卷时长、网站剩余时间、预计得分与严格大于 80 分的门槛、交卷等待原因、答案全文核对与未处理题清单 |
| 题库搜索 | 统一题库导入与状态、来源记录数/题目数/冲突数、关键词搜索、课程/主题筛选、选中文字搜索、答案全文与来源、主题关联查看和维护;不显示岗位选择 |
| 控制台 | 带时间和任务名称的运行日志、状态/警告/错误筛选、复制日志、清空显示、恢复次数及交卷结果 |
| 其他设置 | 允许复制开关、默认关闭的题库导出开关及折叠区、异常恢复参数、后台运行状态说明 |
### 9.2 常驻区域与切换规则
- 标题栏常驻脚本名称“谊安学助手 2.0”,保留拖拽、面板折叠和位置保存。导航、按钮及表单操作不能误触拖拽。
- 全局运行状态、总进度及“暂停全部/继续全部”在各栏均可见;并行时显示视频与考试各自状态,不能只显示其中一个任务。
- 切换功能栏只改变可见内容,不停止或重启任务,不重新开始考试计时,不触发导出,也不改变开关。面板折叠后继续保留当前运行状态,并显示简要进度与需人工处理提示。
- 首次展开默认进入“任务总览”;同一会话内记住当前功能栏、搜索词、筛选项及滚动位置,切回来不清空内容。
- 视频与考试的局部暂停只影响对应任务,全局暂停影响所有任务。关闭“自动选择答案”应停止当前后续选项点击,并取消该场考试的自动交卷计划;切换功能栏不能再次启用。
- 各设置仅在所属栏编辑,总览展示摘要并提供跳转,避免多处控件的值不一致。未启动或不适用的功能显示原因,不能隐藏正在等待人工处理的问题。
- 当前模式未启用的视频或考试栏仍可查看设置和记录,并显示“本模式未启用”;打开该栏不能启动对应任务。共用的答题间隔只保存一份,在课中题与考试中保持一致。
- “考试答题”栏保留现有练习页的自动选择与答案核对入口,进入练习页时显示练习状态;练习和课中题不套用考试交卷倒计时与得分门槛。
- 等待人工、题库冲突、视频恢复失败等重要事件在常驻区域提示,并给对应功能栏增加状态标记;不强制切走用户正在查看的搜索或日志内容。
- 内容区单独滚动,标题、导航与全局暂停保持可见;面板大小随视口适配,拖动或缩放窗口后仍能操作全部功能,键盘可切换功能栏并识别当前选中项。
### 9.3 日志、导出与题库变更
- 控制台是面板内可查看的运行日志,记录时间、任务名称、状态、主题对应、匹配数量、预计得分、交卷等待/结果及恢复原因和次数;建议保留最近 500 条,支持复制和清空显示。
- 清空控制台显示不清除任务清单或恢复检查点;日志不记录登录凭据和完整敏感接口数据。
- 题库导出移动到“其他设置”,保留独立开关与折叠区,默认关闭,开启后才允许导出;原有 CSV/JSON/TXT 格式和仅练习页导出规则不变。
- 保留题库导入、搜索正确答案、允许复制及答案全文核对功能;取消岗位切换,显示统一题库状态与课程/主题筛选。题库或主题关联变更时暂停受影响的答题任务,取消旧交卷计划并重新预检。
## 10. 验收标准
| 场景 | 预期结果 |
|---|---|
| 启动学习计划 | 列出实际课程/课件/考试任务,已完成项目不重复执行,阶段数与任务数不混算 |
| 四种模式 | 视频、课中题、独立考试按所选模式执行;仅视频遇强制题等待人工 |
| 顺序与并行 | 默认顺序;并行最多一个视频和一场考试,不重复任务且遵守前置条件 |
| 最高 2X | 面板与播放器实际倍率一致;网站学习时长和完成状态独立核对 |
| 主题预检 | 显示考试与题库对应关系,无库不自动开考;名称不同的明确关联可用 |
| 统一题库 | 三岗位题库全部可检索和匹配,无岗位选择;重复题保留来源,答案冲突不盲目覆盖 |
| 旧题库迁移与兼容 | 已存题库自动迁移,原 ZIP/CSV 与无岗位字段的新题库均可导入;重复迁移或导入不增生重复记录 |
| 题目乱序和分值标记 | 按正确全文映射,不依赖旧字母;分值和难度不污染题干 |
| 不同题型分值 | 按实际分值累计,仅完整核验通过的题得分,未知题不计正分 |
| 预计得分超过 80 | 例如 84 分,满 6 分钟并完成核验后才提交,可保留少量未知题 |
| 预计得分等于/低于 80 | 进入等待人工,不交卷、不因等待而刷新 |
| 交卷计时与恢复 | 从同一场考试原始开始时间计时,恢复不清零,不产生重复提交 |
| 卡顿和正常暂停 | 异常按次数上限恢复;用户暂停、课中题与等待交卷不误刷新 |
| 后台与回到前台 | 实测切换标签页及最小化,检查网站记录;中断时显示真实状态 |
| 结束汇总 | 完成、跳过、失败、需人工分别列出,存在未完成项目不显示全部完成 |
| 功能分栏 | 六栏内容按功能归属展示,桌面与窄屏均可切换;状态与全局暂停常驻,切栏不重启任务或计时、不触发导出,搜索与筛选内容保留 |
| 既有功能 | 统一搜索、答案全文核对、复制、拖拽、位置恢复、导出开关和站点范围回归通过 |
## 11. 当前样本依据与开发前待核实内容
- 新包 01 为学习地图,02/04 为同课程详情,03/05 为同视频的不同播放状态,06 为考试详情,07 为未加载内容的页面外壳。
- 03/05 的播放器位置与平台已记录学习时长不同;代码也区分播放百分比、已学习时长及完成标记。2X 不能直接承诺总任务耗时减半。
- 视频页虽包含课中弹题组件,但没有保存实际弹出的题干、选项及提交状态。课中题的真实结构、题型、确认方式和题库覆盖情况需要补样本验收。
- 需要核实课程实际完成条件、可用倍速、阶段解锁条件、当前账号的后台播放规则以及并行标签页是否被平台允许。
- 主题对应关系需要建立明确的课程/科目到统一题库主题映射;不能要求课程名与统一题库文件名完全相同,也不能只靠关键词猜测。
- 建议按“统一题库及旧数据迁移 → 任务清单与主题预检 → 单视频及进度 → 课中题 → 考试计分与定时交卷 → 顺序调度 → 并行与后台恢复”分阶段开发,每阶段完成对应验收。
## 附:用户原始需求
根据我提供的谊安学网页压缩包以及之前的完成的刷题脚本,现在需要做一个完整的刷课答题的脚本,现在需要所有任务的一个进度,以及预测时间,看视频过程中可能也有答题,以及当前任务的进度,刷视频要求可以在面板选择倍速,最高是2X,答题的可以设置答题间隔时间(默认4秒)以及交卷的时间(默认6分钟),需要增加只刷视频以及视频的任务以及视频中的答题的模式,或者只有考试的模式。答题之前先确认有没有该题库,没有则跳过该考试。或者考试和视频同时可以进行,导出题库放到其他设置的面板中。面板增加一个控制台的功能,可以看当前进度,视频如果卡住或者暂停自动刷新页面,增加网页可以后台的功能。
+12 -4
View File
@@ -1,6 +1,14 @@
# 按岗位合并题库
# 统一题库
由原始 `题库.zip` 中各岗位的 CSV 合并生成。三个文件均可在“谊安学助手 2.0”的“导入题库”中直接选择,也可同时导入;文件内已有岗位字段,无需靠当前岗位选择归类。
推荐下载并导入 [统一题库.csv](统一题库.csv)。由原始 `题库.zip` 的 28 个 CSV 合成,共 5520 条来源记录,主操、副操、班长共用检索和答案匹配,不再需要选择岗位。原岗位仅作为来源信息保留。
导入后的相同题型、题干、完整选项及答案会聚合展示,并保留全部来源;不同答案分别展示并标记冲突,自动答题不会多数决。可以通过明确的课程/来源信息消歧。
统一题库共有 2459 个题目答案组合、2458 道唯一题目,其中 1 道存在跨来源答案冲突。
## 兼容文件
下面三个旧文件继续保留,内容均已包含在统一题库中,无需另外导入。
| 文件 | 原文件数 | 记录数 | 单选 | 多选 | 判断 |
|---|---:|---:|---:|---:|---:|
@@ -8,7 +16,7 @@
| [主操_合并题库.csv](主操_合并题库.csv) | 9 | 1620 | 540 | 540 | 540 |
| [班长_合并题库.csv](班长_合并题库.csv) | 10 | 2340 | 780 | 780 | 780 |
合计 5520 条记录。统一保留选项 A–H,原文件没有的选项为空;原字段逐格保留,并增加“岗位”和“来源文件”。题号保持原课程题号,不同课程可有相同题号。CSV 使用 UTF-8 BOM,可用 Excel 打开。
统一保留选项 A–H,原文件没有的选项为空;原字段逐格保留,并增加“岗位”和“来源文件”。题号保持原课程题号,不同课程可有相同题号。CSV 使用 UTF-8 BOM,可用 Excel 打开。
合并保留全部记录,不按题干去重,避免丢失课程来源或不同选项。同题在不同课程出现答案差异时也原样保留;搜索时可按课程核对。“常压罐组收料作业中,以下哪些属于工程控制措施?”在风险管理题库中为 ABC,在重大危险源题库中为 ABCD。
@@ -61,6 +69,6 @@ npm run merge:banks
node scripts/merge-question-banks.cjs "题库.zip" "题库"
```
脚本在写入前调用助手的实际导入逻辑,逐条核对合并前后的题干、选项、答案、岗位、题号及课程来源。无需安装额外依赖。
脚本在写入前独立逐格核对全部原始字段,并调用助手的实际导入逻辑,逐条核对合并前后的题干、选项、答案、原岗位、题号、来源文件及课程来源。重复导入同一题库不会增加来源记录。无需安装额外依赖。
原始 ZIP SHA-256:`21758bc40614fc84a5a10fad25cd891ab9775d80ef12427e5cead5b62bf3ea09`。
File diff suppressed because it is too large Load Diff