From eb9c610b9f4024b9d01685bde189bac1a13939d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E6=98=9F=E5=B9=BB=E6=B5=B7?= <30327324256@qq.com> Date: Sun, 18 Oct 2026 01:11:33 +0800 Subject: [PATCH] feat: add unified learning workflows and six-tab assistant --- .gitignore | 2 +- README.md | 117 +- answer-matcher.js | 31 +- answer-runner.js | 12 +- assistant-panel.js | 388 +- bank-store.js | 60 +- build-userscript.mjs | 7 +- exam-controller.js | 290 + learning-engine.js | 650 + package.json | 4 +- practice-exporter.js | 168 +- quiz-adapter.js | 186 + scripts/merge-question-banks.cjs | 62 +- tests/assistant.cjs | 41 +- tests/bank-engine.cjs | 20 +- tests/exam-assistant.cjs | 45 +- tests/exam-controller.cjs | 362 + tests/learning-engine.cjs | 286 + tests/panel-drag.cjs | 6 +- tests/panel-tabs.cjs | 199 + tests/quiz-adapter.cjs | 138 + tests/run.cjs | 2 +- tests/runner-browser.cjs | 4 +- tests/unified-bank.cjs | 123 + tests/verify.cjs | 1 + tests/video-workflow.cjs | 144 + tests/workflow.cjs | 92 + 实施开发方案.md | 59 + 谊安学助手 2.0.user.js | 1793 ++- 需求.md | 209 + 题库/README.md | 16 +- 题库/统一题库.csv | 18642 +++++++++++++++++++++++++++++ 32 files changed, 23806 insertions(+), 353 deletions(-) create mode 100644 exam-controller.js create mode 100644 learning-engine.js create mode 100644 quiz-adapter.js create mode 100644 tests/exam-controller.cjs create mode 100644 tests/learning-engine.cjs create mode 100644 tests/panel-tabs.cjs create mode 100644 tests/quiz-adapter.cjs create mode 100644 tests/unified-bank.cjs create mode 100644 tests/video-workflow.cjs create mode 100644 tests/workflow.cjs create mode 100644 实施开发方案.md create mode 100644 需求.md create mode 100644 题库/统一题库.csv diff --git a/.gitignore b/.gitignore index ba4f3a3..a878723 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# 原始下载网页、压缩包和临时导出不提交;题库/ 中的岗位合并 CSV 提交到仓库。 +# 原始下载网页、压缩包和临时导出不提交;题库/ 中的统一及兼容 CSV 提交到仓库。 网页样本/ 导出结果/ *.zip diff --git a/README.md b/README.md index c292bda..aabd4f8 100644 --- a/README.md +++ b/README.md @@ -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)。真实课中弹题交互、课程完成条件、账号登录后的并发限制与正式提交结果需在实际环境核实;识别不明时脚本等待人工,不把本地模拟通过等同于全站验收完成。 diff --git a/answer-matcher.js b/answer-matcher.js index 646a751..5c17412 100644 --- a/answer-matcher.js +++ b/answer-matcher.js @@ -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); diff --git a/answer-runner.js b/answer-runner.js index 17996be..d39ea75 100644 --- a/answer-runner.js +++ b/answer-runner.js @@ -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; diff --git a/assistant-panel.js b/assistant-panel.js index 4e52265..d7e239a 100644 --- a/assistant-panel.js +++ b/assistant-panel.js @@ -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) { diff --git a/bank-store.js b/bank-store.js index cacd28e..1c12788 100644 --- a/bank-store.js +++ b/bank-store.js @@ -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); diff --git a/build-userscript.mjs b/build-userscript.mjs index cf905b0..ab3e3c6 100644 --- a/build-userscript.mjs +++ b/build-userscript.mjs @@ -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. diff --git a/exam-controller.js b/exam-controller.js new file mode 100644 index 0000000..f282381 --- /dev/null +++ b/exam-controller.js @@ -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); diff --git a/learning-engine.js b/learning-engine.js new file mode 100644 index 0000000..ef3908e --- /dev/null +++ b/learning-engine.js @@ -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 }; +}); diff --git a/package.json b/package.json index a8de4b5..f27646c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/practice-exporter.js b/practice-exporter.js index e90fb97..acaeec5 100644 --- a/practice-exporter.js +++ b/practice-exporter.js @@ -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(); diff --git a/quiz-adapter.js b/quiz-adapter.js new file mode 100644 index 0000000..5326804 --- /dev/null +++ b/quiz-adapter.js @@ -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); diff --git a/scripts/merge-question-banks.cjs b/scripts/merge-question-banks.cjs index f34371f..577930b 100644 --- a/scripts/merge-question-banks.cjs +++ b/scripts/merge-question-banks.cjs @@ -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; }); diff --git a/tests/assistant.cjs b/tests/assistant.cjs index a86addc..853e06d 100644 --- a/tests/assistant.cjs +++ b/tests/assistant.cjs @@ -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++;});
本地验证:40 道保存题目,交卷由用户操作。