Compare commits
18 Commits
0e2923e675
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 48e72ab1e7 | |||
| d8808e64d9 | |||
| ecadae581e | |||
| 4784ac0dcd | |||
| 106818cfc2 | |||
| aa48145557 | |||
| 491fd08c1e | |||
| 8e8d6b83f1 | |||
| cfa960221e | |||
| f8458cff51 | |||
| f1a57853f4 | |||
| bdd0bdf073 | |||
| f999e4fa4d | |||
| b1ccfe89f6 | |||
| 53bddbabaa | |||
| a6aa6a88f9 | |||
| 7259ab8d96 | |||
| d702830470 |
@@ -9,6 +9,8 @@ data/
|
|||||||
.env
|
.env
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
.idea/
|
||||||
|
.ruff_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
default_install_hook_types: [pre-commit, commit-msg]
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.17.3
|
||||||
|
hooks:
|
||||||
|
- id: ruff-check
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: detect-private-key
|
||||||
|
- id: check-added-large-files
|
||||||
|
args: [--maxkb=1024]
|
||||||
|
- repo: https://github.com/commitizen-tools/commitizen
|
||||||
|
rev: v4.19.3
|
||||||
|
hooks:
|
||||||
|
- id: commitizen
|
||||||
|
stages: [commit-msg]
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: web-typecheck
|
||||||
|
name: web typecheck
|
||||||
|
entry: pnpm --dir web typecheck
|
||||||
|
language: system
|
||||||
|
files: ^web/
|
||||||
|
pass_filenames: false
|
||||||
Vendored
+31
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"files.exclude": {
|
||||||
|
"**/__pycache__": true,
|
||||||
|
"**/*.pyc": true,
|
||||||
|
"**/.venv": true,
|
||||||
|
"**/venv": true,
|
||||||
|
"**/env": true,
|
||||||
|
"**/.mypy_cache": true,
|
||||||
|
"**/.pytest_cache": true,
|
||||||
|
"**/.ruff_cache": true
|
||||||
|
},
|
||||||
|
"search.exclude": {
|
||||||
|
"**/__pycache__": true,
|
||||||
|
"**/*.pyc": true,
|
||||||
|
"**/.venv": true,
|
||||||
|
"**/venv": true,
|
||||||
|
"**/env": true,
|
||||||
|
"**/.mypy_cache": true,
|
||||||
|
"**/.pytest_cache": true,
|
||||||
|
"**/.ruff_cache": true
|
||||||
|
},
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/__pycache__/**": true,
|
||||||
|
"**/.venv/**": true,
|
||||||
|
"**/venv/**": true,
|
||||||
|
"**/env/**": true,
|
||||||
|
"**/.mypy_cache/**": true,
|
||||||
|
"**/.pytest_cache/**": true,
|
||||||
|
"**/.ruff_cache/**": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
本文档用于约束本项目中的 AI / 自动化开发行为。开发时优先遵循本文件,其次遵循用户当前消息。
|
本文档用于约束本项目中的 AI / 自动化开发行为。
|
||||||
|
|
||||||
|
**优先级:用户当前消息 > 本文件 > 其他文档。** 与本文件冲突时以用户消息为准,并在收尾时问一句要不要同步改本文件。
|
||||||
|
|
||||||
## 基本原则
|
## 基本原则
|
||||||
|
|
||||||
@@ -9,48 +11,80 @@
|
|||||||
- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。
|
- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。
|
||||||
- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。
|
- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。
|
||||||
- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容、数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。
|
- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容、数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。
|
||||||
- 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。
|
- 不要改无关文件,不要顺手重构;顺手发现的 bug 不修,记进 `docs/progress/todo.md` 并注明位置。
|
||||||
- 不要改无关文件,不要顺手重构。
|
|
||||||
- 任何文件编辑、改动、删除或格式化前,必须先询问用户是否允许;只有得到明确同意后才能修改。
|
|
||||||
- 如果工作区已有用户改动,不要回滚,不要覆盖;只在必要范围内追加修改。
|
- 如果工作区已有用户改动,不要回滚,不要覆盖;只在必要范围内追加修改。
|
||||||
|
|
||||||
|
### 动文件之前(Plan-first)
|
||||||
|
|
||||||
|
- 修改**已存在文件**前,先给一次改动清单:路径 / 改什么 / 为什么 / 影响面;同意后本次任务内连续执行,不逐文件问。
|
||||||
|
- 新建文件、新建目录不用逐次问,但要列进清单。
|
||||||
|
- 删除、覆盖、格式化、`git reset --hard`、`git checkout .`、`git push -f`、删分支 / 删 worktree,必须单独列出、单独同意。
|
||||||
|
- 用户当条消息里点名的改动,视为已同意。
|
||||||
|
|
||||||
|
### 验证口径
|
||||||
|
|
||||||
|
- **产物生成必须做**:前端改动 → `cd web && pnpm build`,`static/` 一起提交,不直接改 `static/` 里的产物。
|
||||||
|
- **质量验证不做**:不跑测试、不跑全量 lint / typecheck,由用户执行。例外:用户明确要求、发版、或改到核心逻辑(认证 / 计费 / 写库 / 定时任务 / 并发 / 对外协议)。
|
||||||
|
- 提交时 pre-commit 会跑 `ruff-check` / `ruff-format` / `web typecheck`,失败就修;这不算“检查语法”。
|
||||||
|
- 结论必须带依据:命令、`文件:行`、报错原文;没核实就写「未核实」。
|
||||||
|
- 命令失败、文件读不到、解析失败 → 结论是「未知」,不是「空」或「没问题」。
|
||||||
|
- 没跑的验证写 `Not tried: <原因>`。
|
||||||
|
|
||||||
|
### 收尾(Commit)
|
||||||
|
|
||||||
|
- **任务完成后自动提交**:一个任务一个 commit(一个 commit 一个目标),不为附带产物(如 `uv.lock`)单开提交,它们跟着产生它的那次任务走。
|
||||||
|
- **重大变更开始前,工作区必须干净**:重大变更指改依赖、改表结构、改 `AGENTS.md` / `docs/architecture.md` 这类规则文档、发版本;有残留改动先提交或撤销,再动手。
|
||||||
|
- **发版本是独立动作**:只在用户明确确认「发版」后才触发,见「发版本流程」。
|
||||||
|
- 提交范围:只提交本次任务的文件(`git add <具体路径>`)。提交前 `git status` 必须只剩本次任务的文件;出现别人的或无关的改动 → 停下问,不顺手提交。
|
||||||
|
- 提交信息:标题 `<区域>: <用户可见的变化>`;body 写 现象 → 根因 → 边界(哪些变了、哪些没变)→ 运行了什么 / 没跑什么。
|
||||||
|
|
||||||
|
## 多 Agent 协作
|
||||||
|
|
||||||
|
- 看板表头统一为 `| 路径 | 任务 | Agent | 状态 |`,历史行 `Agent` 列留空。
|
||||||
|
- 开工前在 `docs/agent_board.md` 追加一行,状态写 `进行中 (MM-DD HH:mm)`(如 `进行中 (11-01 19:40)`)。同一路径已有别人的 `进行中` → 停下问,不硬改。
|
||||||
|
- 完成后只改自己那行:状态改 `已完成,待实测`,**保留行**(它是完成记录)。不动别人 Agent 的行。
|
||||||
|
- 超过 24 小时仍是 `进行中` 的行(按状态里的时间算)视为过期,可以接管,但要注明接管人。
|
||||||
|
- 只有一个 Agent 在顺序开发时,认领行可以省,但改前必须先看有没有人动过同一文件。
|
||||||
|
- **并行开发(≥2 个任务同时改代码)** 时,每个任务用独立 worktree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree add ../study-tool-<任务名> -b <任务名>
|
||||||
|
cp .env ../study-tool-<任务名>/
|
||||||
|
uv sync
|
||||||
|
pnpm --dir web install
|
||||||
|
```
|
||||||
|
|
||||||
|
合并回 master 后 `git worktree remove ../study-tool-<任务名>`。单人顺序开发不强制建 worktree。
|
||||||
|
- 禁止 `git add -A` / `git add .`:必须按具体文件名暂存,防止误提交其他 Agent 未完成的改动。
|
||||||
|
|
||||||
## 反复提醒沉淀
|
## 反复提醒沉淀
|
||||||
|
|
||||||
- 如果开发过程中总是遇到某个问题,或者用户反复提醒同一个注意事项,需要把该注意事项补充到本文件。
|
- 同一个坑出现 ≥2 次(或同一个注意事项被用户纠正 2 次以上)才写成规则;只出现 1 次的先记 `docs/agent_workflow.md` 的「观察中」。
|
||||||
|
- `docs/agent_workflow.md` 每条格式:规则 / 为什么 / 证据(提交 sha、报错原文、`文件:行`)/ Seen N×。
|
||||||
|
- Seen ≥3 且用户同意,才升级进本文件;写入前先全文搜重,有同义条目就改旧的,不新增重复条目。
|
||||||
- 补充时写成明确、可执行的规则,避免只写模糊描述。
|
- 补充时写成明确、可执行的规则,避免只写模糊描述。
|
||||||
- 新规则应放到最相关的章节;找不到合适章节时放到“项目注意事项”。
|
- Agent 只提议并附证据,**不自己改本文件**。
|
||||||
|
|
||||||
## 日志规范
|
|
||||||
|
|
||||||
- 所有日志只走 `runtime.log(key, msg, level="info")`:它统一写控制台、内存(`/api/log`)和 SQLite(`/api/log/history`),三处内容必须一致。
|
|
||||||
- 禁止在新代码里自己 `print()` 写业务日志,也不要直接调 `store.run_log_add()`;模块只负责组织 `msg`。
|
|
||||||
- key 约定:账号相关用账号 id(页面卡片「课程日志」直接显示);全局 / 系统事件用 `"system"`(右上角「系统日志」弹窗显示)。
|
|
||||||
- 时间戳、级别、格式都由 `runtime.log` 统一拼(`时间 [级别] 内容`),模块里不要自己加时间前缀。
|
|
||||||
- 前端读日志统一用 `/api/log?key=&since=`(实时)+ `/api/log/history?key=&tail=`(历史兜底),不要另起一套日志接口。
|
|
||||||
|
|
||||||
## 代码结构 / 平台插件
|
|
||||||
|
|
||||||
- 业务代码都在 `src/study_tool/` 包里;根目录只放启动壳 `app.py`、`pyproject.toml`、`static/`、`data/`、`script/` 这类运行时资源。散落的 JS 脚本统一收进 `script/`,不参与运行的参考脚本放 `script/reference/`。
|
|
||||||
- 平台代码只能放在 `src/study_tool/platforms/` 下;一个平台只注册一次(`Platform` + `registry.register`),并在 `Platform.entries` 里声明登录 / 学习入口。同一平台的不同页面代际可以拆成实现模块(如莞易学的 `jspx.py` / `study.py`),由平台模块按 `course["generation"]` 分发。
|
|
||||||
- 新增平台的标准步骤:写 `platforms/<名字>.py`(实现 `study(key, s, d, **opts)`、声明 `entries`,需要登录 / 课程列表 / 发现时再实现对应可选入口)→ 在 `platforms/__init__.py` 的 `load_builtin()` 里 import → 结束。不要改 `engine.py`、`routes/`、前端。
|
|
||||||
- 引擎、路由、前端都不允许直接 import 某个具体平台模块;需要平台能力时用 `registry.get(course["platform"])`;账号级操作(登录 / 刷新课程 / 发现课程 / 会话)按 `accounts.platform` 用 `registry.get()` 取 `Platform` 上的 `session` / `dump_cookies` / `login` / `list_courses` / `discover` / `renew_session` / `profile`;「添加账号」的平台下拉来自 `GET /api/platforms`。
|
|
||||||
- 前端源码在 `web/`(React + TypeScript + Vite + Tailwind CSS),构建产物输出到 `static/` 并提交进仓库;改完前端必须执行 `cd web && pnpm build`,不要直接改 `static/` 里的产物。
|
|
||||||
- 新代码的 import 一律显式(如 `from study_tool.config import JSPX`),不要再新增 `from xxx import *`;历史遗留的 star import 只保留在拆分前就存在的模块里。
|
|
||||||
- `docs/architecture.md` 维护分层与插件契约,改动结构时同步更新。
|
|
||||||
|
|
||||||
## 文档规范
|
## 文档规范
|
||||||
|
|
||||||
- README 保持简洁,只放项目介绍、核心功能、快速开始和文档入口。
|
- README 保持简洁,只放项目介绍、核心功能、快速开始和文档入口。
|
||||||
- 后续待办写到 `docs/progress/todo.md`。
|
- 后续待办写到 `docs/progress/todo.md`,分三段:待做 / 已知偏差 / 未验证。
|
||||||
- 已实现但还需要用户测试确认的事项写到 `OPTIMIZATION.md`。
|
- 已实现但还需要用户测试确认的事项写到 `OPTIMIZATION.md`。
|
||||||
- 每次任务完成前,都要根据实际变更检查并更新 `OPTIMIZATION.md` 和 `OPTIMIZATION.md`;如果功能或待办没有变化,也要确认无需修改。
|
- 每次任务完成前,根据实际变更检查并更新 `OPTIMIZATION.md` 与 `docs/progress/todo.md`;没有变化也要确认无需修改。
|
||||||
|
- 文档里的命令、路径、字段名必须与代码一致;引用代码写 `路径 + 符号名`,不写行号。
|
||||||
- 文档不要写过期日期;除非用户明确要求记录具体时间。
|
- 文档不要写过期日期;除非用户明确要求记录具体时间。
|
||||||
|
|
||||||
## 发版本流程
|
## 发版本流程
|
||||||
|
|
||||||
- 发版本时,先把 `CHANGELOG.md` 的 `Unreleased` 变更整理成新的版本记录,并保留空的 `Unreleased` 标题。
|
本节是通用流程;本项目的版本文件、发版命令和配置细节见 `docs/architecture.md`「发版」。
|
||||||
- 每次提交必须将当前版本号进行提升,更新 `metadata.yaml`里面的`version`中的版本号。
|
|
||||||
- 将当前未提交的代码全部提交到 Git。
|
- 发版本只在用户明确确认后触发;触发前工作区必须干净(见「收尾」)。
|
||||||
- 提交完成后,给当前提交打最新版本号对应的 tag,例如 `v0.0.5`。
|
- 先把 `CHANGELOG.md` 的 `Unreleased` 变更整理成新的版本记录,保留空的 `Unreleased` 标题,并作为一个普通提交先提交。
|
||||||
- 发版本流程中不要执行编译、测试或构建,除非用户明确要求。
|
- 发版前先做依赖锁文件自检:锁文件若有变化,说明某个任务漏提交了它——补进那次任务,不为它单开提交。
|
||||||
- 如果触发重要的决策,你需要将代码提交到`git`中,并且将本次已经修改的内容进行总结
|
- 版本号只在发版时提升,普通提交不动版本号;用项目配置好的发版工具一次完成「改版本号 + 提交 + 打 tag」,**不要再手动 commit 或打 tag**。
|
||||||
|
- 打 tag 前确认没有同名 tag,不覆盖已有 tag;发完确认 tag 存在、HEAD 正确。
|
||||||
|
- 发版本流程中不要执行编译、测试或构建,除非用户明确要求;产物(如前端构建)在发版前的普通提交里完成。
|
||||||
|
|
||||||
|
## 项目契约
|
||||||
|
|
||||||
|
- 日志约定、目录分层、平台插件契约统一维护在 `docs/architecture.md`,改动结构时同步更新;本文件不再重复这些内容。
|
||||||
@@ -2,8 +2,64 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## v0.0.8
|
||||||
|
|
||||||
|
### 体检修复
|
||||||
|
|
||||||
|
- 启动日志不再打印 `AUTH_TOKEN` 明文:日志同时进控制台 / 内存 / SQLite,前端「系统日志」里能直接看到,之前等于把令牌贴在墙上;现在只提示「需要访问令牌:见 .env 的 AUTH_TOKEN」
|
||||||
|
- 系统设置:温度留空(0)不再把已保存的温度清成 0;API Key / app_secret 设置后原本无法清空,字段右侧加「清空」按钮(`clear_api_key` / `notify.clear_app_secret`)
|
||||||
|
- 故障推送:推送失败不再占用整段 `cooldown_min` 冷却,留 1 分钟重试,避免一次网络抖动把通知压掉十分钟
|
||||||
|
- 用户可见报错里的旧目录名 `platforms/` 改成 `plugins/`
|
||||||
|
- `pyproject.toml` 的版本与 `metadata.yaml` 对齐,`bumpversion` 改成同时更新两处,发版不再漂移
|
||||||
|
- 文档里的旧目录名 `pugins/` 全部改成 `plugins/`,`docs/pugin.md` 更名为 `docs/plugin.md`;`.gitignore` 补上 `.idea/`、`.ruff_cache/`
|
||||||
|
|
||||||
|
### 故障推送
|
||||||
|
|
||||||
|
- 新增通知中心 `study_tool/notify.py`:`fault(kind, key, title, content)` 上报故障、`retry(key, what)` 累计重试、`send()` 直接推送;同一 `(类型, 账号)` 按 `cooldown_min` 冷却,重试只在刚达到阈值时推一次
|
||||||
|
- 新增推送渠道插件 `plugins/daidai_push/`(呆呆面板):`/api/open-api/token` 换 token(内存缓存、按 `expires_in` 过期、401 自动换新重发)+ `/api/notifications/send`;`plugin.json` 用 `"kind": "notify"` 声明类型
|
||||||
|
- 插件加载器支持非平台插件:`kind=notify` 的插件注册到 `notify` 注册表,停用 / 重载同样摘除;`/api/plugins` 返回 `kind`,前端能力显示「推送」
|
||||||
|
- 设置项新增 `notify`(`base_url` / `app_key` / `app_secret` / `channel_name` / 各类型开关 / `retry_threshold` / `cooldown_min`),`/api/settings` 读写(密钥只回显前后几位),新增 `POST /api/notify/test` 测试推送
|
||||||
|
- 触发点:课程学完仍未完成 / 进度没推进 / 没有可识别活动(`notify.check_progress`)、课程处理异常、整体运行异常、登录失败、会话失效且无法续期;公需课掉线重登与自动重学、莞易学单课时重报计入重试次数
|
||||||
|
- 前端「系统设置」改成左侧分类(大模型 / 学习模式 / 故障推送)+ 右侧内容的两栏布局:`Modal` 新增 `settings` 宽度档与 `bodyClassName` 覆盖;「测试连接」「测试推送」分别放进对应面板右上角,弹窗底部只留取消 / 保存
|
||||||
|
|
||||||
|
### 刷新课程 / 会话
|
||||||
|
|
||||||
|
- 修复「平台「」还没有实现刷新课程」:`session_expired` 自动重登后的重试请求补上 `platform`,不再把登录成功的结果丢掉
|
||||||
|
- 修复反复重登:运行状态里的旧 cookie 不再在运行结束后回写账号(只在 `discovering` / `studying` 时同步),重新登录时也会清掉运行状态里的 `cookies`
|
||||||
|
- 公需课学习中掉线重登后,把新会话写回账号与运行状态(新增 `database.account_save_cookies`),库里不再留失效会话
|
||||||
|
|
||||||
|
### 公需课交互式测验
|
||||||
|
|
||||||
|
- 平台契约新增 `quiz_submit(self, s, b)` 槽位,能力 `quiz` 改为「`quiz` + `quiz_submit` 都实现」才算;`/api/quiz/submit` 改成按 `registry.get(platform).quiz_submit` 分发,莞易学的交卷逻辑移到 `quiz.submit_quiz`
|
||||||
|
- 公需课实现 `quiz` / `quiz_submit`:考核是**独立活动页**(`/<aid>/study/course/<cid>`),从课程目录找标题含「考核 / 考试」的活动再解析 `#testForm`(题型映射 01 单选 / 02 多选 / 03 判断),弹窗里看题、AI 答题、交卷;交卷按用户选的字母换算成平台选项值提交,已交卷的课也能回看题目
|
||||||
|
- 修复公需课自动考核:`exam.run` 之前读课程主页(没有 `testForm`,永远拿不到题),改为读考核活动页
|
||||||
|
- 前端测验弹窗交卷请求带上 `platform`
|
||||||
|
|
||||||
|
### 公需课自动重学
|
||||||
|
|
||||||
|
- 公需课 `study` 改为「刷一轮 → 重取进度页(`/study/course/progess`)」循环:即使进入时所有任务点都显示已完成,也会重新读取一次平台状态;仍有未完成任务点时等 60 秒后自动重新开始学习,不再需要手动点「开始学习」
|
||||||
|
- 连续 3 轮平台完成数都没增加(如考核等非视频任务点)或达到 5 轮(`MAX_ROUNDS`)时停止,并在日志里列出剩余任务点
|
||||||
|
|
||||||
|
### 公需课选课兜底
|
||||||
|
|
||||||
|
- 公需课刷新课程时,若 `/uc/store/courseRegister` 里没有目标年度的课,自动去 `/uc/store/course/` 选课:已有「进入学习」的课就直接用,否则按配置选第几门;目标年度和序号放在插件目录 `plugins/gdgxk/config.json`(`year` 空 = 当前年份,`pick` 默认 1),不进数据库、不动前端
|
||||||
|
|
||||||
|
### 插件解耦
|
||||||
|
|
||||||
|
- 插件可直接删除:核心不再 import 或写死任何具体平台。加载器记录插件 import 期间注册的路由,停用 / 重载 / 加载失败时一并摘除(修复重载后 `/api/quiz*` 重复注册、停用后仍可访问)
|
||||||
|
- 平台契约新增能力 `quiz`、`capture`;前端「打开测验」「抓包」改为按 `GET /api/platforms` 的 `capabilities` 显示;`/api/quiz` 改走平台的 `quiz` 能力
|
||||||
|
- 去掉写死的 `guanyixue` 默认值(`accounts.platform`、请求模型、抓包兜底、前端初值与回退文案),页脚与添加账号提示不再写死平台名
|
||||||
|
- 插件专属脚本迁到插件目录:`des.js` / `desenc.js` / `study_video.py` → `plugins/guanyixue/script/`,`capture-jsglpt.py` / `test-gdgxk.py` → `plugins/gdgxk/script/`;`test-gdgxk.py` 改用新的插件 API
|
||||||
|
|
||||||
|
### 工程
|
||||||
|
|
||||||
|
- 新增 `.pre-commit-config.yaml`:提交前跑 ruff 检查 / 格式化、yaml 与私钥 / 大文件检查,`web/` 有改动时跑 `pnpm typecheck`,提交信息由 commitizen 校验(约定式提交);`pyproject.toml` 增加 `[tool.ruff]` 与 `[tool.bumpversion]`,发版用 `bump-my-version bump patch` 同步 `metadata.yaml`、提交并打 `v` 开头的 tag
|
||||||
|
|
||||||
### 重构
|
### 重构
|
||||||
|
|
||||||
|
- 清理 `study_tool` 顶层的插件专属代码:莞易学的站点地址 / 登录授权入口 / 课程板块 / `NODE` / `DESENC` / `HEX32` / `CODE_RE` / `DEBUG_DIR` 等常量从 `config/__init__.py` 迁到 `plugins/guanyixue/config.py`,`config/` 只剩 `ROOT` / `DATA` / `STATIC_DIR` 通用路径;测验请求模型(`QuizIn` / `QuizSubmitIn` / `QuizAnswerIn`)、答题提示词 `DEFAULT_ANSWER_PROMPT`、题型名 `TYPE_LABEL` 从 `models.py` / `config/settings.py` 迁到 `plugins/guanyixue/quiz.py`;引擎里"新版课程 ID"日志挪进莞易学 `study()` 分发
|
||||||
|
- 删除无人引用的代码:`config` 里的 `LOGDIR`(及启动时建 `data/logs` 目录)、`SessionExpired` 再导出、`models.py` 的 `AccountsIn`
|
||||||
|
- `app.py` / `engine.py` / `routes/accounts.py` / `routes/misc.py` 以及莞易学 `utils/*` 的 `from study_tool.config import *`、`from study_tool.models import *` 改成显式 import
|
||||||
- 前端从单文件 `static/index.html`(原生 JS)改为 `web/` 工程:React 19 + TypeScript + Vite + Tailwind CSS v4;Vite 直接输出到 `static/`,FastAPI 托管 `/` 与 `/assets`,运行 Python 不再需要 Node
|
- 前端从单文件 `static/index.html`(原生 JS)改为 `web/` 工程:React 19 + TypeScript + Vite + Tailwind CSS v4;Vite 直接输出到 `static/`,FastAPI 托管 `/` 与 `/assets`,运行 Python 不再需要 Node
|
||||||
- 新旧学习平台合并为「莞易学」:`jspx.dgjy.net`(登录入口)与 `study.dgjy.net`(学习入口)统一注册为 `Platform(id="guanyixue", label="莞易学")`,课程页代际(`legacy` / `spa`)由 `platforms/detect.py` 判断,平台内部按代际分发到 `jspx.py` / `study.py`
|
- 新旧学习平台合并为「莞易学」:`jspx.dgjy.net`(登录入口)与 `study.dgjy.net`(学习入口)统一注册为 `Platform(id="guanyixue", label="莞易学")`,课程页代际(`legacy` / `spa`)由 `platforms/detect.py` 判断,平台内部按代际分发到 `jspx.py` / `study.py`
|
||||||
- `Platform` 新增 `entries`(登录 / 学习入口 URL 片段)与 `registry.detect()`;课程发现按入口识别平台,新增平台不再需要改引擎。新增 `GET /api/platforms` 供前端展示平台名
|
- `Platform` 新增 `entries`(登录 / 学习入口 URL 片段)与 `registry.detect()`;课程发现按入口识别平台,新增平台不再需要改引擎。新增 `GET /api/platforms` 供前端展示平台名
|
||||||
@@ -14,9 +70,12 @@
|
|||||||
- 平台拆成插件包 `src/study_tool/platforms/`:`base.py` 定义 `Platform` 契约、`registry.py` 注册表;引擎按 `course["platform"]` 从注册表分发
|
- 平台拆成插件包 `src/study_tool/platforms/`:`base.py` 定义 `Platform` 契约、`registry.py` 注册表;引擎按 `course["platform"]` 从注册表分发
|
||||||
- HTTP 接口收进 `routes/`(accounts / misc / logs),`docs/architecture.md` 说明分层、平台识别与新增平台的步骤
|
- HTTP 接口收进 `routes/`(accounts / misc / logs),`docs/architecture.md` 说明分层、平台识别与新增平台的步骤
|
||||||
- 散落的 JS 脚本统一收进 `script/`:运行必需的 `des.js` / `desenc.js` 放 `script/`(`config.py` 的 `DESENC` 指向 `script/desenc.js`),不参与运行的 `dgjy-toolkit.user.js`、`study-axios.mjs` 移到 `script/reference/` 作参考
|
- 散落的 JS 脚本统一收进 `script/`:运行必需的 `des.js` / `desenc.js` 放 `script/`(`config.py` 的 `DESENC` 指向 `script/desenc.js`),不参与运行的 `dgjy-toolkit.user.js`、`study-axios.mjs` 移到 `script/reference/` 作参考
|
||||||
|
- 平台契约第一步:新增 `platforms/contract.py` 定义生命周期 ↔ 槽位 ↔ 能力名映射,`Platform.capabilities()` 按已实现槽位推导,`GET /api/platforms` 返回 `capabilities`
|
||||||
|
- 平台契约第二步:`SessionExpired` 移到 `platforms/errors.py`;旧版 / 新版学习流程分别从 `engine.py` 搬到 `platforms/jspx.py` / `platforms/study.py`,`engine.py` 只剩账号级编排(115 行);`routes/*`、`quiz.py` 去掉平台 star import,`session` / `dump_cookies` 缺失时明确报错
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
|
- 公开查询页 `/query`:不需要 `AUTH_TOKEN`,顶部输入框填账号用户名后点「查询」,显示该账号的课程列表与当前学习进度;对应公开接口 `GET /api/query?username=`,只返回课程名 / 章节 / 状态 / 进度与运行进度,不含密码、cookie、账号 id
|
||||||
- 新增平台「广东公需课」(`platforms/gdgxk.py`):`jsglpt.gds.edu.cn` 账号密码(base64)登录后换 ITLMS 票进 `jsxx.gds.edu.cn`,课程列表读 `/uc/store/courseRegister`;学习只跑 `updateViewTime1` 进度心跳(服务端按 `lastViewTime` 直接记账,不需要真播)。票按 `ITLMS-xxx||ticketId` 两段交给 `jsxx.gds.edu.cn/nts/loginToStoreByAccount`;登录要过 4 位图形验证码,用 ddddocr 离线识别并自动重试。配套 `script/capture-jsglpt.py`(抓包取证)与 `script/test-gdgxk.py`(接口自测);课程列表解析出培训年度 / 学时 / 完成状态,卡片显示成「2026年公需课 · 未完成 · 30 学时」
|
- 新增平台「广东公需课」(`platforms/gdgxk.py`):`jsglpt.gds.edu.cn` 账号密码(base64)登录后换 ITLMS 票进 `jsxx.gds.edu.cn`,课程列表读 `/uc/store/courseRegister`;学习只跑 `updateViewTime1` 进度心跳(服务端按 `lastViewTime` 直接记账,不需要真播)。票按 `ITLMS-xxx||ticketId` 两段交给 `jsxx.gds.edu.cn/nts/loginToStoreByAccount`;登录要过 4 位图形验证码,用 ddddocr 离线识别并自动重试。配套 `script/capture-jsglpt.py`(抓包取证)与 `script/test-gdgxk.py`(接口自测);课程列表解析出培训年度 / 学时 / 完成状态,卡片显示成「2026年公需课 · 未完成 · 30 学时」
|
||||||
- 统一日志源 `runtime.log()`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份内容;新增右上角「系统日志」弹窗查看 `key=system` 的启动/设置类日志
|
- 统一日志源 `runtime.log()`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份内容;新增右上角「系统日志」弹窗查看 `key=system` 的启动/设置类日志
|
||||||
- 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite
|
- 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite
|
||||||
@@ -33,7 +92,10 @@
|
|||||||
- 服务重启把上次没跑完的运行标成「已中断」后,那句话会被后续 `set_state` 一起写回库,新一轮运行一起步就顶着「服务重启,上次运行已中断」,库里还留着 `status=studying` 的幽灵记录:`runtime.set_state()` 在状态切入 `discovering` / `studying` 时清掉上一轮的 `error` / `progress` / `finished_at`,`hydrate_runs()` 标记后立即落库
|
- 服务重启把上次没跑完的运行标成「已中断」后,那句话会被后续 `set_state` 一起写回库,新一轮运行一起步就顶着「服务重启,上次运行已中断」,库里还留着 `status=studying` 的幽灵记录:`runtime.set_state()` 在状态切入 `discovering` / `studying` 时清掉上一轮的 `error` / `progress` / `finished_at`,`hydrate_runs()` 标记后立即落库
|
||||||
- 服务重启后卡片「课程日志」显示「暂无日志」,SQLite 里的历史读不出来:前端兜底条件原为「内存为空且 status 为空」,而重启后 status 恰好是 `interrupted`(非空),历史永远不读;现在只看内存是否为空
|
- 服务重启后卡片「课程日志」显示「暂无日志」,SQLite 里的历史读不出来:前端兜底条件原为「内存为空且 status 为空」,而重启后 status 恰好是 `interrupted`(非空),历史永远不读;现在只看内存是否为空
|
||||||
- 删除账号只清了按账号 id 记的 `run_state` / `run_log`,登录日志(key 与 `login_log.username` 记的是用户名 / 手机号)残留:`account_delete()` 改为按账号 id + 用户名一起清,`DELETE /api/accounts/{id}` 同时清掉内存里两个 key 的日志
|
- 删除账号只清了按账号 id 记的 `run_state` / `run_log`,登录日志(key 与 `login_log.username` 记的是用户名 / 手机号)残留:`account_delete()` 改为按账号 id + 用户名一起清,`DELETE /api/accounts/{id}` 同时清掉内存里两个 key 的日志
|
||||||
|
- 学习链路上三处引用未定义的全局名(`platforms/jspx.py` 的 `extract_study_token` / `StudyClient` / `save_snapshot` 等、`platforms/study.py` 的 `parse_qs`、`quiz.py` 的 `BeautifulSoup` / `urlsplit`)会让课程发现 / 答题直接 `NameError`:`parse_qs`、`BeautifulSoup`、`urlsplit` 改为模块顶部导入,`jspx` ↔ `study` 之间改用函数内延迟导入(模块级会成环)
|
||||||
- 服务重启留下的「已中断」状态在重新登录后仍然压在卡片上,徽标显示「已中断」并挂着旧进度和红条:登录成功后清掉该账号上一轮已结束的运行状态(内存 + SQLite),正在跑(`discovering` / `studying`)的不动,内存日志保留
|
- 服务重启留下的「已中断」状态在重新登录后仍然压在卡片上,徽标显示「已中断」并挂着旧进度和红条:登录成功后清掉该账号上一轮已结束的运行状态(内存 + SQLite),正在跑(`discovering` / `studying`)的不动,内存日志保留
|
||||||
|
- 本机启动(`python app.py` / `uv run app.py`)时根目录 `.env` 完全无效:只有 docker compose 会读它,`AUTH_TOKEN` / `PORT` / `RUN_LOG_KEEP` / `SNAPSHOT_KEEP` 都得靠命令行环境变量;现在 `study_tool/__init__.py` 用 `python-dotenv` 加载 `.env`(已存在的环境变量优先,Docker 由 compose 注入的不受影响),新增依赖 `python-dotenv>=1.0`
|
||||||
|
- `.env` 里写 `BIND_ADDR` 在本机启动时不起作用(代码只读 `HOST`):监听地址改为 `HOST` → `BIND_ADDR` → `127.0.0.1` 依次回退
|
||||||
|
|
||||||
### 其他
|
### 其他
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ ENV PYTHONUNBUFFERED=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# nodejs: script/desenc.js 要用(登录表单 rsa 字段走站点原版 DES 算法)
|
# nodejs: 莞易学插件 plugins/guanyixue/script/desenc.js 要用(登录表单 rsa 字段走站点原版 DES 算法)
|
||||||
# curl: 健康检查用
|
# curl: 健康检查用
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends nodejs ca-certificates curl tzdata \
|
&& apt-get install -y --no-install-recommends nodejs ca-certificates curl tzdata \
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ set PORT=8849 && python app.py
|
|||||||
set HOST=0.0.0.0 && python app.py
|
set HOST=0.0.0.0 && python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
也可以不 set:把 `.env.example` 复制成根目录 `.env` 再改(`cp .env.example .env`),本机启动同样读它。
|
||||||
|
同名系统环境变量优先于 `.env`;注意 `.env` 里 `AUTH_TOKEN` 非空时,本机访问也要 `http://127.0.0.1:8848/?token=<值>`。
|
||||||
|
|
||||||
### 方式二:Docker(推荐用于远程管理)
|
### 方式二:Docker(推荐用于远程管理)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -59,17 +62,19 @@ docker compose logs -f # 看启动日志
|
|||||||
| 进容器 | `docker compose exec study-tool bash` |
|
| 进容器 | `docker compose exec study-tool bash` |
|
||||||
| 换端口 | `.env` 里改 `PORT`,再 `docker compose up -d` |
|
| 换端口 | `.env` 里改 `PORT`,再 `docker compose up -d` |
|
||||||
|
|
||||||
环境变量:
|
环境变量(写在根目录 `.env` 里,本机启动和 Docker 都读;同名的系统环境变量优先):
|
||||||
|
|
||||||
| 变量 | 默认 | 说明 |
|
| 变量 | 默认 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `HOST` | `127.0.0.1` | 监听地址;Docker 里是 `0.0.0.0` |
|
| `HOST` | `127.0.0.1` | 监听地址;Docker 里是 `0.0.0.0` |
|
||||||
|
| `BIND_ADDR` | 同 `HOST` | `HOST` 的别名(docker compose 用它绑宿主机端口),本机启动也认 |
|
||||||
| `PORT` | `8848` | 端口 |
|
| `PORT` | `8848` | 端口 |
|
||||||
| `DB_PATH` | `<项目>/data/study.db` | SQLite 库文件 |
|
| `DB_PATH` | `<项目>/data/study.db` | SQLite 库文件 |
|
||||||
| `AUTH_TOKEN` | 空 | 非空 = 页面/接口需要 `?token=` 或 cookie;`/api/health` 永远放行 |
|
| `AUTH_TOKEN` | 空 | 非空 = 页面/接口需要 `?token=` 或 cookie;`/api/health` 永远放行 |
|
||||||
| `RUN_LOG_KEEP` | `2000` | 每个账号保留的日志行数 |
|
| `RUN_LOG_KEEP` | `2000` | 每个账号保留的日志行数 |
|
||||||
| `SNAPSHOT_KEEP` | `200` | 课程页快照保留条数 |
|
| `SNAPSHOT_KEEP` | `200` | 课程页快照保留条数 |
|
||||||
| `NODE_BIN` | 自动找 | node.exe 路径(算登录表单的 DES) |
|
| `NODE_BIN` | 自动找 | node.exe 路径(算登录表单的 DES) |
|
||||||
|
| `TZ` | `Asia/Shanghai` | 只对 Docker(Linux 容器)有效,Windows 本机忽略 |
|
||||||
|
|
||||||
镜像默认装 Chromium(直链视频要真播到结尾)。不需要就用
|
镜像默认装 Chromium(直链视频要真播到结尾)。不需要就用
|
||||||
`docker compose build --build-arg WITH_PLAYWRIGHT=0`,镜像能小一大截。
|
`docker compose build --build-arg WITH_PLAYWRIGHT=0`,镜像能小一大截。
|
||||||
@@ -97,7 +102,7 @@ pnpm typecheck
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `accounts` | 账号 / 密码 / 会话 cookie / 课程缓存 / 勾选 / 自动交测验·反馈表开关 |
|
| `accounts` | 账号 / 密码 / 会话 cookie / 课程缓存 / 勾选 / 自动交测验·反馈表开关 |
|
||||||
| `settings` | 大模型配置(`base_url` / `api_key` / `model` / 温度 / 超时 / 提示词) |
|
| `settings` | 大模型配置(`base_url` / `api_key` / `model` / 温度 / 超时 / 提示词) |
|
||||||
| `kv` | 通用缓存(例如 study 平台试出来的接口前缀、`study_video.py` 的会话) |
|
| `kv` | 通用缓存(例如 study 平台试出来的接口前缀、莞易学 `script/study_video.py` 的会话) |
|
||||||
| `login_log` | 每次登录尝试(平台原话,`/api/loginlog` 读它) |
|
| `login_log` | 每次登录尝试(平台原话,`/api/loginlog` 读它) |
|
||||||
| `run_log` | 每个账号的运行日志(`/api/log/history?key=<id>&tail=200`) |
|
| `run_log` | 每个账号的运行日志(`/api/log/history?key=<id>&tail=200`) |
|
||||||
| `run_state` | 运行状态快照,进程重启后 `/api/runs` 仍能看到,未跑完的标 `interrupted` |
|
| `run_state` | 运行状态快照,进程重启后 `/api/runs` 仍能看到,未跑完的标 `interrupted` |
|
||||||
@@ -159,38 +164,33 @@ src/study_tool/
|
|||||||
app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main()
|
app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main()
|
||||||
webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入)
|
webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入)
|
||||||
runtime.py 运行状态 + 统一日志源(控制台 / 内存 / SQLite)
|
runtime.py 运行状态 + 统一日志源(控制台 / 内存 / SQLite)
|
||||||
config.py 常量、环境变量、根目录定位(data / static / script)
|
config/ 全局路径常量(ROOT / DATA / STATIC_DIR);settings.py 系统设置读写
|
||||||
store.py SQLite3:账号 / 设置 / 登录记录 / 运行日志 / 状态 / 缓存 / 快照
|
database/ SQLite 存储(SQLAlchemy ORM):models.py + 读写函数
|
||||||
models.py HTTP 请求模型
|
models.py 通用 HTTP 请求模型
|
||||||
settings_store.py 系统设置读写
|
llm/ 大模型调用
|
||||||
llm.py 大模型调用
|
|
||||||
quiz.py 测验解析 / 交卷 / AI 答题
|
|
||||||
engine.py 学习引擎:发现 → 按平台分发 → worker / 视频 / 提交
|
engine.py 学习引擎:发现 → 按平台分发 → worker / 视频 / 提交
|
||||||
capture.py 抓包
|
platforms/ 平台运行时
|
||||||
platforms/ 平台插件
|
|
||||||
base.py Platform 契约(id / label / study / entries)
|
base.py Platform 契约(id / label / study / entries)
|
||||||
registry.py 注册表:register / get / detect / all
|
registry.py 注册表:register / get / detect / all
|
||||||
detect.py 页面代际识别(旧版 / 新版 SPA)
|
plugins/ 平台插件
|
||||||
guanyixue.py 莞易学:合并 jspx 登录入口与 study 学习入口
|
guanyixue/ 莞易学:main.py + config.py(站点常量)+ utils/ + capture.py(抓包)+ quiz.py(测验)
|
||||||
jspx.py 莞易学旧版页面实现
|
|
||||||
study.py 莞易学新版 SPA 实现
|
|
||||||
routes/ HTTP 接口
|
routes/ HTTP 接口
|
||||||
accounts.py 账号 / 登录 / 登录记录
|
accounts.py 账号 / 登录 / 登录记录
|
||||||
misc.py 课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 平台列表
|
misc.py 课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 平台列表
|
||||||
logs.py /api/log、/api/runs
|
logs.py /api/log、/api/runs
|
||||||
|
settings.py /api/settings
|
||||||
|
llm.py /api/llm/test
|
||||||
web/ 前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4)
|
web/ 前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4)
|
||||||
static/ 前端构建产物(index.html + assets/ + favicon.svg)
|
static/ 前端构建产物(index.html + assets/ + favicon.svg)
|
||||||
script/des.js 服务端 DES 加密(登录表单 rsa 字段,站点原版算法)
|
|
||||||
script/desenc.js DES 命令行封装(Node 调用,config.py 里定位)
|
|
||||||
script/reference/ 不参与运行的参考脚本:油猴 dgjy-toolkit.user.js、study-axios.mjs
|
script/reference/ 不参与运行的参考脚本:油猴 dgjy-toolkit.user.js、study-axios.mjs
|
||||||
|
(插件专属脚本在 plugins/<名字>/script/:莞易学的 des.js、desenc.js、study_video.py,公需课的抓包 / 自测脚本)
|
||||||
start.bat 一键启动(Windows)
|
start.bat 一键启动(Windows)
|
||||||
Dockerfile Docker 镜像(python:3.12-slim + nodejs + chromium)
|
Dockerfile Docker 镜像(python:3.12-slim + nodejs + chromium)
|
||||||
docker-compose.yml Docker 编排:端口、数据卷、AUTH_TOKEN、健康检查
|
docker-compose.yml Docker 编排:端口、数据卷、AUTH_TOKEN、健康检查
|
||||||
.env.example 远程管理用的环境变量样例(复制成 .env)
|
.env.example 环境变量样例(复制成 .env;本机启动与 Docker 都读)
|
||||||
requirements.txt 依赖
|
requirements.txt 依赖
|
||||||
data/study.db SQLite 库(账号/设置/日志/快照)
|
data/study.db SQLite 库(账号/设置/日志/快照)
|
||||||
docs/architecture.md 分层说明 + 平台插件怎么加
|
docs/architecture.md 分层说明 + 平台插件怎么加
|
||||||
study_video.py 独立命令行工具:按 trainCourseId 直接刷视频课时(会话也存 study.db)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
分层与插件契约见 [docs/architecture.md](docs/architecture.md):引擎只按 `course["platform"]`
|
分层与插件契约见 [docs/architecture.md](docs/architecture.md):引擎只按 `course["platform"]`
|
||||||
@@ -217,7 +217,7 @@ GET jspx.dgjy.net/admin/main.action table-results 课程表
|
|||||||
|
|
||||||
新版页面(慕课)另走一条 OAuth 码换 token 的链,拿到 `study.dgjy.net` 的 `Middle-Token`,见 `ensure_study_session()`。
|
新版页面(慕课)另走一条 OAuth 码换 token 的链,拿到 `study.dgjy.net` 的 `Middle-Token`,见 `ensure_study_session()`。
|
||||||
`jspx.dgjy.net` 与 `study.dgjy.net` 在注册表里是同一个平台(`guanyixue` / 莞易学),
|
`jspx.dgjy.net` 与 `study.dgjy.net` 在注册表里是同一个平台(`guanyixue` / 莞易学),
|
||||||
课程发现时自动判断页面代际(旧版 / 新版),分别走 `jspx.py` / `study.py` 的学习实现。
|
课程发现时自动判断页面代际(旧版 / 新版),分别走 `utils/learn.py` / `utils/spa.py` 的学习实现。
|
||||||
|
|
||||||
## 接口
|
## 接口
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
真正的代码在 src/study_tool/ 包里,这里只把 src 加进 sys.path 再调 main(),
|
真正的代码在 src/study_tool/ 包里,这里只把 src 加进 sys.path 再调 main(),
|
||||||
这样 `python app.py`、`start.bat`、Dockerfile 都不用改。
|
这样 `python app.py`、`start.bat`、Dockerfile 都不用改。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
|
||||||
|
|
||||||
from study_tool.app import main # noqa: E402
|
from study_tool.app import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+386
-1
@@ -2,13 +2,398 @@
|
|||||||
|
|
||||||
已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。
|
已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。
|
||||||
|
|
||||||
|
## 发版体检修复(`src/study_tool/app.py`、`routes/settings.py`、`notify.py`、`web/src/components/SettingsDialog.tsx`)
|
||||||
|
|
||||||
|
**状态**:已实现,已用独立端口 + 独立库实例复测(首页 / `/query` / 系统设置 / 插件管理均正常渲染,无 console 报错);「清空密钥」「推送失败重试」需你实测。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 启动日志不再输出 `AUTH_TOKEN` 明文(日志同时进控制台、内存和 SQLite,前端「系统日志」里能看到),改成「(需要访问令牌:见 .env 的 AUTH_TOKEN)」。
|
||||||
|
- 系统设置:温度留空 / 填 0 = 不改,不再把已保存的温度清成 0;API Key、app_secret 右侧加「清空」按钮(`clear_api_key`、`notify.clear_app_secret`),不用再拿一个假值覆盖。
|
||||||
|
- 故障推送:发送失败只冷却 1 分钟,原来一次网络抖动会把整段 `cooldown_min` 的冷却占掉。
|
||||||
|
- 文案与文档:`platforms/`、`pugins/` 统一成 `plugins/`;`docs/pugin.md` 更名为 `docs/plugin.md`。
|
||||||
|
- 工程:`pyproject.toml` 版本与 `metadata.yaml` 对齐,`bumpversion` 同时更新两处;`.gitignore` 补 `.idea/`、`.ruff_cache/`。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 重启服务,看控制台和右上角「系统日志」:启动那行应是 `控制台启动 -> http://127.0.0.1:8848(需要访问令牌:见 .env 的 AUTH_TOKEN)`,不应出现令牌本身。
|
||||||
|
2. 已保存 API Key 时打开「系统设置 → 大模型」:API Key 右侧有「清空」,点了保存后 `has_key` 应变成 false;「故障推送」里的 app_secret 同理。
|
||||||
|
3. 把推送 `base_url` 改成打不通的地址,触发一次故障推送:日志应出现「故障推送失败」,约 1 分钟后再次触发会重新尝试,而不是等满 10 分钟。
|
||||||
|
|
||||||
|
## 卡片标题显示学员姓名 + 日志面板明亮模式(`plugins/gdgxk/main.py`、`utils/learn.py`、`web/src/components/ui/LogLines.tsx`、`AccountCard.tsx`、`SystemLogDialog.tsx`)
|
||||||
|
|
||||||
|
**状态**:已实现,已用隔离实例截图核对(标题显示平台姓名、日志面板明亮 + 分级配色);「登录后回填姓名」和「进度上报新文案」需你下次学习时确认。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 广东公需课 `profile()` 改成先解析个人中心头像区的 `<a class="name">`:学员账号平台显示的是打码姓名(如「*栋」),老师账号是完整姓名;解析不到才退回原来的「你好,X 老师」。卡片标题仍是 `user → username` 的优先级,所以登录 / 刷新课程后标题就变成姓名,第二行继续显示登录账号。
|
||||||
|
- 进度上报日志改成能对得上的说法:`进度上报:平台已记 1200/2580 秒(46%),本次上报到 1201 秒`;课件结束改 `课件完成:xxx(平台已记 2580/2580 秒)`,不再出现「还差一半却说 100%」。
|
||||||
|
- 新增 `ui/LogLines.tsx`:按 `时间 [级别] 内容` 拆行渲染,info 灰蓝、warn 琥珀、error / fail 红色、success 绿色,时间戳淡灰。
|
||||||
|
- 账号卡片的「课程日志」和右上角「系统日志」都从黑底改成浅色底(`bg-slate-50` + 细边框),长行自动换行不再横向滚动。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 刷新页面后点一次「刷新课程」(或「重新登录」):卡片标题应从手机号变成平台姓名(广东公需课学员是「*栋」这种打码名),第二行仍显示登录账号。
|
||||||
|
2. 下次学习时看日志:应出现 `进度上报:平台已记 x/y 秒(z%),本次上报到 n 秒`,百分比与秒数对得上;课件跑完是 `平台已记 y/y 秒`。
|
||||||
|
3. 日志面板应是浅色底:info 灰蓝、warn 琥珀色、error 红色;点右上角「系统日志」也是同样的浅色配色,长行自动换行。
|
||||||
|
|
||||||
|
## 账号卡片:修改账号信息弹窗 + 自绘确认弹窗(`web/src/components/AccountEditDialog.tsx`、`ui/ConfirmDialog.tsx`、`ui/Modal.tsx`、`ui/Button.tsx`、`AccountCard.tsx`、`QuizDialog.tsx`、`App.tsx`)
|
||||||
|
|
||||||
|
**状态**:已实现,已用独立端口 + 复制库实例截图核对「修改账号信息」弹窗与「删除账号」确认框;「保存后自动重新登录」这一条还需你实测。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 账号卡片「更多操作」菜单最上面新增「修改账号信息」;账号带 `lastError` 时,红条下方也直接给一个「修改账号信息」按钮,出错账号不用再翻菜单。
|
||||||
|
- 新增 `AccountEditDialog`:可改学习平台 / 登录账号 / 密码 / 备注。密码留空表示不修改(后端 `account_upsert` 保留原密码),带显示/隐藏切换。保存后写库并立刻用新凭据重新登录一次:弹窗先关,登录在后台跑,失败走 toast + 卡片红条。
|
||||||
|
- 新增 `ui/ConfirmDialog.tsx` 的 `useConfirm()`:项目内统一风格的确认弹窗(图标 + 标题 + 说明 + 主次按钮,危险操作用红色实心按钮)。替换了三处 `window.confirm`:删除账号(`App.tsx`)、提交测验缺题(`QuizDialog.tsx`)、再考一次(`QuizDialog.tsx`)。
|
||||||
|
- `Modal` 增加 `sm` 尺寸;`Button` 增加 `danger-solid` 变体。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 刷新页面,点账号卡片右下「⋯」→「修改账号信息」:弹窗应回填当前平台 / 账号 / 备注,密码框为空并提示「已保存密码,需要更换时再填」。
|
||||||
|
2. 只改备注后保存:应提示「账号信息已保存,正在用新凭据重新登录…」,随后登录成功并刷新课程;密码留空不应把库里密码清空。
|
||||||
|
3. 故意填错密码保存:卡片应变成「登录失败」+ 红条,红条下出现「修改账号信息」按钮,点它能直接再改。
|
||||||
|
4. 点「⋯」→「删除账号」:弹自绘确认框(红色图标 + 红色「删除账号」按钮),取消不删除、确定才删;测验缺题提交、再考一次也应弹同样的自绘弹窗,不再出现浏览器默认框。
|
||||||
|
|
||||||
|
|
||||||
|
## 故障推送:呆呆面板推送渠道(`study_tool/notify.py`、`plugins/daidai_push/`、`routes/settings.py`、`engine.py`、`web/src/components/SettingsDialog.tsx`)
|
||||||
|
|
||||||
|
**状态**:已实现并实测通过(本机已用给定 app_key / app_secret 换到 token 并成功发出测试推送;进度异常 / 多次重试的自动触发还需一次真实学习验证)。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 新增 `study_tool/notify.py`:渠道注册表 + `fault()` 故障上报 + `retry()` 重试计数 + 冷却(同账号同类型 `cooldown_min` 分钟内只推一次,重试只在刚达到阈值时推)。
|
||||||
|
- 新增插件 `plugins/daidai_push/`(`plugin.json` 里 `"kind": "notify"`):`POST {base_url}/api/open-api/token` 换 token,内存缓存并按 `expires_in` 过期,发送遇 401/403 自动换新 token 重发一次;`POST {base_url}/api/notifications/send` 发通知。
|
||||||
|
- 设置项 `notify`:`base_url`(默认 `https://corn.hanhan.ltd`)、`app_key`、`app_secret`、`channel_name`(留空走默认渠道)、`enabled`、四个分类开关、`retry_threshold`(默认 3)、`cooldown_min`(默认 10)。`GET/POST /api/settings` 读写,`app_secret` 只回显前后几位;新增 `POST /api/notify/test`。
|
||||||
|
- 触发点:学完仍未完成 / 进度没推进 / 没有可识别活动、课程处理异常、整体运行异常、登录失败、会话失效且无法续期;公需课掉线重登与「未完成任务点自动重学」、莞易学单课时重报都计入重试次数。
|
||||||
|
- 前端「系统设置」新增「故障推送」区块(含「测试推送」按钮),插件管理里该插件显示能力「推送」。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 重启服务,打开「系统设置」:左侧是「大模型 / 学习模式 / 故障推送」三栏,右侧只显示当前分类;切到「故障推送」应能看到已填好的 app_key / app_secret(secret 显示为掩码),点面板右上「测试推送」应收到一条「【测试】学习控制台推送」。
|
||||||
|
2. 「插件管理」里应有「呆呆面板推送」卡片,能力显示「推送」;停用后点「测试推送」应提示没有可用渠道,重新启用后恢复。
|
||||||
|
3. 跑一门**学不完**的课(或故意让会话失效):课程结束应收到「学习进度异常」或「登录/会话异常」推送,且同一账号 10 分钟内不会重复推。
|
||||||
|
4. 公需课连续 3 轮进度不增加、或莞易学单课时连续 3 次上报未生效时,应收到「多次重试」推送(阈值可在设置里改)。
|
||||||
|
|
||||||
|
## 插件管理卡片:能力中文 + 适配网站(`web/src/components/PluginsDialog.tsx`、`plugins/__init__.py`、`platforms/contract.py`)
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认(前端已 `cd web && pnpm build`;`/api/plugins` 多了 `sites`,需重启服务才生效)。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 卡片上的能力名由 `login / session / …` 改成中文:登录 / 会话 / 续期 / 账号信息 / 课程列表 / 课程详情 / 学习 / 测验 / 抓包(映射在 `PluginsDialog.tsx`,接口仍返回能力键)。
|
||||||
|
- `/api/plugins` 每条插件多返回 `sites`:取平台 `entries` 并去掉被更具体域名覆盖的父域(莞易学的 `dgjy.net` 不会单独列)。
|
||||||
|
- 卡片内的信息改成白底分组面板:`适配网站` 一行用等宽字体标签列出域名,`能力` 一行用蓝色标签(chip)逐个列出,`平台 / id` 放在最下面一行弱化展示。
|
||||||
|
- 顺手把卡片里过期的 `plugins/`、`plugin.json` 文案改成 `plugins/`、`plugin.json`。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 重启服务后打开右上角「插件管理」:两张卡片都显示中文能力,且各有一行「适配网站」(公需课 `jsglpt.gds.edu.cn · jsxx.gds.edu.cn`,莞易学 `jspx.dgjy.net · study.dgjy.net`)。
|
||||||
|
2. 点停用 / 启用 / 重载:清单刷新后网站与能力仍正确。
|
||||||
|
|
||||||
|
## 公需课交互式测验(`plugins/gdgxk/main.py` 的 `quiz` / `quiz_submit`)
|
||||||
|
|
||||||
|
**状态**:取题已验证(本机账号那门课解析出 30 道题、题型 01、选项 A–D);**交卷未实测**(该课考核早已提交,`done=true`,所以弹窗只会显示题目、按钮禁用)。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 公需课补上 `quiz` / `quiz_submit` 两个槽位,前端卡片的「打开测验」因此出现(能力由方法推导)。
|
||||||
|
- 取题:考核是**独立活动页** `jsxx.gds.edu.cn/<aid>/study/course/<plan_id>`(课程主页上没有 `#testForm`),从课程目录里找标题含「考核 / 考试」的活动,打开它再 `exam.parse`;题型映射成弹窗认的 `01/02/03`。已交卷的课也返回题目,方便回看。
|
||||||
|
- 交卷:`exam.submit_letters` 重取一次考核页拿隐藏字段,把用户选的字母按选项 `value`(`Choice0..4`)换成平台要的值,再走原来的 `finishTest2` 提交并回读得分。
|
||||||
|
- `/api/quiz/submit` 改为按平台分发;莞易学的交卷逻辑挪进 `quiz.submit_quiz`,行为不变。
|
||||||
|
- 顺带修好学习流程里的自动考核:`exam.run` 之前也读课程主页(拿不到表单),现在同样改为读考核活动页。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 找一门**考核还没交**的公需课,点「打开测验」:应列出 30 道题的题干 / 题型 / 选项。
|
||||||
|
2. 点「AI 答题」:应分批把答案填进选项(走 `/api/quiz/answer`,纯大模型)。
|
||||||
|
3. 改几道答案后点交卷:应返回得分;再点一次「打开测验」应显示已交卷且题目可见。
|
||||||
|
4. 若提示「考核页里没有找到题目」,把该考核页 HTML 发我,`exam.parse` 的选择器要跟着改。
|
||||||
|
|
||||||
|
## 刷新课程反复重登 / 空平台报错(`web/src/App.tsx`、`routes/accounts.py`、`plugins/gdgxk/main.py`)
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认(前端需先 `cd web && pnpm build`)。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- `refreshCourses` 在 `session_expired` 自动重登后的重试补上 `platform`,不再出现「平台「」还没有实现刷新课程」,登录成功的课程也会正常写回卡片。
|
||||||
|
- 前端只在运行中(`discovering` / `studying`)才把 `/api/runs` 的 `cookies` 回写账号;运行结束后不再用开跑时的旧会话覆盖刚登录的新会话。
|
||||||
|
- `_clear_done_run` 一并清掉运行状态里的 `cookies`,重新登录后旧会话不会继续挂在 `/api/runs` 里。
|
||||||
|
- 公需课学习中掉线重登(`_relogin`)成功后,用 `database.account_save_cookies` 把新会话写回账号,并 `set_state(cookies=…)` 同步运行状态。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 让账号会话失效(隔一段时间或换设备登录一次),点「刷新课程」:应看到「会话已失效,正在自动重新登录…」后直接读到课程,不再弹「平台「」还没有实现刷新课程」。
|
||||||
|
2. 自动重登后等 10 秒以上,再点一次「刷新课程」:不应再次触发登录(说明旧 cookie 没有被写回去)。
|
||||||
|
3. 学习中故意让会话掉线,日志出现「重新登录成功,继续学习」后结束运行,再点「刷新课程」:应一次成功、不再重登。
|
||||||
|
|
||||||
|
## 公需课自动重学(`plugins/gdgxk/main.py` 的 `study`)
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 每刷完一轮活动,都重新请求进度页 `/study/course/progess` 取任务点状态;全部已完成(或一开始就全部已完成)时也会重取一次,以平台状态为准。
|
||||||
|
- 进度页里还有未完成任务点,就等 60 秒(`ROUND_WAIT`)后自动重新开始学习,只重刷未完成的活动。
|
||||||
|
- 连续 3 轮(`MAX_STALL`)完成数没增加,或达到 5 轮(`MAX_ROUNDS`)就停止,日志列出剩余任务点,避免考核等非视频任务点死循环。
|
||||||
|
- 重试等待期间点「停止」会立即退出。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 选一个有未完成任务点的账号点「开始学习」,观察日志有没有「第 N 轮结束……稍后自动重新学习」,并且自动开始下一轮。
|
||||||
|
2. 选一个全部任务点已完成的账号,日志应显示「平台进度:N/N 个活动已完成」后结束。
|
||||||
|
3. 若某个任务点平台迟迟不记完成,应在 3 轮后停下并列出该任务点;如果 60 秒不够平台刷新状态,调大 `ROUND_WAIT`。
|
||||||
|
|
||||||
|
## 公需课选课兜底(`plugins/gdgxk/utils/register.py`)
|
||||||
|
|
||||||
|
**状态**:已实现,**未实测**(写的时候没有账号,选课超市的页面结构和选课请求是按截图推断的)。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- `list_courses` 发现课程列表里没有目标年度的课时,调用 `register.ensure`:读 `/uc/store/course/` 里所有课程链接,逐个打开详情页判断状态。
|
||||||
|
- 状态判断:任一页按钮是「进入学习」= 已经选中,直接重读课程列表;选中的课位置对应页写「已有选课」= 已选了别的课,不再选;都没选就选第 `pick` 门。
|
||||||
|
- 配置在 `plugins/gdgxk/config.json`:`year`(空 = 当前年份)、`pick`(第几门,从 1 开始)。暂未做弹窗让用户挑。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 用一个当年还没选课的账号点「刷新课程」,看系统日志有没有「公需课选课:…」。
|
||||||
|
2. 若提示「没有解析到课程链接」或「没有找到选课按钮的请求地址」,把 `/uc/store/course/` 页面 HTML、未选状态的详情页 HTML、点「选课」时的请求(cURL)发我,据此改 `register.py` 里的正则和请求方式(现在假定选课是 GET)。
|
||||||
|
|
||||||
|
## 公开查询页 `/query`
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认(前端需先 `cd web && pnpm build`)。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 新增 `routes/query.py`:`GET /api/query?username=` 按用户名返回账号的课程列表(名称 / 章节 / 状态 / 进度)和运行进度,不含密码 / cookie / 账号 id。
|
||||||
|
- `app.py` 的 token 中间件放行 `/query`、`/api/query`、`/favicon.svg` 和 `/assets/*`;`/query` 与 `/` 共用同一个前端入口。
|
||||||
|
- 前端 `main.tsx` 按路径渲染:`/query` 显示新页面 `pages/QueryPage.tsx`(上方输入框 + 查询按钮,下方课程列表与进度),其余路径仍是控制台。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 设置 `AUTH_TOKEN` 后启动,不带 token 打开 `/query`,应能正常显示;打开 `/` 仍要求 token。
|
||||||
|
2. 输入已添加账号的用户名点「查询」,应显示课程列表和进度;输入不存在的用户名显示「没有找到该账号」。
|
||||||
|
3. 注意:该接口任何人知道用户名就能查到课程与进度,公网部署请确认可接受。
|
||||||
|
|
||||||
|
## 题库匹配(核心 `bank.py` + 公需课题库数据)
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认。匹配部分已完成;公需课考核已接到它上面(见下一节)。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 新增 `src/study_tool/bank.py`(与平台无关):题干归一化后按「精确 → 互相包含(两边至少 8 字)→ 相似度 ≥ 0.9」查题库;没命中的题按 5 题一批问设置里的大模型;没配大模型就留空并打 warn 日志。入口 `bank.answer(key, questions, bank)`。
|
||||||
|
- 新增 `plugins/gdgxk/utils/bank.py` 与 `plugins/gdgxk/bank/2026.json`:题库数据由脚本的 121 条 `videoBank` 转成 `[{"q": 题干, "a": "B"}]`。明年换题库只需往 `bank/` 加 JSON,同目录所有 `*.json` 会合并。
|
||||||
|
- `answer.py`(考核按序答案)没动:考核改走题干匹配,按序答案留着备用。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 在项目根目录用 Python 执行:`from study_tool.plugins.gdgxk.utils import bank; bank.answer("t", [{"no":1,"type":"判断","title":"生成式人工智能(GenAI)目前还没有在教育领域得到广泛应用。","options":[{"key":"A","text":"正确"},{"key":"B","text":"错误"}]}])`,应返回 `{"1": {"answer": ["B"], "source": "bank"}}`。
|
||||||
|
2. 换一道题库里没有的题:没配大模型时日志有「还没配置大模型」警告;配了大模型会返回 `source: "llm"`。
|
||||||
|
|
||||||
|
## 公需课课程考核(`plugins/gdgxk/utils/exam.py`)
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认(本轮只验证了解析与选项映射,交卷响应体还没在真实环境跑过)。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 考核不是 JSON 接口:题目内嵌在课程学习页 HTML(`#topicList .m-topic-item`),交卷是 `POST .../ncts/test/delivery/<id>/finishTest2`,body 为 `_method=put` + 表单序列化(Spring method override)。
|
||||||
|
- 题型与字段:单选 `radio`、多选 `checkbox`(最多 5 个选项)、是非 `radio`,字段名 `qti_response_Q{n}:{n+1}:1_RESPONSE`,值 `Choice0..4`;隐藏字段 `qti_item_*` 原样回传。
|
||||||
|
- `exam.parse` 解析题面 / 选项字母 / 提交值;`exam.answer` 把题库或 AI 给的字母换成 `ChoiceN`;`exam.submit` 按页面 `ajaxSubmit` 的做法交卷。
|
||||||
|
- 题库没覆盖全部题目时不交卷,只打 warn 并列出缺答案的题号(避免瞎蒙把分数拉低);交完卷解析「已得 N 分」,低于 60 分打 warn 提示人工重考。
|
||||||
|
- `main.py` 的 `study()` 在视频轮次跑完、进度仍有缺口时调用一次考核,交完再按进度页复核。`opts["exam_auto"]=False` 可关掉自动交卷。
|
||||||
|
- 页面上已显示「您已完成测验」时直接跳过,不会重复交卷。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 找一门视频已刷完、只差考核的课跑一次,日志应出现「考核已交卷,得分 N」。
|
||||||
|
2. 故意让题库缺题(临时改 `bank/2026.json`):应出现「考核共 N 题,M 题没答案,本次不交卷」,且不提交。
|
||||||
|
3. 重跑已完成的课:日志应是「考核已完成」,不再请求 `finishTest2`。
|
||||||
|
|
||||||
|
## 目录整理:config / database / llm 包,平台代码收进插件
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 二次清理:莞易学专属常量(站点地址、登录授权入口、课程板块、`NODE` / `DESENC`、`DEBUG_DIR` 等)迁到 `plugins/guanyixue/config.py`;测验请求模型 / 答题提示词 / 题型名迁到 `plugins/guanyixue/quiz.py`;`config.LOGDIR`、`SessionExpired` 再导出、`AccountsIn` 无人使用,已删除;核心模块的 star import 改为显式。
|
||||||
|
- `config.py` → `config/` 包:`config/__init__.py` 放常量 / 环境变量 / 根目录定位,`config/settings.py` 放系统设置默认值与读写。
|
||||||
|
- `settings_store.py` 拆开:`/api/settings` 移到 `routes/settings.py`;设置读写留在 `config/settings.py`(插件不再反向 import 路由)。
|
||||||
|
- `llm.py` → `llm/` 包:`llm_chat()` 在 `llm/__init__.py`,`/api/llm/test` 移到 `routes/llm.py`;后续大模型相关代码都放这个目录。
|
||||||
|
- `store.py` → `database/` 包,改用 SQLAlchemy 2.0 ORM:`database/models.py` 定义 settings / kv / accounts / login_log / run_log / run_state / snapshots 七张表,`database/__init__.py` 提供 engine、线程级 scoped Session 和原来的读写函数(函数名与返回结构不变)。
|
||||||
|
- `capture.py` → `plugins/guanyixue/capture.py`;抓包接口按账号所属平台走 `registry.get(...)` 的 `capture` / `captures` / `capture_reset`。
|
||||||
|
- `quiz.py` → `plugins/guanyixue/quiz.py`(莞易学专用);插件 `main.py` import 它来注册 `/api/quiz/*`。
|
||||||
|
- `app.py` 调 `routes.load_routes()` 自动扫描并导入 `routes/*`(新增路由文件不用改 `app.py`,之前漏写 import 会导致全部 `/api/*` 404);新增依赖 `sqlalchemy>=2.0`(`pyproject.toml`,需 `uv sync`)。
|
||||||
|
- `docs/architecture.md`、`README.md`、`docs/plugin.md` 同步更新目录与模块引用。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. `uv sync` 后启动,`/api/health` 正常,`data/study.db` 能正常建表 / 读写。
|
||||||
|
2. 账号列表 / 登录 / 登录记录、课程日志、运行状态(`/api/runs`)、快照、设置保存、LLM 测试接口,行为与改造前一致。
|
||||||
|
3. 抓包开始 / 停止 / 查看记录正常;插件停用 / 启用 / 重载正常。
|
||||||
|
4. 莞易学插件启用时,`/api/quiz`、`/api/quiz/submit`、`/api/quiz/answer` 仍可用。
|
||||||
|
5. 莞易学账号登录(旧版 jspx 与新版 study 两条链路都走一遍)、刷新课程、学习正常;新版课程学习开头仍有「新版课程 ID」日志。
|
||||||
|
6. 首次启动不再创建 `data/logs` 目录(只要 `data/debug` 与数据库)。
|
||||||
|
7. 启动后不会再出现 `src/data/`:`database` 的 `DATA_DIR` 复用 `config.DATA`(仓库根 `data/`)。之前 `database/` 比旧 `store.py` 深一层,路径多上了一级才落到 `src/data`;原 `src/data/study.db` 里的账号 / 登录记录 / 日志已合并进根目录 `data/study.db`,页面应能看到两个 gdgxk 账号。
|
||||||
|
|
||||||
|
### 已知取舍
|
||||||
|
|
||||||
|
- `/api/quiz/submit`、`/api/quiz/answer` 仍由莞易学插件自己注册;加载器负责在停用 / 重载时摘掉,所以不再重复注册。要做成通用路由需等第二个平台有测验。
|
||||||
|
|
||||||
|
## 插件可删除:路由随插件进出、前端按能力显示、残件归位
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认(我只做了静态检查、前端 typecheck / build,以及在临时副本里实际启动验证)。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- `plugins/__init__.py`:加载器记录每个插件 import 期间注册到 `app` 的路由,停用 / 重载 / 加载失败时一并摘除;重载不再重复注册 `/api/quiz*`。
|
||||||
|
- `platforms/contract.py` / `base.py`:能力新增 `quiz`(`quiz(self, s, d, b)`)与 `capture`;`/api/platforms` 的 `capabilities` 会带上它们。莞易学 `quiz.py` 的取题拆成 `fetch_quiz`,`/api/quiz` 改走 `pf.quiz`。
|
||||||
|
- 去掉写死的 `guanyixue` 默认值:`accounts.platform` 默认空串、请求模型默认空串、`_capture_platform` 不再兜底;前端平台列表初值为空,「打开测验」「抓包」按 `capabilities` 显示,页脚和添加账号提示不再写「莞易学」。
|
||||||
|
- 残件归位:`script/des.js`、`desenc.js`、`study_video.py` → `plugins/guanyixue/script/`;`script/capture-jsglpt.py`、`test-gdgxk.py` → `plugins/gdgxk/script/`;`DESENC` 路径随插件目录走;`test-gdgxk.py` 改成走新的插件 API。
|
||||||
|
- 已验证(临时副本):正常启动;停用 → 启用 → 重载两次后 `/api/quiz*` 恰好各一条;删掉莞易学目录 / 删光所有插件目录后服务都能启动,`/api/platforms` 分别只剩 gdgxk / 为空。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. `cd web && pnpm build` 后启动,界面能打开;平台下拉里有「莞易学」「广东公需课」。
|
||||||
|
2. 莞易学账号卡片「更多」菜单有「打开测验」「抓包」;广东公需课账号的菜单里没有这两项。
|
||||||
|
3. 「插件」弹窗里停用莞易学:`/api/quiz` 返回 404;再启用恢复;反复点「重载」不报错。
|
||||||
|
4. 把 `plugins/guanyixue` 整个目录挪走再启动:服务正常,添加账号只能选公需课;之前的莞易学账号登录时提示平台未实现,不影响其它账号。
|
||||||
|
5. 在仓库根目录执行 `python src/study_tool/plugins/guanyixue/script/study_video.py verify <trainCourseId>` 能跑(脚本自己把 `src` 加进 `sys.path`)。
|
||||||
|
6. Docker 构建后 nodejs 仍能被登录调用(`DESENC` 现在指向插件目录)。
|
||||||
|
|
||||||
|
## 本机启动读取根目录 .env
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- 修问题:以前只有 docker compose 会读 `.env`,本机 `python app.py` / `uv run app.py` / `start.bat`
|
||||||
|
下 `AUTH_TOKEN` / `PORT` / `RUN_LOG_KEEP` / `SNAPSHOT_KEEP` 全部不生效。
|
||||||
|
- `study_tool/__init__.py` 用 `python-dotenv` 加载仓库根 `.env`;放在包初始化处,早于 store / config
|
||||||
|
读环境变量(`database/__init__.py` 是 import 时就读 `DB_PATH` / `RUN_LOG_KEEP`)。已存在的环境变量优先,Docker 由 compose 注入的不受影响。
|
||||||
|
- 新依赖 `python-dotenv>=1.0`(`pyproject.toml` / `requirements.txt`,需 `uv sync`)。
|
||||||
|
- `app.py` 监听地址改成 `HOST` → `BIND_ADDR` → `127.0.0.1`,`.env` 里的 `BIND_ADDR` 本机也生效。
|
||||||
|
- README 环境变量表补 `.env` 说明与 `BIND_ADDR` / `TZ` 两行;`.env.example` 说明改为「本机与 Docker 都读」。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 根目录有 `.env`(`AUTH_TOKEN` 非空)时直接 `python app.py`,启动日志里的地址应带 `?token=`。
|
||||||
|
2. 不带 token 开 `http://127.0.0.1:8848/` 应显示「需要访问令牌」页;带 `?token=<值>` 正常进控制台。
|
||||||
|
3. `.env` 改 `PORT=8849` 重启 → 实际监听 8849;命令行 `set PORT=8850` 再启动应压过 `.env`。
|
||||||
|
4. `.env` 里 `BIND_ADDR=127.0.0.1` → 只绑本机;改 `0.0.0.0` → 同网段可访问。
|
||||||
|
|
||||||
|
## 莞易学工具按阶段拆到 utils
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- `plugins/guanyixue/` 下的 `jspx.py` / `study.py` 按阶段拆到 `utils/`:`http.py`(会话 / cookie /
|
||||||
|
token)、`login.py`(登录 / SSO / 续期)、`course.py`(旧版课程抓取与代际分发)、
|
||||||
|
`feedback.py`(反馈表 / 测验)、`learn.py`(旧版学习流程)、`spa.py`(新版 SPA)。
|
||||||
|
- `main.py` 只留 `Platform` 子类,按 `course["generation"]` 分发到 `learn` / `spa`。
|
||||||
|
- 新模块全部改成显式 import,不再用 `from study_tool.config import *` / `jspx import *`;
|
||||||
|
`quiz.py` 的引用同步改成 `utils.*`。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. `GET /api/plugins` 里 `guanyixue` 的 loaded 为 true。
|
||||||
|
2. 用莞易学账号走一遍登录、刷新课程、发现课程(旧版 / 新版各一门更好)。
|
||||||
|
3. 启动一次学习并停止,确认日志里仍有进度与完成状态输出。
|
||||||
|
|
||||||
|
## 莞易学迁到插件目录
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- `platforms/guanyixue.py` → `plugins/guanyixue/main.py`;`jspx.py` / `study.py` / `detect.py`
|
||||||
|
一并搬进 `plugins/guanyixue/`,插件自包含,热重载能覆盖全部实现模块。
|
||||||
|
- 导入路径改成 `study_tool.plugins.guanyixue.*`;`quiz.py` 里对 `jspx` / `study` 的引用同步更新。
|
||||||
|
- `platforms/__init__.py` 只剩运行时,`load_builtin()` 只调 `load_plugins()`。
|
||||||
|
- 新增 `plugins/guanyixue/plugin.json`(id / title / name)。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. `GET /api/plugins` 列出 `gdgxk` 与 `guanyixue`,两者 enabled / loaded 都是 true。
|
||||||
|
2. 莞易学:刷新课程 → 发现课程 → 开始学习(旧版 / 新版各一次);测验弹窗照常。
|
||||||
|
3. 插件弹窗里对 guanyixue 点「重载」后再跑一次学习,行为不变。
|
||||||
|
|
||||||
|
## 插件热加载 + 启用/停用清单
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- `plugins/__init__.py` 重写:`load_plugins()` / `discover()` / `set_enabled()` / `reload()`。
|
||||||
|
- 热加载:把 `study_tool.plugins.<名字>`(含子模块)从 `sys.modules` 摘掉后重新 import,
|
||||||
|
import 时 `registry.register()` 覆盖;重载前先 `registry.unregister()` 旧 id,改 id 不留残影。
|
||||||
|
- 启用清单存在设置项 `plugins`(`{"disabled": [...]}`):没列进去的默认启用,
|
||||||
|
新丢进目录的插件自动生效;停用的插件被摘出注册表,`/api/platforms` 与账号平台下拉同步消失。
|
||||||
|
- `platforms/registry.py` 新增 `unregister(pid)`。
|
||||||
|
- 新增接口:`GET /api/plugins`、`POST /api/plugins/{name}/enable|disable`、
|
||||||
|
`POST /api/plugins/reload`、`POST /api/plugins/{name}/reload`;失败返回 `{"ok": false, "error": ...}`。
|
||||||
|
- 前端顶栏新增「插件管理」弹窗(`web/src/components/PluginsDialog.tsx`):列出插件与状态,
|
||||||
|
支持启停 / 单个重载 / 全部重载;启停后会重新拉 `/api/platforms`,「添加账号」的平台下拉同步变化。
|
||||||
|
- 引擎 / 路由 / 平台实现均未改动。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. `GET /api/plugins` 列出 `gdgxk`(enabled / loaded 为 true,capabilities 七项)。
|
||||||
|
2. `POST /api/plugins/gdgxk/disable` → `GET /api/platforms` 里 gdgxk 消失;再 enable 回来。
|
||||||
|
3. 改 `plugins/gdgxk/main.py` 里一句日志文案 → `POST /api/plugins/gdgxk/reload` →
|
||||||
|
不重启服务再跑一次学习,日志应变成新文案。
|
||||||
|
4. 重启服务:停用状态应被记住(设置项 `plugins`)。
|
||||||
|
5. 前端顶栏「插件管理」:停用 gdgxk → 「添加账号」的平台下拉立刻少一项;重新启用后恢复。
|
||||||
|
|
||||||
|
## 平台契约改为子类实现(注册方式调整)
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- `Platform` 从 dataclass「注册时逐项传函数」改成基类:`id` / `label` / `entries` 是类属性,
|
||||||
|
能力是子类方法;没实现的阶段保持基类的 `None`,`capabilities()` 与 `/api/platforms` 不变。
|
||||||
|
- `plugins/gdgxk/main.py`(原 `platforms/gdgxk.py`):`session`(原 `new_session`)/ `login` /
|
||||||
|
`list_courses` / `discover` / `study` 等收进 `class Gdgxk(Platform)`;解析 / 上报类 helper
|
||||||
|
仍是模块级函数。
|
||||||
|
- `platforms/guanyixue.py`:改成 `class Guanyixue(Platform)`,方法转调 `jspx` / `study`。
|
||||||
|
- `plugins/` 改成插件目录:`plugins/<名字>/plugin.json`(只放 `id` / `title` / `name`)+ `main.py`
|
||||||
|
(`Platform` 子类);`plugins/__init__.py::load_plugins()` 扫目录 import,`load_builtin()` 调它。
|
||||||
|
插件里不再声明任何能力绑定。
|
||||||
|
- 引擎 / 路由 / `quiz.py` / 前端 / `platforms/contract.py` 均未改动。
|
||||||
|
- 新增 [docs/plugin.md](plugin.md):插件骨架、槽位契约、新增与迁移流程、常见坑。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. `GET /api/platforms` 仍返回 `gdgxk` 与 `guanyixue`,`capabilities` 与改动前一致
|
||||||
|
(广东公需课:`login` / `session` / `renew` / `profile` / `courses` / `discover` / `study`)。
|
||||||
|
2. 莞易学:刷新课程 → 发现课程 → 开始学习照常(旧版 / 新版都跑一次)。
|
||||||
|
3. 广东公需课:登录 → 刷新课程 → 开始学习照常。
|
||||||
|
|
||||||
|
## 平台流程下沉(契约第二步)
|
||||||
|
|
||||||
|
**状态**:已实现,待实测确认。
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- `SessionExpired` 移到 `platforms/errors.py`,`config.py` 继续导出;平台实现不再反向 import 引擎。
|
||||||
|
- 旧版学习流程(反馈表 / 测验 / 刷课件)从 `engine.py` 搬到 `platforms/jspx.py`;新版 SPA 流程(含真播视频)搬到 `platforms/study.py`。
|
||||||
|
- `engine.py` 只剩账号级编排(课程循环 / 会话续期 / 按平台分发,115 行),不再 import 任何具体平台模块。
|
||||||
|
- `routes/*`、`quiz.py` 去掉对 `platforms.jspx` / `platforms.study` 的 star import;平台没实现 `session` / `dump_cookies` 时明确报错,不再退回莞易学的默认实现(原来会静默按 dgjy 过滤 cookie)。
|
||||||
|
|
||||||
|
### 怎么测
|
||||||
|
|
||||||
|
1. 莞易学:刷新课程 → 发现课程 → 开始学习(快速、常规各跑一次),反馈表 / 测验照常提交。
|
||||||
|
2. 广东公需课:开始学习,确认进度照常上报。
|
||||||
|
3. 错误分支:平台缺少 `session` 时,`/api/courses`、`/api/discover` 应返回「还没有实现 session」。
|
||||||
|
|
||||||
## 添加账号选择平台
|
## 添加账号选择平台
|
||||||
|
|
||||||
**状态**:已实现,待实测确认。
|
**状态**:已实现,待实测确认。
|
||||||
|
|
||||||
### 做了什么
|
### 做了什么
|
||||||
|
|
||||||
- 「添加账号」弹窗最上面新增「学习平台」下拉,选项来自 `GET /api/platforms`(当前只有「莞易学」)。
|
- 「添加账号」弹窗最上面新增「学习平台」下拉,选项来自 `GET /api/platforms`(当前有「莞易学」「广东公需课」)。
|
||||||
- 账号记录新增 `platform` 字段(SQLite 老库启动时自动 `ALTER TABLE` 补列)。
|
- 账号记录新增 `platform` 字段(SQLite 老库启动时自动 `ALTER TABLE` 补列)。
|
||||||
- 登录(`/api/login`)、刷新课程(`/api/courses`)、发现课程(`/api/discover`、`/api/quiz`)、
|
- 登录(`/api/login`)、刷新课程(`/api/courses`)、发现课程(`/api/discover`、`/api/quiz`)、
|
||||||
开始学习(引擎 `run_one_course`)都按账号所选平台取 `Platform` 上的实现;选「莞易学」时
|
开始学习(引擎 `run_one_course`)都按账号所选平台取 `Platform` 上的实现;选「莞易学」时
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Agent 任务看板
|
||||||
|
|
||||||
|
| 路径 | 任务 | Agent | 状态 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| src/study_tool/plugins/gdgxk/main.py、utils/learn.py | 广东公需课:进度页重取任务点状态 + 未完成自动重学 | | 已完成,待实测 |
|
||||||
|
| src/study_tool/plugins/gdgxk/utils/exam.py、main.py | 广东公需课:课程考核解析 + 题库/AI 答题 + 交卷(finishTest2) | | 已完成,待实测 |
|
||||||
|
| web/src/App.tsx、src/study_tool/routes/accounts.py、src/study_tool/plugins/gdgxk/main.py、src/study_tool/database/__init__.py | 修复刷新课程空平台报错 + 旧会话 cookie 回写导致的反复重登 | | 已完成,待实测 |
|
||||||
|
| src/study_tool/platforms/base.py、contract.py、plugins/gdgxk/main.py、plugins/gdgxk/utils/exam.py、plugins/guanyixue/quiz.py、main.py、web/src/components/QuizDialog.tsx | 公需课接入交互式测验:quiz / quiz_submit 槽位 + 按平台分发交卷 | | 已完成,待实测 |
|
||||||
|
| web/src/components/PluginsDialog.tsx、src/study_tool/plugins/__init__.py、src/study_tool/platforms/contract.py | 插件管理卡片:能力名改中文 + 显示适配网站 | | 已完成,待实测 |
|
||||||
|
| src/study_tool/notify.py、plugins/daidai_push/、plugins/__init__.py、config/settings.py、models.py、routes/settings.py、routes/accounts.py、engine.py、plugins/gdgxk/main.py、plugins/guanyixue/utils/spa.py、web/src/components/SettingsDialog.tsx、PluginsDialog.tsx | 故障推送:呆呆面板推送渠道插件 + `notify` 设置项 + 进度异常 / 多次重试 / 登录与运行异常埋点 | | 已完成,待实测 |
|
||||||
|
| web/src/components/SettingsDialog.tsx、web/src/components/ui/Modal.tsx | 系统设置改成左侧分类 + 右侧内容的两栏弹窗(大模型 / 学习模式 / 故障推送) | | 已完成,已截图核对 |
|
||||||
|
| web/src/components/AccountEditDialog.tsx、ui/ConfirmDialog.tsx、ui/Modal.tsx、ui/Button.tsx、AccountCard.tsx、QuizDialog.tsx、App.tsx | 账号卡片加「修改账号信息」弹窗(账号 / 密码 / 备注 / 平台,保存后自动重登)+ 自绘确认弹窗替换 window.confirm | | 已完成,待实测 |
|
||||||
|
| src/study_tool/plugins/gdgxk/main.py、utils/learn.py、web/src/components/ui/LogLines.tsx、AccountCard.tsx、SystemLogDialog.tsx | 卡片标题显示学员姓名(profile 解析个人中心 name)+ 进度上报日志改清楚 + 日志面板改明亮模式并按级别配色 | | 已完成,待实测 |
|
||||||
|
| src/study_tool/app.py、routes/settings.py、notify.py、web/src/components/SettingsDialog.tsx、docs/plugin.md | 发版体检:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名文案统一 | | 已完成,待实测 |
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Agent 工作流 · 经验账本
|
||||||
|
|
||||||
|
> 规则在 `AGENTS.md`;这里只放「还没被证实」或「刚被证实一次」的经验。
|
||||||
|
> 写入前先全文搜重:有同义条目就改旧的,不新增重复条目。
|
||||||
|
> Agent 可以写本文件,但**不能自己改 `AGENTS.md`**——只能提议 + 附证据。
|
||||||
|
|
||||||
|
## 怎么用
|
||||||
|
|
||||||
|
1. 遇到问题,先搜本文件有没有同一条。
|
||||||
|
2. 没有 → 在「观察中」加一条,`Seen:1×`。
|
||||||
|
3. 又遇到 → 同一条 `Seen +1`,把新证据追加进去(不要另开一条)。
|
||||||
|
4. `Seen ≥3` 且用户同意 → 升级进 `AGENTS.md`,本文件那条标 `已升级`;判断为「不该加」的 → 移到「已否决」并写清原因。
|
||||||
|
|
||||||
|
## 条目格式
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### <一句话规则,写成能执行的动作>
|
||||||
|
|
||||||
|
- 为什么:<不这么做的具体代价>
|
||||||
|
- 证据:
|
||||||
|
- `<版本 / commit sha>` <现象,带文件:行 或报错原文>
|
||||||
|
- Seen:N×
|
||||||
|
- 状态:观察中 | 已升级 → AGENTS.md「<章节>」 | 已否决(原因)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 观察中(Seen 1–2)
|
||||||
|
|
||||||
|
### 文档里不写具体的版本号 / 行号示例,它们会过期
|
||||||
|
|
||||||
|
- 为什么:示例一旦过期,Agent 会照着过期的值做事,且没人会主动去改示例。
|
||||||
|
- 证据:
|
||||||
|
- `v0.0.8 (aa48145)`:旧 `AGENTS.md` 写「打 tag,例如 `v0.0.5`」,而当时最新 tag 已是 `v0.0.8`。
|
||||||
|
- Seen:1×
|
||||||
|
- 状态:观察中(新版本改成了 `v0.0.8`,同样会过期;下次改成 `<最新 tag>` 或直接删示例)
|
||||||
|
|
||||||
|
### ruff-format 会重排文档里的 python 代码块
|
||||||
|
|
||||||
|
- 为什么:以为只改 `.py`,结果提交时它把文档里 ```python 代码块的格式也改了,pre-commit 直接拦下这次提交;不知道这点会反复「改了又变」。
|
||||||
|
- 证据:
|
||||||
|
- `06d3206` 提交时:`docs/architecture.md` 的 `Channel` 示例被 `ruff-format` 加了空行,pre-commit 报 `files were modified by this hook` 并中止提交。
|
||||||
|
- Seen:1×
|
||||||
|
- 状态:观察中(文档里的 python 代码块以钩子结果为准,别手改对抗)
|
||||||
|
|
||||||
|
### 发版配置写完要真跑一次 dry-run
|
||||||
|
|
||||||
|
- 为什么:没验证过的发版配置会在发版当天才暴露问题,那时最没有余量排查。
|
||||||
|
- 证据:
|
||||||
|
- `ecadae5` 之前的 `pyproject.toml`:`bump-my-version` 默认 `regex = false`,且仓库文件是 CRLF,原 `search` 真跑报 `Did not find '(?m)^version = "0.0.8"$' in file: 'pyproject.toml'`,这条发版命令从未成功过。
|
||||||
|
- Seen:1×
|
||||||
|
- 状态:观察中(改发版配置后跑 `bump-my-version bump patch --dry-run --allow-dirty -vv`,确认三处都会被改)
|
||||||
|
|
||||||
|
## 已升级 → AGENTS.md
|
||||||
|
|
||||||
|
### 修改已存在文件:一次清单、一次同意
|
||||||
|
|
||||||
|
- 为什么:逐文件询问会让「认领 → 改代码 → 升版本 → 提交」每一步都卡住,长任务直接跑不动;但完全不问又会动到用户的文件。
|
||||||
|
- 证据:
|
||||||
|
- `v0.0.8 (aa48145)`:旧规则「任何文件编辑…必须先询问」与同文件的「认领看板」「提交代码」「升版本号」三条互相冲突,且没写清「一次同意管几次」。
|
||||||
|
- Seen:1×
|
||||||
|
- 状态:已升级 → AGENTS.md「动文件之前(Plan-first)」
|
||||||
|
|
||||||
|
### 不构建 ≠ 不验证
|
||||||
|
|
||||||
|
- 为什么:把「不跑构建」写成「不用检查」,会让 Agent 把没验证的结论当成已验证的结论报上来。
|
||||||
|
- 证据:
|
||||||
|
- `v0.0.8 (aa48145)`:同文件一边写「不需要检查语法、不需要执行构建」,一边写「改完前端必须 `cd web && pnpm build`」;`.pre-commit-config.yaml` 又会在提交时跑 `ruff-check` / `ruff-format` / `web typecheck`。
|
||||||
|
- Seen:1×
|
||||||
|
- 状态:已升级 → AGENTS.md「验证口径」
|
||||||
|
|
||||||
|
### 版本号只在发版提交时提升
|
||||||
|
|
||||||
|
- 为什么:每次提交都 bump 时,bump 同时改 `metadata.yaml` + `pyproject.toml`,并行分支必然冲突;而且这条从来没被执行过。
|
||||||
|
- 证据:
|
||||||
|
- `v0.0.8 (aa48145)`:旧规则「每次提交必须提升版本号」,实际 24 次提交、版本仍是 0.0.8,tag 只有 `v0.0.1` / `v0.0.8`。
|
||||||
|
- Seen:1×
|
||||||
|
- 状态:已升级 → AGENTS.md「发版本流程」
|
||||||
|
|
||||||
|
### 看板按 Agent 认领,完成后改状态、保留行
|
||||||
|
|
||||||
|
- 为什么:规则说「按 Agent 认领」,表里却没有 Agent 列,认领动作无从落地;删行又会把完成记录一起删掉。
|
||||||
|
- 证据:
|
||||||
|
- `v0.0.8 (aa48145)`:`docs/agent_board.md` 表头只有「路径 / 任务 / 状态」,12 行状态全是「已完成,待实测」,没有 Agent 列。
|
||||||
|
- Seen:1×
|
||||||
|
- 状态:已升级 → AGENTS.md「多 Agent 协作」
|
||||||
|
|
||||||
|
### 项目契约和 Agent 行为规则分开
|
||||||
|
|
||||||
|
- 为什么:行为规则和项目契约混在一个文件里,改契约时没人会想到 review 规则文件,两边都不好维护。
|
||||||
|
- 证据:
|
||||||
|
- `v0.0.8 (aa48145)`:`AGENTS.md` 93 行,其中「日志规范」+「代码结构 / 平台插件」约 4KB 是项目契约,而 `docs/architecture.md` 已在维护同一套分层与插件契约。
|
||||||
|
- `d8808e6`:`AGENTS.md`「发版本流程」里写死了本项目的文件名和命令(`metadata.yaml`、`uv tool run bump-my-version …`),用户指出这些该放进 `docs/architecture.md`,`AGENTS.md` 只留通用流程;手写命令还出现过 `runm` / `etadata.yaml` 这类笔误。
|
||||||
|
- Seen:2×
|
||||||
|
- 状态:已升级 → AGENTS.md「项目契约」(两节已并入 `docs/architecture.md`,AGENTS.md 降到 79 行)
|
||||||
|
|
||||||
|
### 一个文件只留一份权威账本
|
||||||
|
|
||||||
|
- 为什么:两份待办并存时,Agent 会读旧的、写新的,两边慢慢分叉。
|
||||||
|
- 证据:
|
||||||
|
- `v0.0.8 (aa48145)`:`docs/todo.md` 仍写 `../src/study_tool/platforms/gdgxk.py`,而平台早已迁到 `src/study_tool/plugins/gdgxk/`。
|
||||||
|
- `v0.0.8` CHANGELOG:文档里的旧目录名 `pugins/` 是另一次同类漂移,靠一次性全量替换才修掉。
|
||||||
|
- Seen:2×
|
||||||
|
- 状态:已升级 → AGENTS.md「文档规范」(`docs/todo.md` 已删除;其中 2 条仍未修的代码问题已迁入 `docs/progress/todo.md`)
|
||||||
|
|
||||||
|
### 发版流程只留一条线,别让手动步骤和工具自动步骤并存
|
||||||
|
|
||||||
|
- 为什么:工具已经自动提交 + 打 tag,文档还写「提交完成后手动打 tag」,照做就会撞同名 tag 失败。
|
||||||
|
- 证据:
|
||||||
|
- `ecadae5`:`pyproject.toml` 里 `commit = true` / `tag = true`,而当时 `AGENTS.md` 发版流程仍有「提交完成后,给当前提交打 tag」一条。
|
||||||
|
- Seen:1×
|
||||||
|
- 状态:已升级 → AGENTS.md「发版本流程」(改为 bump-my-version 一次完成改版本 + 提交 + 打 tag)
|
||||||
|
|
||||||
|
### 附带产物跟着任务走,不单开提交
|
||||||
|
|
||||||
|
- 为什么:为 1 行 `uv.lock` 单开提交会让历史里充满噪音,也让「一个任务一个 commit」失去意义。
|
||||||
|
- 证据:
|
||||||
|
- `4784ac0`:`chore: uv.lock 的 study-tool 版本同步到 0.0.8`,仅 1 行改动,是 v0.0.8 发版的遗留;用户指出「完成一个任务才需要提交」。
|
||||||
|
- Seen:1×
|
||||||
|
- 状态:已升级 → AGENTS.md「收尾(Commit)」
|
||||||
|
|
||||||
|
## 已否决(不要再提议)
|
||||||
|
|
||||||
|
| 规则 | 原因 |
|
||||||
|
| --- | --- |
|
||||||
|
| 每个任务都强制建 git worktree | 单人顺序开发时是纯摩擦:新 worktree 没有 `.env` / `.venv` / `node_modules`,每次要重装;已在 AGENTS.md 限定为「并行开发时」。 |
|
||||||
|
| 六条验收硬拦发布(全量测试 / 多平台 vet / 真用) | 与「用户自己构建验证」冲突,本项目没有对应的测试体系。 |
|
||||||
|
| 每个失败都在同一 commit 开 issue | 用 `docs/progress/todo.md` 等价实现,个人项目不引入 issue 流程。 |
|
||||||
|
| 只合并 reviewed head(`--match-head-commit`) | 没有外部 PR 流程,不适用。 |
|
||||||
|
|
||||||
|
## 维护
|
||||||
|
|
||||||
|
- 「观察中」+「已升级」合计超过 15 条就该清理:合并同义条目、删掉已否决的、把过期的观察降级或删掉。
|
||||||
|
- 每条都必须能落到「证据」;写不出证据的,先别写进来。
|
||||||
|
- 不写会议纪要、不写「注意质量」这类无法执行的表述。
|
||||||
+183
-56
@@ -10,44 +10,78 @@ src/study_tool/
|
|||||||
__main__.py python -m study_tool 入口
|
__main__.py python -m study_tool 入口
|
||||||
app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main()
|
app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main()
|
||||||
webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入)
|
webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入)
|
||||||
config.py 常量 / 环境变量 / 根目录定位(data、static、script)
|
config/ 全局路径常量(ROOT / DATA / STATIC_DIR),不放任何平台常量
|
||||||
|
settings.py 系统设置读写(大模型 + 学习方式)
|
||||||
runtime.py 运行状态 RUNS + 统一日志 log()
|
runtime.py 运行状态 RUNS + 统一日志 log()
|
||||||
store.py SQLite:账号 / 设置 / 日志 / 状态 / 缓存 / 快照
|
database/ SQLite 存储(SQLAlchemy ORM)
|
||||||
models.py HTTP 请求模型(Pydantic)
|
models.py ORM 模型:settings / kv / accounts / login_log / run_log / run_state / snapshots
|
||||||
settings_store.py 系统设置读写
|
__init__.py engine / Session + 读写函数(账号、设置、日志、状态、缓存、快照)
|
||||||
llm.py 大模型调用(OpenAI 兼容)
|
models.py 通用 HTTP 请求模型(Pydantic);测验请求模型在 plugins/guanyixue/quiz.py
|
||||||
quiz.py 测验解析 / 交卷 / AI 答题(含 /api/quiz/*)
|
llm/ 大模型调用(OpenAI 兼容),后续大模型代码都放这里
|
||||||
engine.py 学习引擎:登录→发现→按平台分发→worker / 视频 / 提交
|
bank.py 题库匹配 + 大模型兜底(与平台无关):只决定「这题选什么」,不碰平台页面 / 接口
|
||||||
capture.py 抓包
|
engine.py 账号级编排:课程循环、会话续期、按 platform 分发
|
||||||
platforms/ 平台插件(见下)
|
platforms/ 平台运行时(见下)
|
||||||
base.py Platform 契约(id / label / study / entries)
|
base.py Platform 契约(id / label / 槽位 / capabilities)
|
||||||
|
contract.py 生命周期 ↔ 槽位 ↔ 能力名映射
|
||||||
|
errors.py SessionExpired(平台层错误类型)
|
||||||
registry.py 注册表:register / get / detect / all
|
registry.py 注册表:register / get / detect / all
|
||||||
|
plugins/ 插件式平台(加载器扫目录,新增插件不改代码)
|
||||||
|
__init__.py load_plugins / discover / set_enabled / reload(启停 + 热重载)
|
||||||
|
gdgxk/
|
||||||
|
plugin.json 注册信息:id / title / name(只放元数据)
|
||||||
|
main.py 广东公需课:Platform 子类(登录 / 课程 / 学习 / 续期)
|
||||||
|
utils/http.py 域名 / UA / XHR 上报封装与 SessionExpired
|
||||||
|
utils/login.py base64 账号密码 + 图形验证码 + ITLMS 换票
|
||||||
|
utils/course.py 课程列表卡片 / 学习页上报参数 / 进度页活动清单
|
||||||
|
utils/learn.py 活动页打开 → 注册播放 → 1x 心跳上报
|
||||||
|
utils/exam.py 课程考核:解析内嵌题面 → 题库/AI 答题 → POST finishTest2 交卷
|
||||||
|
utils/bank.py 题库入口:读 bank/*.json,调核心 bank.answer(题库优先、大模型兜底)
|
||||||
|
bank/ 本年题库数据(*.json,格式 [{"q": 题干, "a": "B"}]),换年份只加文件
|
||||||
|
script/ 插件专属脚本:capture-jsglpt.py(抓包取证)、test-gdgxk.py(接口自测)
|
||||||
|
guanyixue/
|
||||||
|
plugin.json 注册信息:id / title / name
|
||||||
|
main.py 莞易学:Platform 子类,按 generation 分发
|
||||||
|
config.py 站点地址 / 登录授权入口 / 课程板块 / Node 与 desenc.js 定位 / 快照兜底目录
|
||||||
|
utils/http.py 会话 / cookie / 身份 / 新版平台 token
|
||||||
|
utils/login.py 账号密码登录 / CASTGC 续期 / token 换取
|
||||||
|
utils/course.py 旧版课程列表 / 结构解析 / 页面代际分发
|
||||||
|
utils/feedback.py 旧版反馈表与测验提交
|
||||||
|
utils/learn.py 旧版学习流程:反馈表 + 测验 + 课件心跳
|
||||||
|
utils/spa.py 新版 SPA:StudyClient / 接口学习 / 视频流程
|
||||||
detect.py 页面代际识别(legacy 旧版 / spa 新版)
|
detect.py 页面代际识别(legacy 旧版 / spa 新版)
|
||||||
guanyixue.py 莞易学平台:合并 jspx 登录入口与 study 学习入口
|
script/ 插件专属脚本:des.js、desenc.js(Node 调用)、study_video.py(命令行刷视频)
|
||||||
jspx.py 莞易学旧版页面实现(会话 / 登录 / 课程抓取 / 旧版学习)
|
capture.py 抓包调试:把页面 XHR/fetch 落成 data/capture-*.json
|
||||||
study.py 莞易学新版 SPA 实现(StudyClient / 接口学习)
|
quiz.py 测验解析 / 交卷 / AI 答题(含 /api/quiz/*、测验请求模型、答题提示词、题型名)
|
||||||
routes/ HTTP 接口
|
routes/ HTTP 接口(load_routes() 启动时扫描本目录,新增文件自动注册)
|
||||||
accounts.py 账号 / 登录 / 登录记录
|
accounts.py 账号 / 登录 / 登录记录
|
||||||
misc.py 课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 平台列表
|
misc.py 课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 平台列表
|
||||||
logs.py /api/log、/api/runs
|
logs.py /api/log、/api/runs
|
||||||
|
settings.py /api/settings
|
||||||
|
llm.py /api/llm/test
|
||||||
web/ 前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4)
|
web/ 前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4)
|
||||||
static/ 前端构建产物(index.html + assets/ + favicon.svg,提交进仓库)
|
static/ 前端构建产物(index.html + assets/ + favicon.svg,提交进仓库)
|
||||||
script/ 服务端 JS 脚本:des.js、desenc.js(Node 调用)
|
|
||||||
script/reference/ 不参与运行的参考脚本:dgjy-toolkit.user.js、study-axios.mjs
|
script/reference/ 不参与运行的参考脚本:dgjy-toolkit.user.js、study-axios.mjs
|
||||||
|
(插件专属脚本跟着插件走:plugins/<名字>/script/,如莞易学的 des.js、desenc.js、study_video.py)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 分层
|
## 分层
|
||||||
|
|
||||||
- **入口层**:`app.py`(根)+ `study_tool/app.py`。前者只负责 `sys.path` 与启动,后者组装应用。
|
- **入口层**:`app.py`(根)+ `study_tool/app.py`。前者只负责 `sys.path` 与启动,后者组装应用。
|
||||||
- **接口层**:`routes/*` 与 `quiz.py` 里的 `/api/quiz/*`,只做参数校验、调用引擎、返回结果。
|
- **接口层**:`routes/*` 与 `plugins/guanyixue/quiz.py` 里的 `/api/quiz/*`,只做参数校验、调用引擎、返回结果。
|
||||||
- **领域层**:`engine.py`(学习流程编排)、`platforms/*`(平台实现)、`llm.py` / `quiz.py` 的解析逻辑。
|
- **领域层**:`engine.py`(账号级编排:课程循环 / 会话续期 / 按平台分发)、`platforms/*`(平台运行时)、`plugins/*`(平台实现,含各自的学习流程)、`notify.py`(故障上报 / 推送渠道注册)、`llm/` 与 `plugins/guanyixue/quiz.py` 的解析逻辑。
|
||||||
- **基础设施**:`store.py`(SQLite)、`runtime.py`(状态 + 日志)、`config.py`。
|
- **基础设施**:`database/`(SQLAlchemy ORM + SQLite)、`runtime.py`(状态 + 日志)、`config/`(常量 + 设置)。
|
||||||
|
|
||||||
依赖方向单向:接口 → 领域 → 基础设施。基础设施不反向 import 接口。
|
依赖方向单向:接口 → 领域 → 基础设施。基础设施不反向 import 接口。
|
||||||
|
|
||||||
|
新代码的 import 一律显式(如 `from study_tool.config import JSPX`),不要再新增 `from xxx import *`;历史遗留的 star import 只保留在拆分前就存在的模块里。
|
||||||
|
|
||||||
|
`engine.py` 与 `routes/*` 只用 `registry.get(...)` 取平台实现,不 import 具体平台模块;
|
||||||
|
平台实现之间只有同平台不同代际会互相 import(`course.py` → `spa.py`),依赖保持单向,避免成环。
|
||||||
|
|
||||||
## 平台插件与识别
|
## 平台插件与识别
|
||||||
|
|
||||||
平台实现在 `platforms/`,通过注册表注入,`engine` 只按 `course["platform"]` 分发:
|
平台实现在 `platforms/` 或插件目录 `plugins/<名字>/`,都通过注册表注入,`engine` 只按
|
||||||
|
`course["platform"]` 分发:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# engine.run_one_course 末尾
|
# engine.run_one_course 末尾
|
||||||
@@ -55,23 +89,24 @@ pf = registry.get(d.get("platform"))
|
|||||||
pf.study(key, s, d, workers=..., cap=..., enable_quiz=..., ...)
|
pf.study(key, s, d, workers=..., cap=..., enable_quiz=..., ...)
|
||||||
```
|
```
|
||||||
|
|
||||||
`Platform` 契约(见 `platforms/base.py`):
|
`Platform` 契约(见 `platforms/base.py`):一个平台 = 一个 `Platform` 子类,类属性放元数据,
|
||||||
|
自己确实有的阶段实现成方法;注册时只实例化子类,不再逐项绑定函数。
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@dataclass
|
|
||||||
class Platform:
|
class Platform:
|
||||||
id: str # course["platform"] 的取值
|
id = "" # course["platform"] 的取值
|
||||||
label: str # 日志 / 界面展示名,如「莞易学」
|
label = "" # 日志 / 界面展示名,如「莞易学」
|
||||||
study: Callable # (key, s, d, **opts) -> None
|
entries = () # 登录入口 / 学习入口的 URL 片段
|
||||||
entries: tuple[str, ...] # 登录入口 / 学习入口的 URL 片段
|
# 槽位:子类实现哪个阶段就覆盖成方法(第一个参数是 self);保持 None = 该平台没有这项能力
|
||||||
# 账号级能力:添加账号时选中的平台决定登录 / 刷新课程 / 发现课程走哪套实现
|
study = None # study(self, key, s, d, **opts) -> None
|
||||||
session: Callable # (cookies) -> requests.Session
|
session = None # session(self, cookies) -> requests.Session
|
||||||
dump_cookies: Callable # (session) -> [cookie]
|
dump_cookies = None # dump_cookies(self, session) -> [cookie]
|
||||||
login: Callable # (username, password) -> (session, user)
|
login = None # login(self, username, password) -> (session, user)
|
||||||
list_courses: Callable # (session) -> [course]
|
list_courses = None # list_courses(self, session) -> [course]
|
||||||
discover: Callable # (session, plan_id, project_id, name) -> 课程描述
|
discover = None # discover(self, session, plan_id, project_id, name) -> 课程描述
|
||||||
renew_session: Callable # (session) -> bool,无密码续期
|
renew_session = None # renew_session(self, session) -> bool,无密码续期
|
||||||
profile: Callable # (session) -> 显示名
|
profile = None # profile(self, session) -> 显示名
|
||||||
|
# capabilities() 按非空槽位推导能力名
|
||||||
```
|
```
|
||||||
|
|
||||||
`registry.detect(url, html)` 按 `entries` 命中平台;「添加账号」时选择的平台写进
|
`registry.detect(url, html)` 按 `entries` 命中平台;「添加账号」时选择的平台写进
|
||||||
@@ -82,46 +117,123 @@ class Platform:
|
|||||||
`opts` 固定包含:`workers / cap / enable_quiz / enable_feedback / mode / parallel /
|
`opts` 固定包含:`workers / cap / enable_quiz / enable_feedback / mode / parallel /
|
||||||
quiz_key / cookies / plan / project / name`。插件可以只取自己需要的。
|
quiz_key / cookies / plan / project / name`。插件可以只取自己需要的。
|
||||||
|
|
||||||
|
### 生命周期与能力
|
||||||
|
|
||||||
|
阶段与槽位对应,缺的阶段不实现即可(不要抛 `NotImplementedError`):
|
||||||
|
|
||||||
|
- 登录 / 会话:`login`、`session`、`renew_session`、`profile`
|
||||||
|
- 查询课程:`list_courses`
|
||||||
|
- 课程详情:`discover`
|
||||||
|
- 学习:`study`
|
||||||
|
- 测验:`quiz(self, s, d, b)` 取题、`quiz_submit(self, s, b)` 按用户答案交卷;能力名 `quiz`(两者都实现才显示)。`/api/quiz*` 接口由莞易学插件注册、内部按 `registry.get(platform)` 分发,前端只在 `capabilities` 含 `quiz` 时显示「打开测验」
|
||||||
|
- 抓包调试:`capture(self, key, cookies, minutes)` + `captures(key)` / `capture_reset(key)`;能力名 `capture`,前端据此显示「抓包」
|
||||||
|
|
||||||
|
### 插件可删除
|
||||||
|
|
||||||
|
整个 `plugins/<名字>/` 目录删掉后,系统照常启动(目录全空也行),只是少一个平台:
|
||||||
|
|
||||||
|
- 核心(`engine` / `routes` / `database` / 前端)不 import 任何具体插件,不写死平台 id;账号的 `platform` 默认空串,前端平台下拉、「打开测验」「抓包」都按 `GET /api/platforms` 返回的 `capabilities` 动态显示。
|
||||||
|
- 插件在 import 时用 `@app.post(...)` 注册的接口,由加载器(`plugins/__init__.py`)记账:停用 / 重载 / 加载失败时一并摘除,不会残留也不会重复注册。
|
||||||
|
- 插件专属的常量、脚本、请求模型、依赖都放在插件目录里(`config.py`、`script/`、`quiz.py`),不放核心目录。
|
||||||
|
- 已有账号选的平台被删后,该账号登录 / 刷新课程会返回 `no_platform`,不影响其它账号。
|
||||||
|
|
||||||
|
`Platform.capabilities()` 按非空槽位推导能力名(映射见 `platforms/contract.py`):
|
||||||
|
`login` / `session` / `renew` / `profile` / `courses` / `discover` / `study`。
|
||||||
|
`GET /api/platforms` 一并返回 `capabilities`,前端据此决定显示哪些按钮。
|
||||||
|
流程顺序只有一个实现(引擎),插件只负责单步。
|
||||||
|
|
||||||
### 内置:莞易学
|
### 内置:莞易学
|
||||||
|
|
||||||
`jspx.dgjy.net`(登录入口)和 `study.dgjy.net`(学习入口)是同一个平台,统一注册为
|
`jspx.dgjy.net`(登录入口)和 `study.dgjy.net`(学习入口)是同一个平台,统一注册为
|
||||||
`id=guanyixue`、`label=莞易学`。两代课程页:
|
`id=guanyixue`、`label=莞易学`,实现在 `plugins/guanyixue/`。两代课程页:
|
||||||
|
|
||||||
- `platforms/jspx.py`:旧版页面(`tier-tree-one` / `tier3`),负责会话、登录、课程抓取,
|
- `utils/http.py` / `utils/login.py`:会话、cookie、账号密码登录与 CASTGC 续期。
|
||||||
学习流程在 `engine._study_builtin`。
|
- `utils/course.py`:旧版课程页(`tier-tree-one` / `tier3`)的课程抓取与代际分发。
|
||||||
- `platforms/study.py`:新版 Vue SPA,`StudyClient` + `/train/course/front/*` 接口,
|
- `utils/feedback.py` / `utils/learn.py`:旧版反馈表、测验与刷课件(`_study_builtin`)。
|
||||||
学习流程在 `engine.run_study_course`。
|
- `utils/spa.py`:新版 Vue SPA,`StudyClient` + `/train/course/front/*` 接口,
|
||||||
- `platforms/guanyixue.py`:注册平台并按 `course["generation"]` 分发;
|
以及新版学习流程(`run_study_course`:接口上报 / 真播视频)。
|
||||||
`platforms/detect.py` 只判断页面代际,不判断平台归属。
|
- `main.py`:注册平台并按 `course["generation"]` 分发;`detect.py` 只判断页面代际,
|
||||||
|
不判断平台归属。
|
||||||
|
|
||||||
|
### 内置:广东公需课
|
||||||
|
|
||||||
|
`id=gdgxk`,`plugins/gdgxk/` 实现(插件目录,`plugin.json` 只放 id / title / name,`main.py`
|
||||||
|
只放 `Platform` 子类,登录 / 解析 / 心跳工具在 `utils/`):
|
||||||
|
在 `jsglpt.gds.edu.cn` 登录(账号、密码
|
||||||
|
都是 base64),再用 `ssoItlmsGxkTicket.action` 换票进 `jsxx.gds.edu.cn`;视频按真实
|
||||||
|
流逝时间上报 `updateViewTime1`。没有测验 / 反馈表,所以不实现对应能力。
|
||||||
|
|
||||||
### 新增一个平台
|
### 新增一个平台
|
||||||
|
|
||||||
1. 新建 `src/study_tool/platforms/<名字>.py`:
|
1. 新建 `../src/study_tool/plugins`,写 `plugin.json`(`id` / `title` / `name`)和 `main.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from study_tool.platforms import registry
|
|
||||||
from study_tool.platforms.base import Platform
|
|
||||||
|
|
||||||
def _study(key, s, d, **opts):
|
from study_tool.platforms import registry
|
||||||
|
from study_tool.platforms import Platform
|
||||||
|
|
||||||
|
|
||||||
|
class MyPlatform(Platform):
|
||||||
|
id = "<平台 id>"
|
||||||
|
label = "展示名"
|
||||||
|
entries = ("<登录入口域名>", "<学习入口域名>")
|
||||||
|
|
||||||
|
def session(self, cookies=None): ...
|
||||||
|
def dump_cookies(self, s): ...
|
||||||
|
def login(self, username, password): ...
|
||||||
|
def list_courses(self, s): ...
|
||||||
|
def discover(self, s, plan_id, project_id, name=""): ...
|
||||||
|
def profile(self, s): ...
|
||||||
|
|
||||||
|
def study(self, key, s, d, **opts):
|
||||||
# 用 s(requests.Session,带该账号 cookie)把课程活动跑完
|
# 用 s(requests.Session,带该账号 cookie)把课程活动跑完
|
||||||
# 日志一律 runtime.log(key, ...)
|
# 日志一律 runtime.log(key, ...)
|
||||||
...
|
...
|
||||||
|
|
||||||
PLATFORM = registry.register(Platform(
|
|
||||||
id="<平台 id>", label="展示名", study=_study,
|
PLATFORM = registry.register(MyPlatform())
|
||||||
entries=("<登录入口域名>", "<学习入口域名>"),
|
|
||||||
session=<new_session>, dump_cookies=<dump_cookies>, login=<login>,
|
|
||||||
list_courses=<list_courses>, discover=<discover>,
|
|
||||||
))
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 `from study_tool.platforms import <名字>`。
|
平台确实没有的阶段不要写,保持基类的 `None`,引擎按能力判断走不走那个分支。
|
||||||
|
|
||||||
|
2. 不用改任何代码:`load_plugins()` 扫目录时会自动 import 这个插件。实现复杂时可以像
|
||||||
|
`plugins/guanyixue/` 那样把工具按阶段拆到 `utils/`(http / login / course / learn / spa)。
|
||||||
|
|
||||||
3. 平台注册后会自动出现在「添加账号」的平台下拉框;账号选了该平台,登录 / 刷新课程 /
|
3. 平台注册后会自动出现在「添加账号」的平台下拉框;账号选了该平台,登录 / 刷新课程 /
|
||||||
发现课程 / 学习都走它的实现,不需要改路由或前端。
|
发现课程 / 学习都走它的实现,不需要改路由或前端。
|
||||||
|
|
||||||
现阶段登录与课程发现由莞易学模块承担(SSO 只有一套);`accounts.platform` 已经是平台分发的
|
已内置莞易学(`guanyixue`)和广东公需课(`gdgxk`),都在 `plugins/` 下;`accounts.platform` 是
|
||||||
依据,接入第二个平台时在 `platforms/<平台>.py` 里实现上面的入口即可。
|
平台分发依据,接入新平台只需建一个插件目录,引擎与路由不用改。
|
||||||
|
|
||||||
|
## 故障推送
|
||||||
|
|
||||||
|
`notify.py` 是通知中心:核心代码只调 `notify.fault(kind, key, title, content)` /
|
||||||
|
`notify.retry(key, what)`,不 import 任何具体推送服务;真正发请求的渠道在插件里。
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Channel: # notify.Channel
|
||||||
|
id = ""
|
||||||
|
label = ""
|
||||||
|
|
||||||
|
def send(self, title, content, context=None): ... # 失败抛异常
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFY = notify.register(DaidaiPush()) # 插件 main.py 末尾注册
|
||||||
|
```
|
||||||
|
|
||||||
|
- 插件 `plugin.json` 写 `"kind": "notify"`,加载器就从 `notify` 注册表校验(默认
|
||||||
|
`kind=platform` 走 `registry`);`/api/plugins` 会多返回 `kind`,能力显示为「推送」。
|
||||||
|
- 故障类型:`run`(整体运行异常)、`course`(单门课程异常)、`login`(登录失败 /
|
||||||
|
会话无法续期)、`progress`(学完仍未完成 / 进度没推进 / 没有可识别活动)、
|
||||||
|
`retry`(同一原因反复重试)。每种类型在设置项 `notify` 里有独立开关。
|
||||||
|
- 触发点在引擎与路由:课程循环前后对比进度快照(`engine._snap` + `notify.check_progress`)、
|
||||||
|
课程异常 / 运行异常、`SessionExpired` 续期与重登、`/api/login` 失败;插件内部的重试
|
||||||
|
(公需课重登 / 自动重学、莞易学单课时重报)调 `notify.retry` 计数。
|
||||||
|
- 节流:同一 `(类型, 账号)` 在 `cooldown_min` 分钟内只推一次;`retry` 只在计数刚达到
|
||||||
|
`retry_threshold` 时推一次;每轮学习开始 `notify.reset(key)` 清零计数。
|
||||||
|
- 配置在系统设置 `notify`:`base_url` / `app_key` / `app_secret` / `channel_name` /
|
||||||
|
各类型开关 / `retry_threshold` / `cooldown_min`;`POST /api/notify/test` 走同一个
|
||||||
|
`notify.send()` 发测试推送。内置渠道插件 `plugins/daidai_push/`(呆呆面板)。
|
||||||
|
|
||||||
## 前端
|
## 前端
|
||||||
|
|
||||||
@@ -137,6 +249,21 @@ quiz_key / cookies / plan / project / name`。插件可以只取自己需要的
|
|||||||
|
|
||||||
## 日志
|
## 日志
|
||||||
|
|
||||||
统一入口 `runtime.log(key, msg, level="info")`,一次写三处:控制台、内存
|
所有日志只走 `runtime.log(key, msg, level="info")`:它统一写控制台、内存(`/api/log`)和 SQLite(`/api/log/history`),三处内容必须一致。
|
||||||
(`/api/log` 给前端轮询)、SQLite(`/api/log/history`)。key 用账号 id 或 `system`。
|
|
||||||
规范见 `AGENTS.md` 的「日志规范」,不要自己 `print()`。
|
- 禁止在新代码里自己 `print()` 写业务日志,也不要直接调 `store.run_log_add()`;模块只负责组织 `msg`。
|
||||||
|
- key 约定:账号相关用账号 id(页面卡片「课程日志」直接显示);全局 / 系统事件用 `"system"`(右上角「系统日志」弹窗显示)。
|
||||||
|
- 时间戳、级别、格式都由 `runtime.log` 统一拼(`时间 [级别] 内容`),模块里不要自己加时间前缀。
|
||||||
|
- 前端读日志统一用 `/api/log?key=&since=`(实时)+ `/api/log/history?key=&tail=`(历史兜底),不要另起一套日志接口。
|
||||||
|
|
||||||
|
## 发版
|
||||||
|
|
||||||
|
通用流程见 `AGENTS.md`「发版本流程」;本项目的具体做法:
|
||||||
|
|
||||||
|
- 版本号三处:`metadata.yaml`、`pyproject.toml`、`uv.lock`(其中 `study-tool` 那一段),由 `pyproject.toml` 的 `[tool.bumpversion]` 统一改,不要手改。
|
||||||
|
- 发版前先 `uv lock` 自检;它若改动了 `uv.lock`,说明某个任务漏提交了锁文件,补进那次任务。
|
||||||
|
- 命令:`uv tool run bump-my-version bump patch`。工作区需干净;它一次完成改三处版本、提交 `chore(release): vX.Y.Z`、打 tag `vX.Y.Z`,不要再手动 commit 或 `git tag`。
|
||||||
|
- 发完用 `git log -1` 和 `git tag -l` 确认 tag 存在、HEAD 正确;同名 tag 已存在时工具会失败,不会覆盖。
|
||||||
|
- 前端有改动时,`pnpm build` 并提交 `static/` 要在发版前的普通提交里完成,发版步骤本身不构建。
|
||||||
|
- 改了 `[tool.bumpversion]` 配置后,先跑 `uv tool run bump-my-version bump patch --dry-run --allow-dirty -vv`,确认三个文件都会被改。
|
||||||
|
- 仓库文件是 CRLF,且 bump-my-version 默认按字面量搜索:用正则的条目必须写 `regex = true`,并且不要用 `$` 锚定行尾(`uv.lock` 那条用 `\r?\n` 兼容换行)。
|
||||||
|
|||||||
+665
@@ -0,0 +1,665 @@
|
|||||||
|
# 学习平台插件开发指南
|
||||||
|
|
||||||
|
欢迎来到学习平台插件开发指南!本章节将会引导你如何开发学习平台的插件,在这之前你需要具备以下基础知识:
|
||||||
|
|
||||||
|
- 1. 一定的 Python 开发编程经验
|
||||||
|
- 2. 一定的 Git / GitHub 使用经验
|
||||||
|
- 3. 会用浏览器开发者工具看请求、Cookie 和页面结构
|
||||||
|
|
||||||
|
> **文档定位**:本文只讲「怎么接入一个平台」——目录怎么放、类怎么写、每个槽位返回什么、怎么联调。
|
||||||
|
> 分层与整体结构见 [architecture.md](architecture.md),仓库级开发约束见 [AGENTS.md](../AGENTS.md)。
|
||||||
|
>
|
||||||
|
> **一句话原则**:插件只回答「这一步在这个站点怎么做」,流程编排只有一份,在引擎里。
|
||||||
|
> 新增平台不需要改 `engine.py`、`routes/`、前端,也不需要改 `load_builtin()`。
|
||||||
|
|
||||||
|
## 环境准备
|
||||||
|
|
||||||
|
### 1. Python 与依赖
|
||||||
|
|
||||||
|
- Python **3.10 ~ 3.14**。`ddddocr` 依赖 `onnxruntime`,后者只对这些版本的正式版发轮子;
|
||||||
|
3.15 / free-threaded / ARM 上会装不上。缺 `ddddocr` 只影响需要图形验证码识别的平台。
|
||||||
|
- 推荐用 uv(依赖版本已锁在 `uv.lock`):
|
||||||
|
|
||||||
|
```bat
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
- 没有 uv 就用 pip:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖清单见 [requirements.txt](../requirements.txt),其中 `requests`、`beautifulsoup4`、`lxml`、`playwright`
|
||||||
|
是插件开发最常用的几个;标准协议、解析、编码一律优先用它们,不要手写底层实现。
|
||||||
|
|
||||||
|
### 2. 启动服务
|
||||||
|
|
||||||
|
在仓库根目录执行:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
uv run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
或者:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听 `127.0.0.1:8848`,浏览器打开 <http://127.0.0.1:8848>。
|
||||||
|
换端口 / 对外开放用环境变量,或把 `.env.example` 复制成根目录 `.env` 再改:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
set PORT=8849 && python app.py
|
||||||
|
set HOST=0.0.0.0 && python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`AUTH_TOKEN` 非空时页面和接口都要带 `?token=<值>`(浏览器会记住 cookie);本机开发一般不用设。
|
||||||
|
|
||||||
|
启动日志里出现插件加载失败时,先看控制台 / 右上角「系统日志」,再按
|
||||||
|
[加载与启停](#加载与启停) 一节排查。
|
||||||
|
|
||||||
|
### 3. 只在改前端时才需要
|
||||||
|
|
||||||
|
前端源码在 `web/`(React + TypeScript + Vite + Tailwind),构建产物输出到 `static/` 并提交进仓库。
|
||||||
|
**插件开发通常不需要碰前端**;确实要改界面时,改完必须执行:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
cd web
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
不要直接改 `static/` 里的产物。
|
||||||
|
|
||||||
|
### 4. 联调工具
|
||||||
|
|
||||||
|
| 工具 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| 浏览器 DevTools → Network | 看登录 / 列表 / 上报接口的 URL、请求体、返回体 |
|
||||||
|
| 浏览器 DevTools → Application → Cookies | 确认域名、`Path`、`HttpOnly`、会话 cookie 名 |
|
||||||
|
| 内置抓包(顶栏「抓包」) | 把真实页面 HTML 存成快照,用 `/api/snapshot/{sid}` 回看,适合分析服务端渲染页面 |
|
||||||
|
| `data/study.db`(SQLite) | 只读排查账号 / 设置 / 日志;不要手改表结构 |
|
||||||
|
| `plugins/<名字>/script/` | 该插件专属的探测 / 自测脚本,随插件一起删;通用的参考实现放根目录 `script/reference/` |
|
||||||
|
|
||||||
|
## 插件是什么
|
||||||
|
|
||||||
|
一个插件 = `../src/study_tool/plugins` 目录;一个平台 = 一个 `Platform` 子类 + 一次注册。
|
||||||
|
|
||||||
|
- **元数据**用类属性声明:`id`、`label`、`entries`。
|
||||||
|
- **能力**用「实现同名方法」绑定:实现了 `study` 就有学习能力,没实现就保持基类的 `None`。
|
||||||
|
- **注册**在模块末尾执行一次:`PLATFORM = registry.register(X())`。
|
||||||
|
|
||||||
|
### 一次学习的调用链
|
||||||
|
|
||||||
|
```
|
||||||
|
添加账号(GET /api/platforms 取平台下拉)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/login ──────────► Platform.login(username, password)
|
||||||
|
│ └─► Platform.list_courses(s)(登录后顺手缓存课程)
|
||||||
|
▼
|
||||||
|
POST /api/courses ────────► Platform.session(cookies) → Platform.list_courses(s)
|
||||||
|
▼
|
||||||
|
POST /api/discover ───────► Platform.session(cookies) → Platform.discover(s, plan_id, project_id, name)
|
||||||
|
▼
|
||||||
|
POST /api/study ──────────► engine.study_worker
|
||||||
|
└─► Platform.session(cookies)
|
||||||
|
Platform.discover(...) # 重新拿最新进度
|
||||||
|
Platform.dump_cookies(s) # 回写会话
|
||||||
|
Platform.study(key, s, d, **opts)
|
||||||
|
▼
|
||||||
|
POST /api/stop ───────────► 置 RUNS[key]["status"] = "stopped",插件在循环里自行退出
|
||||||
|
```
|
||||||
|
|
||||||
|
### 三条设计原则
|
||||||
|
|
||||||
|
1. **流程在引擎,站点细节在插件**:课程循环、会话续期、停止、日志格式都由引擎 / `runtime` 统一处理,
|
||||||
|
插件只写「这个站点怎么登录、怎么拿列表、怎么上报进度」。
|
||||||
|
2. **缺能力就不写**:平台没有反馈表就不要写反馈逻辑,没有密码登录就不要写 `login`;
|
||||||
|
前端按 `capabilities()` 决定显示哪些按钮。
|
||||||
|
3. **不反向依赖**:插件不能 import `engine` / `routes` / 前端;插件之间也不要互相 import。
|
||||||
|
|
||||||
|
## 插件项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/study_tool/plugins/
|
||||||
|
__init__.py 加载器:load_plugins / discover / set_enabled / reload
|
||||||
|
<名字>/
|
||||||
|
__init__.py 包说明(必需,写一句本插件的职责即可)
|
||||||
|
plugin.json 注册信息:id / title / name(只放元数据,不写能力绑定)
|
||||||
|
main.py class <名字>(Platform):槽位实现 + 末尾 registry.register
|
||||||
|
utils/ 可选:按阶段拆的工具(http / login / course / learn / feedback / spa ...)
|
||||||
|
<实现模块>.py 可选:页面代际、复杂流程等,按需再拆
|
||||||
|
```
|
||||||
|
|
||||||
|
- **目录名就是插件名**:`GET /api/plugins` 的 `name`、启停 / 重载接口的 `{name}` 都用它。
|
||||||
|
- `main.py` 放平台类与注册;实现较复杂时按阶段拆到 `utils/` 或同目录模块
|
||||||
|
(登录、课程解析、学习上报各一块),保持单个文件可读。
|
||||||
|
- `../src/study_tool/platforms/` 只放运行时(`base` / `contract` / `errors` / `registry`),
|
||||||
|
**不要**把具体平台放回去;也不要为某个插件改 `platforms/`。
|
||||||
|
- 插件目录内互相引用用相对导入(`from .utils import course`);跨插件不要引用。
|
||||||
|
- 目录内可以按需增加 `models/` 等子目录;没有实际代码的空目录不会参与加载。
|
||||||
|
|
||||||
|
## 插件元数据:`plugin.json`
|
||||||
|
|
||||||
|
`plugin.json` **只是注册信息**,不是配置 Schema,也不参与能力绑定。加载器只读 `id`、`title` 和 `kind`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "gdgxk",
|
||||||
|
"title": "广东省教师继续教育管理平台",
|
||||||
|
"name": "gdgxk"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | 是 | 平台 id,必须与 `Platform.id` 完全一致;会写进 `accounts.platform` / `course["platform"]`,**上线后不要改** |
|
||||||
|
| `title` | 是 | 插件名称,显示在「插件管理」弹窗里;建议写平台全称 |
|
||||||
|
| `name` | 否 | 人类可读的插件名;当前加载器不读取,实际以**目录名**为准,建议与目录名保持一致 |
|
||||||
|
| `kind` | 否 | 插件类型:缺省 `platform`(学习平台,注册到 `registry`);`notify` = 推送渠道(注册到 `notify`,见「推送渠道插件」) |
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- `id` 用稳定的英文小写标识(如 `guanyixue`、`gdgxk`),不要用中文、空格或会变动的版本号。
|
||||||
|
- 目录名、`plugin.json.name`、`Platform.id` 三者保持一致,最省心。
|
||||||
|
- 旧的 `description` / `link` 字段目前**不会被读取**:写了不报错,但插件清单、账号下拉里都不会显示。
|
||||||
|
需要描述就写在 `title` 或目录的 `README` / `__init__.py` 文档字符串里。
|
||||||
|
- 插件需要持久化自己的配置时,用 `database.get_setting(name)` / `database.set_setting(name, value)`,
|
||||||
|
键名加插件前缀(如 `"gdgxk.something"`),不要改系统设置 `llm`,也不要新建表。
|
||||||
|
- 缺少 `plugin.json` 的目录不会被扫描到;`id` 与类不一致时加载失败,日志里会出现
|
||||||
|
`插件 <目录名> 加载失败:插件 <目录名> 没有注册 id='...'`。
|
||||||
|
|
||||||
|
## 插件骨架
|
||||||
|
|
||||||
|
### `__init__.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""<平台名>插件:实现在 main.py,注册信息在 plugin.json。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### `main.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""<平台名>:登录 → 课程 → 发现 → 学习。"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from study_tool import runtime
|
||||||
|
from study_tool.platforms import registry
|
||||||
|
from study_tool.platforms import Platform
|
||||||
|
from study_tool.platforms.errors import SessionExpired
|
||||||
|
|
||||||
|
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..."
|
||||||
|
DOMAINS = ("<平台域名>",) # cookie 域名过滤;和 entries 用同一组域名
|
||||||
|
|
||||||
|
|
||||||
|
class MyPlatform(Platform):
|
||||||
|
id = "<平台 id>" # 必须与 plugin.json 的 id 一致
|
||||||
|
label = "展示名" # 添加账号下拉、日志、插件管理里显示
|
||||||
|
entries = ("<登录入口域名>", "<学习入口域名>") # 识别页面属于本平台
|
||||||
|
|
||||||
|
# ---- 会话 ----------------------------------------------------------
|
||||||
|
def session(self, cookies=None):
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers.update({"User-Agent": UA})
|
||||||
|
for c in cookies or []:
|
||||||
|
domain = c.get("domain") or ""
|
||||||
|
if not any(d in domain for d in DOMAINS):
|
||||||
|
continue
|
||||||
|
s.cookies.set(c["name"], c.get("value") or "", domain=domain.lstrip("."), path=c.get("path") or "/")
|
||||||
|
return s
|
||||||
|
|
||||||
|
def dump_cookies(self, s):
|
||||||
|
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path} for c in s.cookies]
|
||||||
|
|
||||||
|
# ---- 登录 / 账号级 --------------------------------------------------
|
||||||
|
def login(self, username, password):
|
||||||
|
s = self.session()
|
||||||
|
# 登录失败:raise RuntimeError("账号或密码错误")
|
||||||
|
# 会话失效:raise SessionExpired("...")
|
||||||
|
return s, (self.profile(s) if self.profile else "")
|
||||||
|
|
||||||
|
def list_courses(self, s):
|
||||||
|
# -> [{"name": ..., "plan_id": ..., "project_id": ..., "in_study": True}]
|
||||||
|
...
|
||||||
|
|
||||||
|
def profile(self, s):
|
||||||
|
return "<显示名>"
|
||||||
|
|
||||||
|
def renew_session(self, s):
|
||||||
|
# 无密码续期:成功 True,失败 False(不要抛异常)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ---- 发现 / 学习 ----------------------------------------------------
|
||||||
|
def discover(self, s, plan_id, project_id, name=""):
|
||||||
|
return {
|
||||||
|
"platform": self.id,
|
||||||
|
"course": name,
|
||||||
|
"progress": "0/0",
|
||||||
|
"info": "",
|
||||||
|
"user": "",
|
||||||
|
"courseware": [],
|
||||||
|
"feedback": [],
|
||||||
|
"test": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def study(self, key, s, d, **opts):
|
||||||
|
for cw in d["courseware"]:
|
||||||
|
if self._stopped(key):
|
||||||
|
return
|
||||||
|
runtime.log(key, "开始:%s" % cw["title"])
|
||||||
|
# ... 调接口上报进度;被踢回登录页时 raise SessionExpired("...")
|
||||||
|
runtime.log(key, "完成:%s" % cw["title"])
|
||||||
|
|
||||||
|
# ---- 私有辅助(_ 开头,不属于契约)-----------------------------------
|
||||||
|
def _stopped(self, key):
|
||||||
|
with runtime.RUNS_LOCK:
|
||||||
|
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORM = registry.register(MyPlatform())
|
||||||
|
```
|
||||||
|
|
||||||
|
平台确实没有的阶段**不要写**,保持基类的 `None`;`capabilities()` 按「子类是否把它覆盖成方法」
|
||||||
|
推导能力名,前端据此决定显示哪些按钮。
|
||||||
|
|
||||||
|
## 槽位契约
|
||||||
|
|
||||||
|
| 槽位 | 方法签名 | 调用方 | 缺失时 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `study` | `(self, key, s, d, **opts)` | `engine.run_one_course` | 学习时报「平台 X 没有可用的学习实现」 |
|
||||||
|
| `discover` | `(self, s, plan_id, project_id, name)` | `run_one_course`、`/api/discover` | 报「平台 X 没有可用的课程发现实现」 |
|
||||||
|
| `session` | `(self, cookies) -> requests.Session` | 引擎与各路由 | 报「平台 X 没有实现 session」 |
|
||||||
|
| `dump_cookies` | `(self, s) -> [cookie]` | 登录 / 刷新 / 发现后回写前端 | 不回写 cookie,后续请求可能丢会话 |
|
||||||
|
| `login` | `(self, username, password) -> (session, user)` | `/api/login` | 不能密码登录,`/api/login` 报 `no_platform` |
|
||||||
|
| `list_courses` | `(self, s) -> [course]` | 登录后、`/api/courses` | 登录后课程为空,`/api/courses` 报 `no_platform` |
|
||||||
|
| `renew_session` | `(self, s) -> bool` | `SessionExpired` 时无密码续期 | 直接走密码重登 |
|
||||||
|
| `profile` | `(self, s) -> 显示名` | 登录后卡片显示 | 显示名为空 |
|
||||||
|
|
||||||
|
> 对「能完整跑起来」的平台,实际必需的是 `session` + `discover` + `study`。
|
||||||
|
> 想支持「添加账号 → 登录 → 自动带出课程」,再补 `login` / `list_courses` / `dump_cookies` / `profile`。
|
||||||
|
|
||||||
|
### `session` / `dump_cookies`
|
||||||
|
|
||||||
|
- `cookies` 是前端传回的列表,元素形如 `{"name", "value", "domain", "path"}`,`domain` 可能带前导点。
|
||||||
|
- 只接收本平台的 cookie(按 `DOMAINS` 过滤),否则会把别的站点 cookie 塞进来;
|
||||||
|
域名过滤**必须**在插件里做,引擎不兜底。
|
||||||
|
- 恢复时 `domain=domain.lstrip(".")`;`path` 缺省用 `/`。
|
||||||
|
- 过滤后为空时,`/api/courses` 会返回 `no_cookie`「保存的 cookie 全部被过滤」,
|
||||||
|
这通常说明 `DOMAINS` 写错或登录域与学习域不是同一个父域。
|
||||||
|
- `dump_cookies` 把 `requests.Session` 里的 cookie 原样导出即可;域名不要改写,
|
||||||
|
下次 `session()` 还要按它恢复。
|
||||||
|
|
||||||
|
### `login`
|
||||||
|
|
||||||
|
- 返回 `(session, user)`:`session` 是**已登录**的会话,`user` 是卡片上显示的名字。
|
||||||
|
- 登录失败抛 `RuntimeError`(带可读原因),路由会记进登录记录并回显给前端。
|
||||||
|
- 会话失效抛 `SessionExpired`,不要自己弹窗、不要自己重试整轮流程。
|
||||||
|
- 登录成功后引擎 / 路由会立刻调 `list_courses` 缓存课程;`list_courses` 失败不会让登录失败,
|
||||||
|
只会把 `warn` 带回去。
|
||||||
|
|
||||||
|
### `list_courses`
|
||||||
|
|
||||||
|
返回列表,每个课程至少包含:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"name": "课程名",
|
||||||
|
"plan_id": "...", # 引擎 / 路由都按它定位课程
|
||||||
|
"project_id": "...", # 同上
|
||||||
|
"in_study": True, # 是否在学;False 表示已结束,前端会折叠
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
可附加 `section` / `status_label` / `info` / `progress` 等展示字段。
|
||||||
|
`in_study` 缺失会让 `/api/courses` 的计数直接报 `KeyError`,必须给。
|
||||||
|
|
||||||
|
### `discover`
|
||||||
|
|
||||||
|
签名 `(self, s, plan_id, project_id, name="")`,返回课程描述。**以下键引擎会直接取,必须存在**:
|
||||||
|
|
||||||
|
| 键 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `platform` | 本平台 id;引擎据此再次 `registry.get()` 取学习实现 |
|
||||||
|
| `course` | 课程名 |
|
||||||
|
| `progress` | 进度文本,如 `"3/10"` |
|
||||||
|
| `info` | 说明文本(可为空串) |
|
||||||
|
| `user` | 显示名 |
|
||||||
|
| `courseware` | 课件列表 |
|
||||||
|
| `feedback` | 反馈表列表 |
|
||||||
|
| `test` | 测验列表 |
|
||||||
|
|
||||||
|
可选键:`generation`(`legacy` / `spa`,用于同一平台多代页面分发)、`train_id`、`page_url`、`need_join`。
|
||||||
|
|
||||||
|
三类活动列表的元素结构由插件自己定义(前端不依赖明细,学习时由插件消费),
|
||||||
|
建议统一成 `{"title", "done", ...}`,并带上学习所需的定位字段(`href` 或 `period_id`)。
|
||||||
|
三类都为空时引擎会直接报「课程里没有任何可识别的活动」。
|
||||||
|
|
||||||
|
`discover` 里如果切换了域名 / 换了新会话,记得让后续 `study` 用同一个 `s`;
|
||||||
|
引擎会在 `discover` 之后调用 `dump_cookies(s)` 把新会话存回去。
|
||||||
|
|
||||||
|
### `study`
|
||||||
|
|
||||||
|
- 收到的是 `discover` 的返回值 `d`,**不要重新发现**;需要最新数据可在内部再取一次页面。
|
||||||
|
- `s` 是已恢复的会话,所有请求都走它;不要自己新建裸 `requests` 会话。
|
||||||
|
- 循环里必须检查停止(见下一节),否则「停止」按钮无效。
|
||||||
|
- 会话失效抛 `SessionExpired`;其它可预期错误抛 `RuntimeError`,引擎会记到该账号的运行状态里。
|
||||||
|
- 全部活动处理完后正常返回即可,不需要返回值。
|
||||||
|
|
||||||
|
### `renew_session` / `profile`
|
||||||
|
|
||||||
|
- `renew_session(s)`:无密码续期(例如 SSO 票据仍有效)。成功返回 `True`,失败返回 `False`,
|
||||||
|
**不要抛异常**——引擎会把它当成一次尝试,失败后继续走密码重登。
|
||||||
|
- `profile(s)`:只读显示名;`/api/courses` 里异常会被吞掉、显示为空,不影响主流程。
|
||||||
|
如果在 `login` 内部直接调用它,自己兜底(`self.profile(s) if self.profile else ""`)。
|
||||||
|
|
||||||
|
## 返回值与 `opts`
|
||||||
|
|
||||||
|
### `study` 的 `opts`
|
||||||
|
|
||||||
|
引擎固定传入以下参数,插件只取自己需要的:
|
||||||
|
|
||||||
|
| 参数 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `workers` | 并发数(路由已夹到 1~95) |
|
||||||
|
| `cap` | 单门课程最长学习分钟数 |
|
||||||
|
| `enable_quiz` | 是否自动交测验(默认关) |
|
||||||
|
| `enable_feedback` | 是否自动交反馈表(默认开) |
|
||||||
|
| `mode` | `fast` = 只调接口;`normal` = 开浏览器真播 |
|
||||||
|
| `parallel` | `normal` 模式下的并发播放路数 |
|
||||||
|
| `quiz_key` | 答题用的大模型配置(`dict` 或 `None`) |
|
||||||
|
| `cookies` | 进入本轮学习前的 cookie 列表;网络请求请用 `s`,不要用它新建会话 |
|
||||||
|
| `plan` / `project` | `plan_id` / `project_id` |
|
||||||
|
| `name` | 课程名 |
|
||||||
|
|
||||||
|
## 日志、停止与错误
|
||||||
|
|
||||||
|
### 日志
|
||||||
|
|
||||||
|
所有日志只走 `runtime.log(key, msg, level="info")`:
|
||||||
|
|
||||||
|
- `key` 用引擎传进来的账号 id;全局 / 系统事件才用 `"system"`。
|
||||||
|
- 时间戳、级别、格式由 `runtime.log` 统一拼,插件里**不要**自己加时间前缀。
|
||||||
|
- 不要 `print()`,不要直接调 `database.run_log_add()`。
|
||||||
|
- 级别用 `info` / `warn` / `error`;正常流程用 `info`,可恢复异常用 `warn`,失败用 `error`。
|
||||||
|
|
||||||
|
```python
|
||||||
|
runtime.log(key, "开始:%s" % title)
|
||||||
|
runtime.log(key, "心跳上报失败,稍后重试:%s" % e, level="warn")
|
||||||
|
```
|
||||||
|
|
||||||
|
日志会同时进控制台、`/api/log`(实时)和 `/api/log/history`(历史),三处内容一致。
|
||||||
|
|
||||||
|
### 停止
|
||||||
|
|
||||||
|
引擎不会强杀线程,插件必须在循环里主动响应:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _stopped(self, key):
|
||||||
|
with runtime.RUNS_LOCK:
|
||||||
|
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
|
||||||
|
```
|
||||||
|
|
||||||
|
在每门课程、每个课件、每次等待前都检查一次;长等待拆成小段(如 `time.sleep(1)` 循环),
|
||||||
|
保证点「停止」后几秒内能退出。
|
||||||
|
|
||||||
|
### 错误
|
||||||
|
|
||||||
|
| 情况 | 抛什么 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 账号密码错误、页面结构变化、接口返回异常 | `RuntimeError("可读原因")` | 登录失败 / 该门课程失败,原因进日志与卡片 |
|
||||||
|
| 会话失效(被踢回登录页、接口返回未登录) | `SessionExpired("...")` | 引擎尝试 `renew_session`,失败再用密码重登 |
|
||||||
|
| 可恢复的瞬时错误(超时、5xx) | 插件内部重试,重试耗尽再抛 | 同上 |
|
||||||
|
|
||||||
|
不要在插件里 `sys.exit` / 杀线程 / 清空 `RUNS`;不要吞掉 `SessionExpired` 后继续请求。
|
||||||
|
|
||||||
|
## 推送渠道插件(`kind: notify`)
|
||||||
|
|
||||||
|
插件目录同样放 `plugins/<名字>/`,但 `plugin.json` 写 `"kind": "notify"`,`main.py` 实现
|
||||||
|
`notify.Channel` 子类而不是 `Platform`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "daidai_push", "title": "呆呆面板推送", "name": "daidai_push", "kind": "notify" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from study_tool import notify
|
||||||
|
from study_tool.config.settings import load_settings
|
||||||
|
|
||||||
|
|
||||||
|
class DaidaiPush(notify.Channel):
|
||||||
|
id = "daidai_push"
|
||||||
|
label = "呆呆面板推送"
|
||||||
|
|
||||||
|
def send(self, title, content, context=None):
|
||||||
|
cfg = load_settings()["notify"] # base_url / app_key / app_secret / channel_name
|
||||||
|
... # 失败直接抛异常,消息会写进账号日志
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFY = notify.register(DaidaiPush())
|
||||||
|
```
|
||||||
|
|
||||||
|
- 核心只调 `notify.fault(kind, key, title, content)` / `notify.retry(key, what)`;
|
||||||
|
渠道插件不参与学习流程,也不要 import `engine` / `routes`。
|
||||||
|
- 配置一律读设置项 `notify`(「系统设置 → 故障推送」里编辑),不要在插件里写死地址或密钥;
|
||||||
|
换 token 之类需要缓存的东西放模块级变量,重启后自动重取。
|
||||||
|
- 停用 / 重载由加载器统一处理:`kind=notify` 的插件从 `notify` 注册表摘除,不会残留。
|
||||||
|
- `POST /api/notify/test` 会走 `notify.send()`,等价于点「测试推送」。
|
||||||
|
|
||||||
|
## 加载与启停
|
||||||
|
|
||||||
|
加载器在 `plugins/__init__.py`,对外四个函数 + 对应 HTTP 接口:
|
||||||
|
|
||||||
|
| 函数 | 接口 | 作用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `load_plugins()` | 启动时自动调用 | 按启用清单 import 全部插件 |
|
||||||
|
| `discover()` | `GET /api/plugins` | 磁盘清单 + 是否启用 / 已加载 / label / sites / capabilities |
|
||||||
|
| `set_enabled(name, bool)` | `POST /api/plugins/{name}/enable`、`/disable` | 立即加载或从注册表摘掉,并落库 |
|
||||||
|
| `reload(name=None)` | `POST /api/plugins/reload`、`/api/plugins/{name}/reload` | 重新 import,改完插件代码不用重启 |
|
||||||
|
|
||||||
|
行为细节:
|
||||||
|
|
||||||
|
- 启用清单存在设置项 `plugins`(`{"disabled": [...]}`):没列进去的默认启用,
|
||||||
|
新丢进目录的插件会自动生效。
|
||||||
|
- 停用的插件会 `registry.unregister()`,从 `/api/platforms` 消失,账号平台下拉里也不会出现。
|
||||||
|
- 启动时单个插件加载失败只记一条 `system` 级别的 `warn` 日志,不会拖垮服务;
|
||||||
|
`GET /api/plugins` 里该插件 `loaded=false`,可以修好后单独 `reload`。
|
||||||
|
- 热重载先把旧实现从注册表摘掉再 import;正在跑的学习任务持有的是旧实例,跑到结束不受影响。
|
||||||
|
- 重载已停用的插件会报错(先启用再重载);重载不存在的插件会报「没有这个插件」。
|
||||||
|
- 前端顶栏的「插件管理」弹窗(`web/src/components/PluginsDialog.tsx`)就是这几个接口的界面。
|
||||||
|
|
||||||
|
## 开发流程(从零接入一个平台)
|
||||||
|
|
||||||
|
1. **摸清站点**:用浏览器完成一次「登录 → 课程列表 → 进入课程 → 上报进度」,
|
||||||
|
在 Network 里记下每个接口的 URL、方法、请求体、返回体、必要 header 和 cookie 域名。
|
||||||
|
服务端渲染的页面把 HTML 存下来,用 `bs4` / `lxml` 解析。
|
||||||
|
2. **建目录**:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
mkdir src\study_tool\plugins\<名字>
|
||||||
|
```
|
||||||
|
|
||||||
|
放 `__init__.py`、`plugin.json`、`main.py` 三个文件;实现复杂再拆 `utils/`。
|
||||||
|
3. **先写 `session` / `dump_cookies`**:能恢复出带登录态的 `requests.Session`,
|
||||||
|
这是后面所有槽位的基础。
|
||||||
|
4. **再写 `login` / `list_courses` / `profile`**:能在「添加账号」里登录成功,
|
||||||
|
卡片显示名字,刷新课程能拿到列表。
|
||||||
|
5. **写 `discover`**:把课程页解析成统一的课程描述(三类活动列表)。
|
||||||
|
6. **最后写 `study`**:按活动类型逐个处理,循环里检查停止,日志用账号 key。
|
||||||
|
7. **注册并启动**:`PLATFORM = registry.register(<类名>())`;启动服务,
|
||||||
|
`GET /api/platforms` 应出现该平台。
|
||||||
|
8. **联调**:按 `/api/login` → `/api/courses` → `/api/discover` → `/api/study` 的顺序走一遍,
|
||||||
|
详细检查项见[联调检查表](#联调检查表)。
|
||||||
|
9. **收尾**:同步更新 [architecture.md](architecture.md) 的目录说明;按仓库规范更新
|
||||||
|
`CHANGELOG.md` / `OPTIMIZATION.md`;不要顺手改无关文件。
|
||||||
|
|
||||||
|
不需要改 `load_builtin()`、`engine.py`、`routes/` 或前端——加载器扫目录时会自动 import 各插件。
|
||||||
|
|
||||||
|
## 联调与调试
|
||||||
|
|
||||||
|
### 接口一览
|
||||||
|
|
||||||
|
| 接口 | 方法 | 主要参数 | 用到的插件槽位 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/platforms` | GET | — | `capabilities()` |
|
||||||
|
| `/api/plugins` | GET | — | 插件清单(不 import 插件代码) |
|
||||||
|
| `/api/plugins/{name}/enable` `/disable` | POST | — | 加载 / 卸载 |
|
||||||
|
| `/api/plugins/reload`、`/api/plugins/{name}/reload` | POST | — | 重新 import |
|
||||||
|
| `/api/login` | POST | `account_id` 或 `username` / `password` / `platform` | `login` → `list_courses` → `dump_cookies` |
|
||||||
|
| `/api/courses` | POST | `platform`、`cookies` | `session` → `list_courses` → `profile` |
|
||||||
|
| `/api/discover` | POST | `platform`、`cookies`、`plan_id`、`project_id`、`name` | `session` → `discover` |
|
||||||
|
| `/api/study` | POST | `key`、`cookies`、`courses[]`、`workers`、`cap_minutes`、`enable_quiz`、`enable_feedback` | 后台线程:`session` → `discover` → `study` |
|
||||||
|
| `/api/stop` | POST | `key` | 置停止标记 |
|
||||||
|
| `/api/log` | GET | `key`、`since` | 实时日志 |
|
||||||
|
| `/api/log/history` | GET | `key`、`tail` | 历史日志 |
|
||||||
|
| `/api/runs` | GET | — | 各账号运行状态快照 |
|
||||||
|
| `/api/loginlog` | GET | `tail` | 登录尝试回放 |
|
||||||
|
|
||||||
|
`/api/study` 是异步的:立即返回,进度看 `/api/runs` 和 `/api/log`;同一账号正在跑时再调会返回
|
||||||
|
「该账号正在学习中」。
|
||||||
|
|
||||||
|
### curl 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 平台是否注册成功
|
||||||
|
curl http://127.0.0.1:8848/api/platforms
|
||||||
|
|
||||||
|
# 插件清单(enabled / loaded / sites / capabilities)
|
||||||
|
curl http://127.0.0.1:8848/api/plugins
|
||||||
|
|
||||||
|
# 改完代码热重载单个插件
|
||||||
|
curl -X POST http://127.0.0.1:8848/api/plugins/<名字>/reload
|
||||||
|
|
||||||
|
# 用账号密码登录(platform 填插件 id)
|
||||||
|
curl -X POST http://127.0.0.1:8848/api/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"...","password":"...","platform":"<id>"}'
|
||||||
|
|
||||||
|
# 用已保存的 cookie 刷新课程(cookie 从上一步返回里取)
|
||||||
|
curl -X POST http://127.0.0.1:8848/api/courses \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"platform":"<id>","cookies":[...]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 下把 `\` 换成一行写完,或使用 PowerShell 的 `Invoke-RestMethod`。
|
||||||
|
|
||||||
|
### 常见错误码
|
||||||
|
|
||||||
|
| code | 含义 | 排查方向 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `no_credential` | 没有可用账号密码 | 添加账号时填密码,或传 `account_id` |
|
||||||
|
| `no_platform` | 平台未注册 / 没实现该槽位 | `/api/plugins` 看 `loaded`;确认方法名拼写 |
|
||||||
|
| `login_failed` | 登录抛了异常 | `/api/loginlog` 看平台返回;检查验证码 / 加密 / 表单字段 |
|
||||||
|
| `no_cookie` | cookie 为空或全被过滤 | `session` 的域名过滤;重新登录 |
|
||||||
|
| `session_expired` | 会话失效 | 实现 / 修好 `renew_session` 或确认密码可重登 |
|
||||||
|
| `fetch_failed` | 列表接口异常 | 页面结构变化、接口参数、header |
|
||||||
|
| `no_course` | 登录有效但没课程 | 课程页选择器 / 板块参数 |
|
||||||
|
|
||||||
|
### 排查顺序
|
||||||
|
|
||||||
|
1. `GET /api/plugins`:插件是否 `loaded=true`;失败先看系统日志里的加载错误。
|
||||||
|
2. `GET /api/platforms`:`label` 和 `capabilities` 是否符合预期(少了能力 = 方法没被识别)。
|
||||||
|
3. `/api/login`:登录是否成功;失败看 `/api/loginlog` 的 `stage` 和错误。
|
||||||
|
4. `/api/courses`:cookie 是否恢复成功、列表选择器是否正确。
|
||||||
|
5. `/api/discover`:返回的三类活动数量与页面是否一致。
|
||||||
|
6. `/api/study` + `/api/log?key=<账号 id>`:日志是否出现在该账号卡片、进度是否递增。
|
||||||
|
7. `/api/stop`:几秒内循环是否退出。
|
||||||
|
|
||||||
|
## 把已有实现改成子类
|
||||||
|
|
||||||
|
老写法(自由函数 + 逐项注册)已经废弃,迁移步骤:
|
||||||
|
|
||||||
|
1. 保留模块级纯函数(`_b64` / `_ocr` / `_parse_courses` 等),它们不属于契约。
|
||||||
|
2. 把原来的槽位自由函数改成同名方法:加 `self`,模块内互相调用改成 `self.xxx`,
|
||||||
|
平台私有的重登逻辑改成 `_relogin` 这类私有方法。
|
||||||
|
3. 加类属性 `id` / `label` / `entries`,删掉模块末尾的逐项注册。
|
||||||
|
4. 模块末尾改成 `PLATFORM = registry.register(<类名>())`。
|
||||||
|
5. 实现模块一起搬进 `plugins/<名字>/`,目录内互相引用改成相对导入;
|
||||||
|
外部(如 `plugins/guanyixue/quiz.py`)如果引用了平台实现,同步改路径或改成走 `registry.get()`。
|
||||||
|
6. 逐个验证槽位行为与迁移前一致;同一平台的不同代际由 `study` 里按
|
||||||
|
`course["generation"]` 分发(参见 `plugins/guanyixue/main.py`)。
|
||||||
|
|
||||||
|
## 硬约束
|
||||||
|
|
||||||
|
- 日志只走 `runtime.log(key, msg, level=...)`,`key` 用引擎传进来的账号 id;不要自己加时间前缀。
|
||||||
|
- 循环里要响应停止:`(runtime.RUNS.get(key) or {}).get("status") == "stopped"` 时返回。
|
||||||
|
- 会话失效抛 `SessionExpired`;自动续期 / 用密码重登由引擎统一处理(`renew_session` 里除外)。
|
||||||
|
- 不 import `engine` / `routes` / 前端;插件之间只允许同平台不同代际互相引用,
|
||||||
|
模块级互相 import 成环时改用函数内延迟导入。
|
||||||
|
- cookie 域名过滤在平台自己的 `session` 里做,引擎不兜底:漏写 `session` 会直接报
|
||||||
|
「平台没有实现 session」,不会静默过滤。
|
||||||
|
- 时间、解析、编码这类通用能力用现成库(`requests` / `bs4` / `lxml` / `datetime`),不要手写。
|
||||||
|
- 新代码的 import 一律显式(如 `from study_tool.runtime import log`),不要新增 `from xxx import *`。
|
||||||
|
- 不改 `engine.py` / `routes/` / 前端来适配单个平台;平台差异只能落在插件目录里。
|
||||||
|
|
||||||
|
## 常见坑
|
||||||
|
|
||||||
|
- **`plugin.json.id` 与类 `id` 不一致**:加载时抛「没有注册 id=...」,插件不会出现在
|
||||||
|
`/api/platforms`;改完记得 `reload`。
|
||||||
|
- **插件目录缺 `__init__.py` / `plugin.json`**:`plugin.json` 缺失会直接扫描不到;
|
||||||
|
`__init__.py` 是仓库约定(现有插件都有),补上能避免打包 / 热重载时的导入歧义。
|
||||||
|
- **自由函数改方法后忘加 `self`**:模块内原来的 `_relogin(key)` 必须写成 `self._relogin(key)`,
|
||||||
|
否则运行时才报错;提交前可扫一遍模块的未定义全局名。
|
||||||
|
- **注册写两遍**:既在类里又在模块末尾逐项注册,会导致同一个 id 被覆盖,`capabilities`
|
||||||
|
也可能和实际方法不一致。只保留 `registry.register(<类名>())`。
|
||||||
|
- **cookie 域写错**:`session` 里过滤的是保存下来的 cookie,域名不匹配会得到空会话,
|
||||||
|
表现为「登录成功但刷新课程提示未登录」。
|
||||||
|
- **日志 key 用错**:平台用自己拼的字符串当 key,日志会跑进别的卡片或 `system` 弹窗。
|
||||||
|
- **把代际当平台**:旧版 / 新版注册成两个平台,账号绑定会漂移;应该在
|
||||||
|
`discover` 里写 `generation`,由 `study` 分发。
|
||||||
|
- **在 `study` 里重新 `discover`**:会重复请求、重复解析,还可能把引擎已经算好的进度覆盖掉;
|
||||||
|
需要最新数据时在插件内部按需取。
|
||||||
|
- **忽略 `dump_cookies`**:`discover` 里换了会话却不让引擎回写,下一门课又会掉登录。
|
||||||
|
- **长 `sleep` 不检查停止**:点「停止」后要等几分钟才退出;把等待拆成小段。
|
||||||
|
- **加载失败只看页面**:启动时的加载异常只写日志、不弹窗;先看控制台或系统日志。
|
||||||
|
|
||||||
|
## 联调检查表
|
||||||
|
|
||||||
|
- [ ] `GET /api/platforms` 里能看到该平台,`label` 与 `capabilities` 正确
|
||||||
|
- [ ] `GET /api/plugins` 里 `loaded=true`,`title` 正确
|
||||||
|
- [ ] 停用 → 平台从 `/api/platforms` 消失;启用 → 重新出现
|
||||||
|
- [ ] 修改代码后 `POST /api/plugins/{name}/reload` 生效,不需要重启
|
||||||
|
- [ ] 添加账号 → 选平台 → 登录成功,卡片显示 `profile` 名字
|
||||||
|
- [ ] 刷新课程返回非空列表,`in_study` 标记正确
|
||||||
|
- [ ] 发现课程返回的活动数与页面一致
|
||||||
|
- [ ] 开始学习:日志出现在该账号卡片里,进度递增
|
||||||
|
- [ ] 点停止后几秒内循环退出
|
||||||
|
- [ ] 会话失效时能走到续期 / 重登,不需要手动刷新
|
||||||
|
- [ ] 平台没实现 `session` 时,`/api/courses` 报「还没有实现 session」
|
||||||
|
- [ ] 日志里没有插件自己加的时间前缀,也没有 `print` 输出
|
||||||
|
|
||||||
|
## 提交前
|
||||||
|
|
||||||
|
- 只改插件目录(以及确有必要同步的 `docs/architecture.md`);不要顺手重构无关文件。
|
||||||
|
- 按仓库规范更新 `CHANGELOG.md` 的 `Unreleased`;需要用户测试确认的事项写进 `OPTIMIZATION.md`。
|
||||||
|
- 插件依赖的平台接口、验证码、加密方式如果是逆向得到的,把关键结论写进插件目录的
|
||||||
|
`__init__.py` 文档字符串或 `main.py` 顶部注释,方便下次维护。
|
||||||
|
- 不提交 `__pycache__`、临时抓包、`data/study.db`。
|
||||||
|
|
||||||
|
## 方案演进
|
||||||
|
|
||||||
|
1. 早期:平台实现直接写在 `engine.py` 里。
|
||||||
|
2. 拆包:`platforms/base.py` 定义 `Platform` 契约 + `registry` 注册表,引擎按
|
||||||
|
`course["platform"]` 分发;平台用自由函数 + 逐项注册;学习流程仍在引擎。
|
||||||
|
3. 契约补全:`contract.py` 推导 `capabilities`;`SessionExpired` 移到 `platforms/errors.py`;
|
||||||
|
各平台学习流程从引擎搬回平台模块(`engine.py` 只剩账号级编排)。
|
||||||
|
4. 类式:`Platform` 变成普通基类,平台写成子类(类属性 + 方法),禁止逐项传函数注册。
|
||||||
|
5. 插件目录(当前):平台全部迁到 `plugins/<名字>/`(`plugin.json` + `main.py`,实现复杂的
|
||||||
|
再拆同目录模块),`load_plugins()` 扫目录加载,支持启停与热重载;
|
||||||
|
`gdgxk`、`guanyixue` 都已完成迁移。
|
||||||
|
|
||||||
|
## 附录:现有插件对照
|
||||||
|
|
||||||
|
| 插件 | 目录 | 入口域名 | 已实现能力 | 实现拆分 | 值得参考 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 广东公需课 | `plugins/gdgxk/` | `jsglpt.gds.edu.cn`、`jsxx.gds.edu.cn` | 登录 / 会话 / 课程 / 发现 / 学习 / 续期 / 显示名 | `utils/`:`http` / `login` / `course` / `learn` | 双域名 SSO 换票、base64 账号密码、验证码、心跳上报 |
|
||||||
|
| 莞易学 | `plugins/guanyixue/` | `jspx.dgjy.net`、`study.dgjy.net` | 登录 / 会话 / 课程 / 发现 / 学习 / 续期 / 显示名 | `utils/`:`http` / `login` / `course` / `feedback` / `learn` / `spa`;`detect.py` 识别页面代际 | 同一平台 `legacy` / `spa` 两代页面按 `generation` 分发 |
|
||||||
|
|
||||||
|
看代码时的推荐顺序:
|
||||||
|
|
||||||
|
1. `plugins/guanyixue/main.py`:最短的完整子类,能看清类属性 + 槽位 + 代际分发。
|
||||||
|
2. `plugins/guanyixue/utils/course.py`:`discover` 的返回值长什么样。
|
||||||
|
3. `plugins/gdgxk/main.py` + `utils/learn.py`:登录换票、学习循环与停止检查。
|
||||||
|
4. `platforms/base.py` / `contract.py`:契约的最终定义(有疑问以代码为准)。
|
||||||
+27
-4
@@ -1,10 +1,33 @@
|
|||||||
# 后续待办
|
# 后续待办
|
||||||
|
|
||||||
- 接入第二个学习平台时,在 `platforms/<平台>.py` 里实现 `session` / `login` / `list_courses` /
|
## 待做
|
||||||
`discover` / `study` 并注册;账号在「添加账号」里选平台后即可用,登录与课程发现不需要再改路由。
|
|
||||||
|
- 登录日志的 key 用错维度:`routes/accounts.py` 的 `_log_login()` 拿用户名(手机号)当 key 写 `run_log`,而卡片
|
||||||
|
「课程日志」按账号 id 读,两边对不上——登录行只有「登录记录」页能看到。要么统一成账号 id,要么卡片同时聚合两个 key。
|
||||||
|
- `ddddocr` 的报错提示会误导:`plugins/gdgxk/utils/login.py` 把任何导入失败都写成「需要先装 ddddocr(pip install ddddocr)」,
|
||||||
|
`uv sync` 在应用运行期间改写包造成的 `DLL load failed` 也会被说成「没装」。应区分 ImportError 与 OSError(DLL),
|
||||||
|
并把真实异常写进 `runtime.log`。
|
||||||
- 前端补一条自动化回归(Playwright):空态、账号卡片、测验弹窗、设置保存、平台下拉。
|
- 前端补一条自动化回归(Playwright):空态、账号卡片、测验弹窗、设置保存、平台下拉。
|
||||||
|
- pre-commit 首次接入还没跑过全量:`ruff-format` 会重排约 30 个文件,`ruff check` 会报
|
||||||
|
`plugins/guanyixue/script/study_video.py:34` 的 E402(脚本先改 `sys.path` 再 import,加 `# noqa: E402` 即可)。
|
||||||
|
找一次专门的提交跑 `pre-commit run --all-files`,别混进功能改动里。
|
||||||
- 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`,
|
- 构建检查进 CI:`cd web && pnpm install --frozen-lockfile && pnpm typecheck && pnpm build`,
|
||||||
并校验 `static/` 与源码同步。
|
并校验 `static/` 与源码同步。
|
||||||
|
- 广东公需课:视频弹题仍未接入;`answer.py` 的两套按序答案留作考核备选,暂未使用。
|
||||||
|
- 推送渠道目前只有呆呆面板(`plugins/daidai_push/`):要接 WxPusher / Telegram / 邮件,按
|
||||||
|
`"kind": "notify"` 再建一个插件目录即可,核心不用改;`notify.send()` 会发给所有已加载渠道。
|
||||||
|
- 接入第二个学习平台时,建 `plugins/<名字>/`(`plugin.json` + `main.py` 的 `Platform` 子类),实现
|
||||||
|
`session` / `login` / `list_courses` / `discover` / `study` 并注册;账号在「添加账号」里选平台后即可用,登录与课程发现不需要再改路由。
|
||||||
|
|
||||||
|
## 已知偏差
|
||||||
|
|
||||||
|
- 测验 `/api/quiz/submit` 已改成按 `registry.get(platform).quiz_submit` 分发(公需课也能交卷),但 `/api/quiz*` 三个接口仍由莞易学插件注册:
|
||||||
|
停用莞易学后公需课的「打开测验」会 404。要让接口彻底跟插件解耦,需把它们挪到 `routes/`,并把 AI 答题的提示词 / 解析一起下沉到核心。
|
||||||
|
- `engine.py` 的 legacy / spa 日志标签、`enable_quiz` / `optQuiz` 字段、`snapshots` 表仍带着莞易学的概念,
|
||||||
|
有第二个平台再决定是否抽象。
|
||||||
|
|
||||||
|
## 未验证
|
||||||
|
|
||||||
- 广东公需课:用一门**未学完**的课实测进度上报,确认平台端「已学时长 / 已完成」状态跟着变。
|
- 广东公需课:用一门**未学完**的课实测进度上报,确认平台端「已学时长 / 已完成」状态跟着变。
|
||||||
- 广东公需课:**试卷考核**未接入 —— 平台判定通过要「所有教学活动 100% + 试卷考核 ≥60 分」,所以视频报满后课程仍显示「未完成」。
|
- 广东公需课:**试卷考核**已接入(`utils/exam.py`:解析内嵌题面 → 题库/AI 答题 → `finishTest2` 交卷),待用
|
||||||
- 广东公需课:视频弹题未接入。
|
一门只差考核的课实测交卷响应与得分。
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
## 待评估:接入广东公需课平台(jsxx.gds.edu.cn)
|
|
||||||
|
|
||||||
**状态**:已接入,见 `src/study_tool/platforms/gdgxk.py` 与 `OPTIMIZATION.md`;登录 / 课程列表 / 进度心跳已实测,待用未学完课程确认。
|
|
||||||
|
|
||||||
### 背景
|
|
||||||
|
|
||||||
参考脚本:`2026广东公需课·全自动完美版(后台保活+10分钟刷新).js`(油猴 v1.3,`@match https://jsxx.gds.edu.cn/*`)。
|
|
||||||
|
|
||||||
它做的事:悬浮面板 + 后台保活 + 10 分钟整页刷新 + 视频题目题库命中(约 200 条)+ 未命中时 12 轮
|
|
||||||
暴力试错 + 考核页按课程 id 硬编码两套 30 题答案自动勾选(不代交卷)。
|
|
||||||
|
|
||||||
注意:这是**广东专业技术人员继续教育公需课**,跟本项目的 dgjy(东莞教师培训)不是一套系统,
|
|
||||||
连登录入口都不同。
|
|
||||||
|
|
||||||
### 结论:不能直接融合
|
|
||||||
|
|
||||||
| 维度 | 该脚本 | 本项目 | 能不能用 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 站点 | jsxx.gds.edu.cn | jspx / study.dgjy.net | 无交集 |
|
|
||||||
| 实现层 | 页面 DOM 点击、layui/mylayer 弹层 | 服务端接口 + Playwright 真播 | 不同层 |
|
|
||||||
| 答案 | 硬编码题库 + 按课程 id 两套答案 | 大模型现算 | 数据不可复用 |
|
|
||||||
| 保活 | setInterval、伪造 mousemove、整页 reload | 服务端跑逻辑,浏览器只播视频 | reload 会打断播放 |
|
|
||||||
| 兜底 | 12 轮暴力试错 | LLM + 交卷前人工核对 | 平台限次,会烧光重考机会 |
|
|
||||||
|
|
||||||
### 真要接入时的顺序
|
|
||||||
|
|
||||||
1. **抓包取证**(阻塞项,必须有公需课账号)
|
|
||||||
- 登录链路:账号密码还是统一身份认证?落哪些 cookie / token
|
|
||||||
- 课程列表与课时树接口
|
|
||||||
- 课时上报接口:视频进度怎么记账,是否要心跳
|
|
||||||
- 考试接口:取卷、交卷、重考次数(对应 dgjy 的 `surplusExamCount` 那一套)
|
|
||||||
- 视频是不是直链,需不需要 Playwright 真播到结尾
|
|
||||||
2. **适配层分派**:`app.py` 现在 `login()` / `discover()` / `detect_platform()` / `StudyClient`
|
|
||||||
全是 dgjy 专用,得按平台分支;`accounts` 表加平台标识,控制台卡片显示平台
|
|
||||||
3. **答题**:本地题库命中优先、LLM 兜底(题库得自己攒 dgjy 的,脚本那 200 条是公需课内容,搬不过来)
|
|
||||||
4. **UI**:课程卡片区分平台;公需课的「考核」页结构跟 dgjy 完全不同,不能复用测验弹窗
|
|
||||||
|
|
||||||
### 明确不照抄的部分
|
|
||||||
|
|
||||||
- 暴力试错答题:dgjy 平台一般只给 3 次重考,试错直接废掉机会
|
|
||||||
- 10 分钟整页刷新:打断正在播放的视频,进度白费
|
|
||||||
- 按课程 id 硬编码答案:换课程批次即失效,脚本注释自己写了「已反转,解决反了问题」,说明是试错出来的
|
|
||||||
- `.layui-layer` / `.mylayer-wrap` / `#studySelectAct .next` / `#topicList .m-topic-item` 这些选择器
|
|
||||||
- 用 `innerText` 包含「正确」判断已答弹层:题干带「正确」二字就会被误点
|
|
||||||
- 把「下一节」按钮 `pointer-events:none` 锁死:平台一改版就失效
|
|
||||||
|
|
||||||
### 可以借鉴的
|
|
||||||
|
|
||||||
- 题库命中优先、LLM 兜底(省 token、命中时更准)
|
|
||||||
- 强制播完才放开「下一节」—— 项目已有等价实现(播到 `ended` + `completeTime` 校验)
|
|
||||||
|
|
||||||
## 待办:日志 key 与 ddddocr 报错提示
|
|
||||||
|
|
||||||
- **登录日志的 key 用错维度**:`routes/accounts.py` 的 `_log_login()` 拿**用户名(手机号)**当 key 写 run_log,而卡片「课程日志」读的是**账号 id**,两边对不上——登录行只有登录记录页(`login_log` 表)能看到。要么统一成账号 id,要么卡片同时聚合两个 key。
|
|
||||||
- **ddddocr 的报错提示会误导**:`platforms/gdgxk.py` 把任何导入失败都写成「需要先装 ddddocr(pip install ddddocr)」,`uv sync` 在应用运行期间改写包造成的 `DLL load failed` 也会被说成「没装」。应区分 ImportError 与 OSError(DLL),并把真实异常写进 `runtime.log`。
|
|
||||||
|
|
||||||
## 已完成
|
|
||||||
|
|
||||||
- 「首次进入课程弹『未加入课堂』」已修复,实测确认见 `OPTIMIZATION.md`。
|
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
name: study-tool
|
name: study-tool
|
||||||
version: 0.0.6
|
version: 0.0.8
|
||||||
description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台
|
description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台
|
||||||
|
|||||||
+37
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "study-tool"
|
name = "study-tool"
|
||||||
version = "0.0.1"
|
version = "0.0.8"
|
||||||
description = "东莞市教师培训平台多账号学习控制台"
|
description = "东莞市教师培训平台多账号学习控制台"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -15,6 +15,8 @@ dependencies = [
|
|||||||
"lxml>=5.0",
|
"lxml>=5.0",
|
||||||
"playwright>=1.40",
|
"playwright>=1.40",
|
||||||
"ddddocr",
|
"ddddocr",
|
||||||
|
"python-dotenv>=1.0",
|
||||||
|
"sqlalchemy>=2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -34,3 +36,37 @@ where = ["src"]
|
|||||||
name = "aliyun"
|
name = "aliyun"
|
||||||
url = "https://mirrors.aliyun.com/pypi/simple"
|
url = "https://mirrors.aliyun.com/pypi/simple"
|
||||||
default = true
|
default = true
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
extend-exclude = ["static", "web"]
|
||||||
|
|
||||||
|
# 只拦真正的错误(语法 / 未定义名 / 未用导入);历史模块仍有 star import,先不拦 F403 / F405
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E4", "E7", "E9", "F"]
|
||||||
|
ignore = ["F403", "F405"]
|
||||||
|
|
||||||
|
# 发版:uv tool run bump-my-version bump patch(工作区需干净),自动改 metadata.yaml / pyproject.toml / uv.lock 三处版本、提交并打 tag
|
||||||
|
[tool.bumpversion]
|
||||||
|
current_version = "0.0.8"
|
||||||
|
commit = true
|
||||||
|
tag = true
|
||||||
|
tag_name = "v{new_version}"
|
||||||
|
message = "chore(release): v{new_version}"
|
||||||
|
|
||||||
|
[[tool.bumpversion.files]]
|
||||||
|
filename = "metadata.yaml"
|
||||||
|
search = "version: {current_version}"
|
||||||
|
replace = "version: {new_version}"
|
||||||
|
|
||||||
|
[[tool.bumpversion.files]]
|
||||||
|
filename = "pyproject.toml"
|
||||||
|
regex = true
|
||||||
|
search = '(?m)^version = "{current_version}"'
|
||||||
|
replace = 'version = "{new_version}"'
|
||||||
|
|
||||||
|
[[tool.bumpversion.files]]
|
||||||
|
filename = "uv.lock"
|
||||||
|
regex = true
|
||||||
|
search = '(?m)(name = "study-tool"\r?\n)version = "{current_version}"'
|
||||||
|
replace = '\g<1>version = "{new_version}"'
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ beautifulsoup4>=4.12
|
|||||||
lxml>=5.0
|
lxml>=5.0
|
||||||
playwright>=1.40
|
playwright>=1.40
|
||||||
ddddocr
|
ddddocr
|
||||||
|
python-dotenv>=1.0
|
||||||
|
sqlalchemy>=2.0
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
"""广东公需课自测:用浏览器抓到的 cookie 走一遍 列表 → 发现 → 心跳。
|
|
||||||
|
|
||||||
用法:PYTHONPATH=src python script/test-gdgxk.py [课件id]
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from study_tool.platforms import gdgxk
|
|
||||||
|
|
||||||
cid = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
||||||
cookies = json.load(open("data/capture-jsglpt-cookies.json", encoding="utf-8"))
|
|
||||||
s = gdgxk.new_session(cookies)
|
|
||||||
|
|
||||||
courses = gdgxk.list_courses(s)
|
|
||||||
print("课程数:", len(courses))
|
|
||||||
for c in courses:
|
|
||||||
print(" ", c["plan_id"], c["name"])
|
|
||||||
|
|
||||||
if not cid and courses:
|
|
||||||
cid = courses[0]["plan_id"]
|
|
||||||
|
|
||||||
d = gdgxk.discover(s, cid, "")
|
|
||||||
print("\n课程:", d["course"])
|
|
||||||
print("进度:", d["progress"])
|
|
||||||
print("视频:", d["info"])
|
|
||||||
cw = d["courseware"][0]
|
|
||||||
print("课件:", cw["id"], "| 总长", cw["total"], "| 已看", cw["viewTime"])
|
|
||||||
|
|
||||||
sid = uuid.uuid4().hex
|
|
||||||
sign = gdgxk._sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, cw["uid"])
|
|
||||||
print("\n播放注册签名:", sign)
|
|
||||||
print("服务端已看秒数:", gdgxk._view_time(s, cw, cw["uid"], sign))
|
|
||||||
|
|
||||||
r = gdgxk._xhr(s, "POST",
|
|
||||||
gdgxk.X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1",
|
|
||||||
data={"_method": "PUT", "aid": cw["aid"], "isLimit": "false", "md5": sign["md5"],
|
|
||||||
"expires": sign["time"], "md5Uid": cw["uid"],
|
|
||||||
"lastViewTime": str(min(cw["viewTime"] + 60, cw["total"])),
|
|
||||||
"playbackSessionId": sid})
|
|
||||||
print("\n心跳响应:", r.status_code, r.text[:300])
|
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
"""多账号学习控制台(东莞市教师培训平台)。"""
|
"""多账号学习控制台(东莞市教师培训平台)。"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# 仓库根目录的 .env:本机启动(python app.py / uv run app.py)也按它配置。
|
||||||
|
# 必须在任何子模块之前加载 —— database / config 在 import 时就读环境变量;
|
||||||
|
# 已经在环境里的变量优先(Docker 由 compose 注入,不会被 .env 覆盖)。
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
load_dotenv(os.path.join(_ROOT, ".env"))
|
||||||
|
|
||||||
__version__ = "0.0.1"
|
__version__ = "0.0.1"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""`python -m study_tool` 入口(与根目录 app.py 等价)。"""
|
"""`python -m study_tool` 入口(与根目录 app.py 等价)。"""
|
||||||
|
|
||||||
from study_tool.app import main
|
from study_tool.app import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+61
-45
@@ -1,22 +1,19 @@
|
|||||||
"""多账号学习控制台 —— 入口。
|
"""多账号学习控制台 —— 入口。
|
||||||
|
|
||||||
代码结构
|
代码结构
|
||||||
config 常量 / 环境变量
|
config 常量 / 环境变量 / 系统设置(config.settings)
|
||||||
|
database SQLite 存储(SQLAlchemy ORM:模型 + 读写函数)
|
||||||
runtime 运行状态 + 统一日志(控制台 / 内存 / SQLite)
|
runtime 运行状态 + 统一日志(控制台 / 内存 / SQLite)
|
||||||
platform_jspx 旧版平台登录与课程抓取
|
platforms 平台运行时(registry / base / contract / errors)
|
||||||
platform_study 新版平台 API
|
plugins 平台插件(guanyixue / gdgxk …);平台实现、抓包、测验都在插件目录
|
||||||
engine 学习引擎(worker / 播放 / 提交)
|
engine 学习引擎(worker / 播放 / 提交)
|
||||||
capture 抓包
|
|
||||||
models HTTP 请求模型
|
|
||||||
settings_store 系统设置
|
|
||||||
llm 大模型调用
|
llm 大模型调用
|
||||||
quiz 测验解析 / 交卷 / AI 答题
|
models HTTP 请求模型
|
||||||
accounts 账号与登录接口
|
routes 账号 / 设置 / 日志 / 课程等接口
|
||||||
routes_misc 课程 / 学习 / 抓包 / 快照接口
|
|
||||||
log_routes 日志与运行状态接口
|
|
||||||
|
|
||||||
日志只走 runtime.log(),不要自己 print。
|
日志只走 runtime.log(),不要自己 print。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
@@ -26,17 +23,16 @@ from fastapi import Request
|
|||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from study_tool import store
|
from study_tool import database
|
||||||
from study_tool.config import *
|
from study_tool.config import STATIC_DIR
|
||||||
from study_tool.platforms import load_builtin
|
from study_tool.platforms import load_builtin
|
||||||
|
|
||||||
|
from study_tool.routes import load_routes
|
||||||
from study_tool.runtime import RUNS, RUNS_LOCK, log
|
from study_tool.runtime import RUNS, RUNS_LOCK, log
|
||||||
from study_tool.webapp import app
|
from study_tool.webapp import app
|
||||||
|
|
||||||
# 导入即把各路由模块的接口注册到 app 上(必须在 main() 之前)
|
load_routes() # 扫描 routes/ 并导入全部路由模块,接口注册到 app 上(必须在 main() 之前)
|
||||||
from study_tool import llm, quiz, settings_store # noqa: E402,F401
|
load_builtin() # 扫 plugins/ 目录加载全部插件(目录为空也能正常启动)
|
||||||
from study_tool.routes import accounts, logs, misc # noqa: E402,F401
|
|
||||||
|
|
||||||
load_builtin() # 注册内置平台插件(jspx / study)
|
|
||||||
|
|
||||||
APP_ID = "dgjy-study-console"
|
APP_ID = "dgjy-study-console"
|
||||||
|
|
||||||
@@ -49,6 +45,7 @@ if os.path.isdir(_ASSETS_DIR):
|
|||||||
# 不设置 = 不校验(本机使用,或前面已经有反代做鉴权)。/api/health 永远放行,供健康检查用。
|
# 不设置 = 不校验(本机使用,或前面已经有反代做鉴权)。/api/health 永远放行,供健康检查用。
|
||||||
AUTH_TOKEN = (os.environ.get("AUTH_TOKEN") or "").strip()
|
AUTH_TOKEN = (os.environ.get("AUTH_TOKEN") or "").strip()
|
||||||
AUTH_COOKIE = "dgjy_auth"
|
AUTH_COOKIE = "dgjy_auth"
|
||||||
|
_PUBLIC_PATHS = {"/query", "/api/query", "/favicon.svg"}
|
||||||
|
|
||||||
_AUTH_PAGE = """<!doctype html><meta charset="utf-8"><title>需要访问令牌</title>
|
_AUTH_PAGE = """<!doctype html><meta charset="utf-8"><title>需要访问令牌</title>
|
||||||
<style>body{font:14px/1.7 system-ui,"Microsoft YaHei",sans-serif;background:#f5f6f8;color:#1f2430;
|
<style>body{font:14px/1.7 system-ui,"Microsoft YaHei",sans-serif;background:#f5f6f8;color:#1f2430;
|
||||||
@@ -65,30 +62,34 @@ background:#2b6cf6;color:#fff;font-size:14px;cursor:pointer}code{background:#f1f
|
|||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def auth_gate(request: Request, call_next):
|
async def auth_gate(request: Request, call_next):
|
||||||
if not AUTH_TOKEN:
|
# 公开查询页(/query + /api/query)及其静态资源不需要 token
|
||||||
|
if not AUTH_TOKEN or request.url.path in _PUBLIC_PATHS or request.url.path.startswith("/assets/"):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
tok = (request.query_params.get("token")
|
tok = (
|
||||||
|
request.query_params.get("token")
|
||||||
or request.cookies.get(AUTH_COOKIE)
|
or request.cookies.get(AUTH_COOKIE)
|
||||||
or request.headers.get("x-auth-token") or "")
|
or request.headers.get("x-auth-token")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
if tok == AUTH_TOKEN:
|
if tok == AUTH_TOKEN:
|
||||||
resp = await call_next(request)
|
resp = await call_next(request)
|
||||||
if request.query_params.get("token"):
|
if request.query_params.get("token"):
|
||||||
# 带上 ?token= 访问后下发 cookie,页面里所有同源 fetch 会自动带上
|
# 带上 ?token= 访问后下发 cookie,页面里所有同源 fetch 会自动带上
|
||||||
resp.set_cookie(AUTH_COOKIE, AUTH_TOKEN, max_age=30 * 24 * 3600,
|
resp.set_cookie(AUTH_COOKIE, AUTH_TOKEN, max_age=30 * 24 * 3600, httponly=True, samesite="lax")
|
||||||
httponly=True, samesite="lax")
|
|
||||||
return resp
|
return resp
|
||||||
if request.url.path == "/api/health":
|
if request.url.path == "/api/health":
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
if request.url.path.startswith("/api/"):
|
if request.url.path.startswith("/api/"):
|
||||||
return JSONResponse({"ok": False, "code": "unauthorized",
|
return JSONResponse(
|
||||||
"error": "缺少访问令牌(AUTH_TOKEN)"}, status_code=401)
|
{"ok": False, "code": "unauthorized", "error": "缺少访问令牌(AUTH_TOKEN)"}, status_code=401
|
||||||
|
)
|
||||||
return HTMLResponse(_AUTH_PAGE, status_code=401)
|
return HTMLResponse(_AUTH_PAGE, status_code=401)
|
||||||
|
|
||||||
|
|
||||||
def hydrate_runs():
|
def hydrate_runs():
|
||||||
"""进程重启后把上次的运行状态读回来;上次没跑完的标记成「已中断」。"""
|
"""进程重启后把上次的运行状态读回来;上次没跑完的标记成「已中断」。"""
|
||||||
try:
|
try:
|
||||||
saved = store.run_state_all()
|
saved = database.run_state_all()
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
with RUNS_LOCK:
|
with RUNS_LOCK:
|
||||||
@@ -101,22 +102,26 @@ def hydrate_runs():
|
|||||||
st["status"] = "interrupted"
|
st["status"] = "interrupted"
|
||||||
st["error"] = "服务重启,上次运行已中断"
|
st["error"] = "服务重启,上次运行已中断"
|
||||||
try:
|
try:
|
||||||
store.run_state_set(k, st) # 落库,别让库里一直挂着 studying
|
database.run_state_set(k, st) # 落库,别让库里一直挂着 studying
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
RUNS[k] = st
|
RUNS[k] = st
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/query", response_class=HTMLResponse) # 公开查询页:同一个前端入口,由前端按路径渲染
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index():
|
def index():
|
||||||
# 页面必须禁止缓存:后端重启或换端口后,旧标签页会拿着缓存里的 HTML 继续跑,
|
# 页面必须禁止缓存:后端重启或换端口后,旧标签页会拿着缓存里的 HTML 继续跑,
|
||||||
# 页面照常显示、所有接口全 ERR_CONNECTION_REFUSED,看起来就像"登录失败"。
|
# 页面照常显示、所有接口全 ERR_CONNECTION_REFUSED,看起来就像"登录失败"。
|
||||||
html = open(os.path.join(STATIC_DIR, "index.html"), encoding="utf-8").read()
|
html = open(os.path.join(STATIC_DIR, "index.html"), encoding="utf-8").read()
|
||||||
return HTMLResponse(html, headers={
|
return HTMLResponse(
|
||||||
|
html,
|
||||||
|
headers={
|
||||||
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||||
"Pragma": "no-cache",
|
"Pragma": "no-cache",
|
||||||
"Expires": "0",
|
"Expires": "0",
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/favicon.svg")
|
@app.get("/favicon.svg")
|
||||||
@@ -129,13 +134,20 @@ def favicon():
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def api_health():
|
def api_health():
|
||||||
return {"ok": True, "app": APP_ID, "pid": os.getpid(),
|
return {
|
||||||
"port": int(os.environ.get("PORT", "8848")), "runs": len(RUNS),
|
"ok": True,
|
||||||
"db": store.db_info(), "auth": bool(AUTH_TOKEN)}
|
"app": APP_ID,
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"port": int(os.environ.get("PORT", "8848")),
|
||||||
|
"runs": len(RUNS),
|
||||||
|
"db": database.db_info(),
|
||||||
|
"auth": bool(AUTH_TOKEN),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _port_open(port, host="127.0.0.1", timeout=0.6):
|
def _port_open(port, host="127.0.0.1", timeout=0.6):
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
s.settimeout(timeout)
|
s.settimeout(timeout)
|
||||||
return s.connect_ex((host, port)) == 0
|
return s.connect_ex((host, port)) == 0
|
||||||
@@ -146,6 +158,7 @@ def _lan_ip():
|
|||||||
if os.path.exists("/.dockerenv"):
|
if os.path.exists("/.dockerenv"):
|
||||||
return ""
|
return ""
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||||
s.connect(("223.5.5.5", 80))
|
s.connect(("223.5.5.5", 80))
|
||||||
@@ -157,6 +170,7 @@ def _lan_ip():
|
|||||||
def _probe_self(port):
|
def _probe_self(port):
|
||||||
"""端口已被占用时判断是不是本程序自己在跑,避免重复启动直接报错。"""
|
"""端口已被占用时判断是不是本程序自己在跑,避免重复启动直接报错。"""
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen("http://127.0.0.1:%d/api/health" % port, timeout=1.5) as r:
|
with urllib.request.urlopen("http://127.0.0.1:%d/api/health" % port, timeout=1.5) as r:
|
||||||
return json.loads(r.read().decode("utf-8"))
|
return json.loads(r.read().decode("utf-8"))
|
||||||
@@ -166,41 +180,43 @@ def _probe_self(port):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
port = int(os.environ.get("PORT", "8848"))
|
port = int(os.environ.get("PORT", "8848"))
|
||||||
# 默认只监听本机;Docker / 远程管理用 HOST=0.0.0.0
|
# 默认只监听本机;Docker / 远程管理用 HOST=0.0.0.0(.env 里写 BIND_ADDR 同样生效)
|
||||||
host = os.environ.get("HOST", "127.0.0.1")
|
host = os.environ.get("HOST") or os.environ.get("BIND_ADDR") or "127.0.0.1"
|
||||||
url = "http://127.0.0.1:%d" % port
|
url = "http://127.0.0.1:%d" % port
|
||||||
# 设了访问令牌时,打印出来的地址一律带上 ?token=:本机访问同样要校验令牌
|
# 设了访问令牌时,日志里只提示「需要令牌」,绝不打印令牌本身:日志同时进控制台、
|
||||||
suffix = ("/?token=%s" % AUTH_TOKEN) if AUTH_TOKEN else ""
|
# 内存和 SQLite,前端「系统日志」弹窗里能直接看到,明文等于把钥匙贴在墙上。
|
||||||
|
suffix = "(需要访问令牌:见 .env 的 AUTH_TOKEN)" if AUTH_TOKEN else ""
|
||||||
|
|
||||||
store.init() # 建库建表 + 导入旧的 settings.json / logs/*.log
|
database.init() # 建库建表 + 导入旧的 settings.json / logs/*.log
|
||||||
hydrate_runs() # 上次的运行状态读回来(没跑完的标成已中断)
|
hydrate_runs() # 上次的运行状态读回来(没跑完的标成已中断)
|
||||||
log("system", "SQLite: %s" % store.DB_PATH)
|
log("system", "SQLite: %s" % database.DB_PATH)
|
||||||
|
|
||||||
if _port_open(port):
|
if _port_open(port):
|
||||||
info = _probe_self(port)
|
info = _probe_self(port)
|
||||||
if info and info.get("app") == APP_ID:
|
if info and info.get("app") == APP_ID:
|
||||||
log("system", "服务已在运行 -> %s%s(PID %s)" % (url, suffix, info.get("pid")))
|
log("system", "服务已在运行 -> %s%s(PID %s)" % (url, suffix, info.get("pid")))
|
||||||
return
|
return
|
||||||
log("system", "端口 %d 被其它程序占用(不是本程序):netstat -ano | findstr :%d,"
|
log(
|
||||||
"换端口用 PORT=8849 python app.py" % (port, port), level="error")
|
"system",
|
||||||
|
"端口 %d 被其它程序占用(不是本程序):netstat -ano | findstr :%d,"
|
||||||
|
"换端口用 PORT=8849 python app.py" % (port, port),
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
log("system", "控制台启动 -> %s%s" % (url, suffix))
|
log("system", "控制台启动 -> %s%s" % (url, suffix))
|
||||||
if AUTH_TOKEN:
|
if AUTH_TOKEN:
|
||||||
log("system", "IPv6 同步监听 http://[::1]:%d;远程访问 http://%s:%d" % (
|
log("system", "IPv6 同步监听 http://[::1]:%d;远程访问 http://%s:%d" % (port, _lan_ip() or "<服务器IP>", port))
|
||||||
port, _lan_ip() or "<服务器IP>", port))
|
|
||||||
elif host == "0.0.0.0":
|
elif host == "0.0.0.0":
|
||||||
log("system", "已监听 0.0.0.0 但没设 AUTH_TOKEN:同网段谁都能打开,远程部署请设置", level="warn")
|
log("system", "已监听 0.0.0.0 但没设 AUTH_TOKEN:同网段谁都能打开,远程部署请设置", level="warn")
|
||||||
|
|
||||||
srv4 = uvicorn.Server(uvicorn.Config(app, host=host, port=port,
|
srv4 = uvicorn.Server(uvicorn.Config(app, host=host, port=port, log_level="warning", access_log=False))
|
||||||
log_level="warning", access_log=False))
|
|
||||||
|
|
||||||
def _serve_v6():
|
def _serve_v6():
|
||||||
# Windows 上 localhost 优先解析到 ::1。只绑 127.0.0.1 时,
|
# Windows 上 localhost 优先解析到 ::1。只绑 127.0.0.1 时,
|
||||||
# 浏览器打开 http://localhost:8848 就是 ERR_CONNECTION_REFUSED。
|
# 浏览器打开 http://localhost:8848 就是 ERR_CONNECTION_REFUSED。
|
||||||
try:
|
try:
|
||||||
uvicorn.Server(uvicorn.Config(app, host="::1", port=port,
|
uvicorn.Server(uvicorn.Config(app, host="::1", port=port, log_level="warning", access_log=False)).run()
|
||||||
log_level="warning", access_log=False)).run()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""题库匹配 + 大模型兜底,与平台无关。
|
||||||
|
|
||||||
|
插件负责两头:把平台的题目转成统一格式、把答案填回平台;中间「这题选什么」由这里决定:
|
||||||
|
1. 题库命中(题干归一化后:精确 -> 互相包含 -> 相似度)就直接用;
|
||||||
|
2. 没命中的题交给设置里配置的大模型;没配大模型就留空,由插件决定怎么处理。
|
||||||
|
|
||||||
|
题目格式:{"no": 题号, "type": 题型名(单选/多选/判断),"title": 题干, "options": [{"key": "A", "text": "..."}]}
|
||||||
|
题库格式(插件目录里的 *.json,可放多个):[{"q": 题干, "a": "B"}],多选写 "ABC"。
|
||||||
|
返回:{题号: {"answer": ["A", "C"], "source": "bank" | "llm"}},没答出的题不在结果里。
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from study_tool import runtime
|
||||||
|
from study_tool.config.settings import load_settings
|
||||||
|
from study_tool.llm import llm_chat
|
||||||
|
|
||||||
|
BATCH = 5 # 大模型每批问几题,单批失败只影响这一批
|
||||||
|
FUZZY = 0.9 # 相似度命中的下限
|
||||||
|
MIN_CONTAIN = 8 # 互相包含匹配时,两边都至少要有这么多字,免得短题干乱命中
|
||||||
|
SYSTEM = (
|
||||||
|
"你是考试答题助手。根据题干和选项判断正确答案。\n"
|
||||||
|
"单选题返回一个选项字母;多选题返回全部正确的选项字母连写(如 AC);判断题返回 A(正确)或 B(错误)。\n"
|
||||||
|
'只输出 JSON:{"answers":{"题号":"答案"}},不要输出解释或多余文字。'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def norm(s):
|
||||||
|
"""去掉空白和中英文标点、统一大小写,题干才能跨页面格式比较。"""
|
||||||
|
return re.sub(r"[\W_]+", "", str(s or "")).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def letters(raw, options=None):
|
||||||
|
"""把 "A#C" / "AC" / ["A", "C"] 收敛成选项字母列表;有选项时只留合法字母,不是字母答案返回空。"""
|
||||||
|
keys = [o.get("key") for o in options or [] if o.get("key")]
|
||||||
|
if isinstance(raw, (list, tuple)):
|
||||||
|
raw = "".join(map(str, raw))
|
||||||
|
s = re.sub(r"[#,,、/\s]+", "", str(raw or "")).upper()
|
||||||
|
if not re.fullmatch(r"[A-F]+", s):
|
||||||
|
return []
|
||||||
|
return [c for c in dict.fromkeys(s) if not keys or c in keys]
|
||||||
|
|
||||||
|
|
||||||
|
class Bank:
|
||||||
|
def __init__(self, items):
|
||||||
|
self.items = [(norm(i["q"]), i["a"]) for i in items if i.get("q") and i.get("a")]
|
||||||
|
self.exact = dict(self.items)
|
||||||
|
|
||||||
|
def find(self, title):
|
||||||
|
"""返回题库里的答案原文,没命中返回空串。"""
|
||||||
|
t = norm(title)
|
||||||
|
if not t:
|
||||||
|
return ""
|
||||||
|
if t in self.exact:
|
||||||
|
return self.exact[t]
|
||||||
|
for q, a in self.items:
|
||||||
|
if min(len(q), len(t)) >= MIN_CONTAIN and (q in t or t in q):
|
||||||
|
return a
|
||||||
|
q, a = max(self.items, key=lambda x: difflib.SequenceMatcher(None, t, x[0]).ratio(), default=("", ""))
|
||||||
|
return a if q and difflib.SequenceMatcher(None, t, q).ratio() >= FUZZY else ""
|
||||||
|
|
||||||
|
|
||||||
|
def load(folder):
|
||||||
|
"""读目录下全部 *.json 合并成一个题库;目录不存在就是空题库。"""
|
||||||
|
items = []
|
||||||
|
for f in sorted(Path(folder).glob("*.json")):
|
||||||
|
items += json.loads(f.read_text(encoding="utf-8"))
|
||||||
|
return Bank(items)
|
||||||
|
|
||||||
|
|
||||||
|
def ask(part, cfg):
|
||||||
|
"""问大模型一批题,返回 {题号: 原始答案}。"""
|
||||||
|
lines = []
|
||||||
|
for q in part:
|
||||||
|
opts = ";".join("%s. %s" % (o.get("key"), o.get("text")) for o in q.get("options") or [])
|
||||||
|
lines.append("第%s题(%s)%s\n选项:%s" % (q.get("no"), q.get("type") or "单选", q.get("title") or "", opts or "无"))
|
||||||
|
content = llm_chat(
|
||||||
|
[{"role": "system", "content": cfg.get("prompt") or SYSTEM}, {"role": "user", "content": "\n\n".join(lines)}],
|
||||||
|
cfg,
|
||||||
|
max_tokens=1600,
|
||||||
|
)
|
||||||
|
m = re.search(r"\{.*\}", content.replace("```json", "").replace("```", ""), re.S)
|
||||||
|
data = json.loads(m.group(0)) if m else {}
|
||||||
|
data = data.get("answers") if isinstance(data.get("answers"), dict) else data
|
||||||
|
return {str(k): v for k, v in data.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def llm_fill(key, miss, out):
|
||||||
|
cfg = load_settings()["llm"]
|
||||||
|
if not (cfg.get("api_key") and cfg.get("base_url")):
|
||||||
|
runtime.log(key, "题库没命中 %d 题,且还没配置大模型(右上角「设置」),这些题留空" % len(miss), level="warn")
|
||||||
|
return
|
||||||
|
before = len(out)
|
||||||
|
for i in range(0, len(miss), BATCH):
|
||||||
|
part = miss[i : i + BATCH]
|
||||||
|
try:
|
||||||
|
got = ask(part, cfg)
|
||||||
|
except Exception as e:
|
||||||
|
runtime.log(key, "AI 答题第 %d 批失败:%s" % (i // BATCH + 1, str(e)[:160]), level="warn")
|
||||||
|
continue
|
||||||
|
for q in part:
|
||||||
|
a = letters(got.get(str(q.get("no"))), q.get("options"))
|
||||||
|
if a:
|
||||||
|
out[str(q.get("no"))] = {"answer": a, "source": "llm"}
|
||||||
|
runtime.log(key, "AI 补答 %d/%d 题(答案仅供参考,未核对)" % (len(out) - before, len(miss)))
|
||||||
|
|
||||||
|
|
||||||
|
def answer(key, questions, bank=None, use_llm=True):
|
||||||
|
"""题库优先,没命中的走大模型。日志统一走 runtime.log(key, ...)。"""
|
||||||
|
out, miss = {}, []
|
||||||
|
for q in questions:
|
||||||
|
a = letters(bank.find(q.get("title")), q.get("options")) if bank else []
|
||||||
|
if a:
|
||||||
|
out[str(q.get("no"))] = {"answer": a, "source": "bank"}
|
||||||
|
else:
|
||||||
|
miss.append(q)
|
||||||
|
runtime.log(key, "题库命中 %d/%d 题" % (len(out), len(questions)))
|
||||||
|
if miss and use_llm:
|
||||||
|
llm_fill(key, miss, out)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""全局路径常量:仓库根、data、static。
|
||||||
|
|
||||||
|
平台专属的地址 / 常量放在各自的插件目录(如 plugins/guanyixue/config.py)。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# 包内路径 -> 项目根目录:data / static / script 这些运行时资源都留在仓库根
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__)) # .../study_tool/config
|
||||||
|
PKG = os.path.dirname(HERE) # .../study_tool
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(PKG)) # 仓库根
|
||||||
|
DATA = os.path.join(ROOT, "data")
|
||||||
|
STATIC_DIR = os.path.join(ROOT, "static")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""系统设置(大模型 + 学习方式)读写。"""
|
||||||
|
from study_tool import database
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ settings
|
||||||
|
|
||||||
|
SETTINGS_NAME = "llm"
|
||||||
|
DEFAULT_LLM = {
|
||||||
|
"base_url": "https://api.openai.com/v1",
|
||||||
|
"api_key": "",
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"timeout": 120,
|
||||||
|
"prompt": "",
|
||||||
|
}
|
||||||
|
# 学习方式:fast = 只调 study/log 接口,不开浏览器;normal = 真播视频(可多路并发)
|
||||||
|
DEFAULT_STUDY = {
|
||||||
|
"mode": "fast",
|
||||||
|
"parallel": 2,
|
||||||
|
}
|
||||||
|
# 故障推送:渠道插件(plugins/*/plugin.json 里 kind=notify)读这段配置
|
||||||
|
DEFAULT_NOTIFY = {
|
||||||
|
"enabled": True,
|
||||||
|
"base_url": "https://corn.hanhan.ltd",
|
||||||
|
"app_key": "",
|
||||||
|
"app_secret": "",
|
||||||
|
"channel_name": "",
|
||||||
|
"on_error": True, # 运行 / 课程异常
|
||||||
|
"on_login": True, # 登录失败 / 会话失效且无法续期
|
||||||
|
"on_progress": True, # 学习进度异常
|
||||||
|
"on_retry": True, # 同一原因反复重试
|
||||||
|
"retry_threshold": 3, # 重试几次算故障
|
||||||
|
"cooldown_min": 10, # 同一账号同一类型的推送冷却
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings():
|
||||||
|
data = database.get_setting(SETTINGS_NAME) or {}
|
||||||
|
llm = dict(DEFAULT_LLM)
|
||||||
|
study = dict(DEFAULT_STUDY)
|
||||||
|
notify = dict(DEFAULT_NOTIFY)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
raw = data.get("llm")
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
llm.update({k: v for k, v in raw.items() if k in DEFAULT_LLM and v is not None})
|
||||||
|
st = data.get("study")
|
||||||
|
if isinstance(st, dict):
|
||||||
|
study.update({k: v for k, v in st.items() if v is not None})
|
||||||
|
nt = data.get("notify")
|
||||||
|
if isinstance(nt, dict):
|
||||||
|
notify.update({k: v for k, v in nt.items() if k in DEFAULT_NOTIFY and v is not None})
|
||||||
|
study["mode"] = study.get("mode") if study.get("mode") in ("fast", "normal") else "fast"
|
||||||
|
try:
|
||||||
|
study["parallel"] = max(1, min(int(study.get("parallel") or 2), 5))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
study["parallel"] = 2
|
||||||
|
notify["enabled"] = bool(notify.get("enabled"))
|
||||||
|
for flag in ("on_error", "on_login", "on_progress", "on_retry"):
|
||||||
|
notify[flag] = bool(notify.get(flag))
|
||||||
|
try:
|
||||||
|
notify["retry_threshold"] = max(2, min(int(notify.get("retry_threshold") or 3), 10))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
notify["retry_threshold"] = 3
|
||||||
|
try:
|
||||||
|
notify["cooldown_min"] = max(1, min(int(notify.get("cooldown_min") or 10), 1440))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
notify["cooldown_min"] = 10
|
||||||
|
return {"llm": llm, "study": study, "notify": notify}
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(data):
|
||||||
|
database.set_setting(SETTINGS_NAME, {"llm": data.get("llm") or {},
|
||||||
|
"study": data.get("study") or {},
|
||||||
|
"notify": data.get("notify") or {}})
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
"""SQLite 存储层(SQLAlchemy ORM)。
|
||||||
|
|
||||||
|
表
|
||||||
|
settings 系统配置(name -> JSON),目前只有 llm
|
||||||
|
kv 通用缓存(任意 key -> JSON),例如 StudyClient 试出来的接口前缀
|
||||||
|
accounts 账号 / 密码 / 会话 cookie / 课程缓存 / 勾选 / 开关
|
||||||
|
login_log 每次登录尝试(平台原话)
|
||||||
|
run_log 每个账号(key)的运行日志
|
||||||
|
run_state 运行状态快照,进程重启后 /api/runs 还能看到上次跑到哪
|
||||||
|
snapshots 课程页原始 HTML(站点改版时定位选择器用)
|
||||||
|
|
||||||
|
并发模型
|
||||||
|
* WAL + busy_timeout=15s:读写互不阻塞,写锁竞争自动重试
|
||||||
|
* scoped_session:每个线程一条 Session,ThreadPoolExecutor 里多线程写日志不会串
|
||||||
|
* 本进程内的写操作再用一把 RLock 串起来,避免多线程 commit 抖动(读不加锁)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, delete, event, func, select
|
||||||
|
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||||
|
|
||||||
|
from study_tool.config import DATA
|
||||||
|
from study_tool.database.models import (Account, Base, Kv, LoginLog, RunLog,
|
||||||
|
RunState, Setting, Snapshot)
|
||||||
|
|
||||||
|
DATA_DIR = os.environ.get("DATA_DIR") or DATA # 仓库根目录的 data/,路径只在 config 里算一次
|
||||||
|
DB_PATH = os.environ.get("DB_PATH") or os.path.join(DATA_DIR, "study.db")
|
||||||
|
|
||||||
|
# 每个账号保留的日志行数,超出就丢最旧的(远程长期挂机,不能无限涨)
|
||||||
|
RUN_LOG_KEEP = int(os.environ.get("RUN_LOG_KEEP", "2000") or 2000)
|
||||||
|
# 页面快照保留条数
|
||||||
|
SNAPSHOT_KEEP = int(os.environ.get("SNAPSHOT_KEEP", "200") or 200)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True)
|
||||||
|
|
||||||
|
engine = create_engine("sqlite:///" + os.path.abspath(DB_PATH),
|
||||||
|
connect_args={"check_same_thread": False, "timeout": 15})
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def _sqlite_pragmas(dbapi_conn, _record):
|
||||||
|
cur = dbapi_conn.cursor()
|
||||||
|
cur.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cur.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
cur.execute("PRAGMA busy_timeout=15000")
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
Session = scoped_session(sessionmaker(bind=engine, expire_on_commit=False))
|
||||||
|
_lock = threading.RLock() # 只保护“写”
|
||||||
|
_write_count = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 基础
|
||||||
|
|
||||||
|
def init():
|
||||||
|
"""建库建表(幂等,启动时调用一次)。"""
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def db_info():
|
||||||
|
try:
|
||||||
|
size = os.path.getsize(DB_PATH) if os.path.exists(DB_PATH) else 0
|
||||||
|
except OSError:
|
||||||
|
size = 0
|
||||||
|
models = {"accounts": Account, "run_log": RunLog, "login_log": LoginLog,
|
||||||
|
"snapshots": Snapshot, "run_state": RunState, "kv": Kv, "settings": Setting}
|
||||||
|
counts = {}
|
||||||
|
with Session() as s:
|
||||||
|
for name, model in models.items():
|
||||||
|
counts[name] = s.scalar(select(func.count()).select_from(model))
|
||||||
|
return {"path": DB_PATH, "size": size, "counts": counts}
|
||||||
|
|
||||||
|
|
||||||
|
init()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ settings
|
||||||
|
|
||||||
|
def get_setting(name, default=None):
|
||||||
|
with Session() as s:
|
||||||
|
row = s.get(Setting, name)
|
||||||
|
return row.value if row is not None else default
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(name, value):
|
||||||
|
with _lock, Session() as s:
|
||||||
|
row = s.get(Setting, name)
|
||||||
|
if row is None:
|
||||||
|
row = Setting(name=name)
|
||||||
|
s.add(row)
|
||||||
|
row.value = value
|
||||||
|
row.updated = time.time()
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ kv 缓存
|
||||||
|
|
||||||
|
def kv_get(key, default=None):
|
||||||
|
with Session() as s:
|
||||||
|
row = s.get(Kv, key)
|
||||||
|
return row.value if row is not None else default
|
||||||
|
|
||||||
|
|
||||||
|
def kv_set(key, value):
|
||||||
|
with _lock, Session() as s:
|
||||||
|
row = s.get(Kv, key)
|
||||||
|
if row is None:
|
||||||
|
row = Kv(key=key)
|
||||||
|
s.add(row)
|
||||||
|
row.value = value
|
||||||
|
row.updated = time.time()
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def kv_del(key):
|
||||||
|
with _lock, Session() as s:
|
||||||
|
s.execute(delete(Kv).where(Kv.key == key))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def kv_all():
|
||||||
|
with Session() as s:
|
||||||
|
return {row.key: row.value for row in s.scalars(select(Kv))}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ accounts
|
||||||
|
|
||||||
|
def _acc_row(row):
|
||||||
|
"""ORM 对象 -> 前端用的记录(不含密码)。"""
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"username": row.username or "",
|
||||||
|
"platform": row.platform or "",
|
||||||
|
"note": row.note or "",
|
||||||
|
"user": row.user or "",
|
||||||
|
"cookies": row.cookies or [],
|
||||||
|
"courses": row.courses or [],
|
||||||
|
"selected": row.selected or [],
|
||||||
|
"status": row.status or "idle",
|
||||||
|
"lastError": row.last_error or "",
|
||||||
|
"lastErrorAt": row.last_error_at or 0,
|
||||||
|
"optQuiz": bool(row.opt_quiz),
|
||||||
|
"optFeedback": bool(row.opt_feedback),
|
||||||
|
"loginAt": row.login_at or 0,
|
||||||
|
"created": row.created or 0,
|
||||||
|
"updated": row.updated or 0,
|
||||||
|
"hasPassword": bool(row.password),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def account_list():
|
||||||
|
with Session() as s:
|
||||||
|
rows = s.scalars(select(Account).order_by(Account.created, Account.id)).all()
|
||||||
|
return [_acc_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def account_get(account_id):
|
||||||
|
if not account_id:
|
||||||
|
return None
|
||||||
|
with Session() as s:
|
||||||
|
row = s.get(Account, str(account_id))
|
||||||
|
return _acc_row(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def account_password(account_id):
|
||||||
|
if not account_id:
|
||||||
|
return ""
|
||||||
|
with Session() as s:
|
||||||
|
row = s.get(Account, str(account_id))
|
||||||
|
return (row.password or "") if row is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
def account_exists(account_id):
|
||||||
|
if not account_id:
|
||||||
|
return False
|
||||||
|
with Session() as s:
|
||||||
|
return s.get(Account, str(account_id)) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def account_upsert(rec):
|
||||||
|
"""新增/更新账号。rec['password'] 为空(None)时保留库里原有密码。
|
||||||
|
|
||||||
|
明文密码要留在库里:会话失效时后台要能自己重登,不能等用户回来输密码。
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
aid = str(rec.get("id") or "").strip()
|
||||||
|
if not aid:
|
||||||
|
aid = "a%s%04d" % (int(now * 1000), int(time.time() * 10000) % 10000)
|
||||||
|
with _lock, Session() as s:
|
||||||
|
row = s.get(Account, aid)
|
||||||
|
if row is None:
|
||||||
|
row = Account(id=aid, created=float(rec.get("created") or now))
|
||||||
|
s.add(row)
|
||||||
|
row.username = rec.get("username") or ""
|
||||||
|
platform = (rec.get("platform") or "").strip()
|
||||||
|
if platform:
|
||||||
|
row.platform = platform
|
||||||
|
pwd = rec.get("password")
|
||||||
|
if pwd is not None:
|
||||||
|
row.password = str(pwd)
|
||||||
|
row.note = rec.get("note") or ""
|
||||||
|
row.user = rec.get("user") or ""
|
||||||
|
row.cookies = rec.get("cookies") or []
|
||||||
|
row.courses = rec.get("courses") or []
|
||||||
|
row.selected = rec.get("selected") or []
|
||||||
|
row.status = rec.get("status") or "idle"
|
||||||
|
row.last_error = rec.get("lastError") or ""
|
||||||
|
row.last_error_at = float(rec.get("lastErrorAt") or 0)
|
||||||
|
row.opt_quiz = 1 if rec.get("optQuiz") is True else 0
|
||||||
|
row.opt_feedback = 0 if rec.get("optFeedback") is False else 1
|
||||||
|
row.login_at = float(rec.get("loginAt") or 0)
|
||||||
|
row.created = float(rec.get("created") or row.created or now)
|
||||||
|
row.updated = now
|
||||||
|
s.commit()
|
||||||
|
return aid
|
||||||
|
|
||||||
|
|
||||||
|
def account_save_login(account_id, user, cookies, courses):
|
||||||
|
"""登录成功后把新的会话写回账号(服务端自己记,不依赖前端回传)。"""
|
||||||
|
rec = account_get(account_id)
|
||||||
|
if not rec:
|
||||||
|
return
|
||||||
|
courses = courses or []
|
||||||
|
plans = {c.get("plan_id") for c in courses}
|
||||||
|
rec.update({
|
||||||
|
"user": user or rec.get("user") or "",
|
||||||
|
"cookies": cookies or [],
|
||||||
|
"courses": courses,
|
||||||
|
"selected": [p for p in (rec.get("selected") or []) if p in plans],
|
||||||
|
"status": "logged_in",
|
||||||
|
"lastError": "",
|
||||||
|
"lastErrorAt": 0,
|
||||||
|
"loginAt": time.time(),
|
||||||
|
})
|
||||||
|
rec.pop("hasPassword", None)
|
||||||
|
account_upsert(rec)
|
||||||
|
|
||||||
|
|
||||||
|
def account_save_cookies(account_id, cookies):
|
||||||
|
"""只更新会话:学习中掉线重登后写回新会话,不动课程缓存和勾选。"""
|
||||||
|
rec = account_get(account_id)
|
||||||
|
if not rec:
|
||||||
|
return
|
||||||
|
rec.update({"cookies": cookies or [], "status": "logged_in", "lastError": "", "lastErrorAt": 0})
|
||||||
|
rec.pop("hasPassword", None)
|
||||||
|
account_upsert(rec)
|
||||||
|
|
||||||
|
|
||||||
|
def account_delete(account_id):
|
||||||
|
"""删账号,连同它的运行状态、课程日志、登录记录一起删。
|
||||||
|
|
||||||
|
登录日志的 key 和 login_log.username 记的是用户名(手机号),不是账号 id,
|
||||||
|
两边都要清,否则删完账号旧日志还留在页面上。
|
||||||
|
"""
|
||||||
|
aid = str(account_id or "")
|
||||||
|
acc = account_get(aid) or {}
|
||||||
|
uname = str(acc.get("username") or "")
|
||||||
|
with _lock, Session() as s:
|
||||||
|
s.execute(delete(Account).where(Account.id == aid))
|
||||||
|
s.execute(delete(RunState).where(RunState.key == aid))
|
||||||
|
for key in (aid, uname):
|
||||||
|
if key:
|
||||||
|
s.execute(delete(RunLog).where(RunLog.key == key))
|
||||||
|
s.execute(delete(LoginLog).where(LoginLog.username == key))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 登录记录
|
||||||
|
|
||||||
|
def login_log_add(username, ok, code, elapsed, stage="", error="", ts=None):
|
||||||
|
with _lock, Session() as s:
|
||||||
|
s.add(LoginLog(ts=ts or time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
username=username, ok=1 if ok else 0, code=str(code or ""),
|
||||||
|
elapsed=float(elapsed or 0), stage=str(stage or ""),
|
||||||
|
error=str(error or "").replace("\n", " ")[:500]))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def login_log_tail(n=40):
|
||||||
|
"""返回旧版文本行格式(ts\\t用户名\\tOK\\tcode\\t耗时\\tstage\\t错误),前端不用改。"""
|
||||||
|
n = max(1, min(int(n or 40), 500))
|
||||||
|
with Session() as s:
|
||||||
|
rows = s.scalars(select(LoginLog).order_by(LoginLog.id.desc()).limit(n)).all()
|
||||||
|
return ["%s\t%s\t%s\t%s\t%.1fs\t%s\t%s" % (
|
||||||
|
r.ts, r.username, "OK" if r.ok else "FAIL", r.code,
|
||||||
|
r.elapsed or 0, r.stage or "", r.error or "") for r in reversed(rows)]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 运行日志
|
||||||
|
|
||||||
|
def run_log_add(key, line, ts=None):
|
||||||
|
key = str(key or "")
|
||||||
|
with _lock, Session() as s:
|
||||||
|
s.add(RunLog(key=key, ts=ts or time.time(), line=line))
|
||||||
|
s.commit()
|
||||||
|
n = _write_count.get("run_log", 0) + 1
|
||||||
|
_write_count["run_log"] = n
|
||||||
|
if n % 200 == 0: # 每 200 行清一次,避免每行都跑一遍 DELETE
|
||||||
|
keep = (select(RunLog.id).where(RunLog.key == key)
|
||||||
|
.order_by(RunLog.id.desc()).limit(RUN_LOG_KEEP))
|
||||||
|
s.execute(delete(RunLog).where(RunLog.key == key, RunLog.id.not_in(keep)))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def run_log_tail(key, n=200):
|
||||||
|
n = max(1, min(int(n or 200), RUN_LOG_KEEP))
|
||||||
|
with Session() as s:
|
||||||
|
rows = s.scalars(select(RunLog.line).where(RunLog.key == str(key))
|
||||||
|
.order_by(RunLog.id.desc()).limit(n)).all()
|
||||||
|
return list(reversed(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def run_log_keys():
|
||||||
|
with Session() as s:
|
||||||
|
rows = s.execute(select(RunLog.key, func.count(RunLog.id), func.max(RunLog.ts))
|
||||||
|
.group_by(RunLog.key)).all()
|
||||||
|
return [{"key": key, "lines": count, "updated": updated} for key, count, updated in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def run_log_clear(key):
|
||||||
|
with _lock, Session() as s:
|
||||||
|
s.execute(delete(RunLog).where(RunLog.key == str(key)))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 运行状态
|
||||||
|
|
||||||
|
def run_state_set(key, state):
|
||||||
|
st = dict(state or {})
|
||||||
|
st.pop("log", None) # 日志单独进 run_log 表,不在这里重复一份
|
||||||
|
with _lock, Session() as s:
|
||||||
|
row = s.get(RunState, str(key))
|
||||||
|
if row is None:
|
||||||
|
row = RunState(key=str(key))
|
||||||
|
s.add(row)
|
||||||
|
row.state = st
|
||||||
|
row.updated = time.time()
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def run_state_all():
|
||||||
|
with Session() as s:
|
||||||
|
return {row.key: row.state for row in s.scalars(select(RunState))
|
||||||
|
if isinstance(row.state, dict)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_state_delete(key):
|
||||||
|
with _lock, Session() as s:
|
||||||
|
s.execute(delete(RunState).where(RunState.key == str(key or "")))
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 页面快照
|
||||||
|
|
||||||
|
def snapshot_add(plan_id, project_id, url, html):
|
||||||
|
with _lock, Session() as s:
|
||||||
|
row = Snapshot(plan_id=str(plan_id or ""), project_id=str(project_id or ""),
|
||||||
|
url=url or "", html=html or "", created=time.time())
|
||||||
|
s.add(row)
|
||||||
|
s.flush()
|
||||||
|
sid = row.id
|
||||||
|
keep = select(Snapshot.id).order_by(Snapshot.id.desc()).limit(SNAPSHOT_KEEP)
|
||||||
|
s.execute(delete(Snapshot).where(Snapshot.id.not_in(keep)))
|
||||||
|
s.commit()
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_get(sid):
|
||||||
|
with Session() as s:
|
||||||
|
row = s.get(Snapshot, int(sid))
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {c.name: getattr(row, c.name) for c in row.__table__.columns}
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_list(limit=50):
|
||||||
|
limit = max(1, min(int(limit or 50), 500))
|
||||||
|
with Session() as s:
|
||||||
|
rows = s.execute(select(Snapshot.id, Snapshot.plan_id, Snapshot.project_id,
|
||||||
|
Snapshot.url, Snapshot.created,
|
||||||
|
func.length(Snapshot.html).label("size"))
|
||||||
|
.order_by(Snapshot.id.desc()).limit(limit)).all()
|
||||||
|
return [{"id": r.id, "plan_id": r.plan_id, "project_id": r.project_id,
|
||||||
|
"url": r.url, "created": r.created, "size": r.size} for r in rows]
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""SQLAlchemy ORM 模型:账号 / 设置 / 登录记录 / 运行日志 / 运行状态 / 快照。"""
|
||||||
|
from sqlalchemy import Float, Index, Integer, String
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(Base):
|
||||||
|
"""系统配置(name -> JSON),目前只有 llm。"""
|
||||||
|
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
value: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
updated: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Kv(Base):
|
||||||
|
"""通用缓存(任意 key -> JSON),例如 StudyClient 试出来的接口前缀。"""
|
||||||
|
|
||||||
|
__tablename__ = "kv"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
value: Mapped[dict] = mapped_column(JSON)
|
||||||
|
updated: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Account(Base):
|
||||||
|
"""账号 / 密码 / 会话 cookie / 课程缓存 / 勾选 / 开关。"""
|
||||||
|
|
||||||
|
__tablename__ = "accounts"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String, default="")
|
||||||
|
platform: Mapped[str] = mapped_column(String, default="")
|
||||||
|
password: Mapped[str] = mapped_column(String, default="")
|
||||||
|
note: Mapped[str] = mapped_column(String, default="")
|
||||||
|
user: Mapped[str] = mapped_column(String, default="")
|
||||||
|
cookies: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
courses: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
selected: Mapped[list] = mapped_column(JSON, default=list)
|
||||||
|
status: Mapped[str] = mapped_column(String, default="idle")
|
||||||
|
last_error: Mapped[str] = mapped_column(String, default="")
|
||||||
|
last_error_at: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
opt_quiz: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
opt_feedback: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
login_at: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
created: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
updated: Mapped[float] = mapped_column(Float, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginLog(Base):
|
||||||
|
"""每次登录尝试(平台原话)。"""
|
||||||
|
|
||||||
|
__tablename__ = "login_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
ts: Mapped[str] = mapped_column(String)
|
||||||
|
username: Mapped[str] = mapped_column(String)
|
||||||
|
ok: Mapped[int] = mapped_column(Integer)
|
||||||
|
code: Mapped[str] = mapped_column(String)
|
||||||
|
elapsed: Mapped[float] = mapped_column(Float)
|
||||||
|
stage: Mapped[str] = mapped_column(String)
|
||||||
|
error: Mapped[str] = mapped_column(String)
|
||||||
|
|
||||||
|
|
||||||
|
class RunLog(Base):
|
||||||
|
"""每个账号(key)的运行日志。"""
|
||||||
|
|
||||||
|
__tablename__ = "run_log"
|
||||||
|
__table_args__ = (Index("idx_run_log_key", "key", "id"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
key: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
ts: Mapped[float] = mapped_column(Float)
|
||||||
|
line: Mapped[str] = mapped_column(String)
|
||||||
|
|
||||||
|
|
||||||
|
class RunState(Base):
|
||||||
|
"""运行状态快照,进程重启后 /api/runs 还能看到上次跑到哪。"""
|
||||||
|
|
||||||
|
__tablename__ = "run_state"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
state: Mapped[dict] = mapped_column(JSON)
|
||||||
|
updated: Mapped[float] = mapped_column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
class Snapshot(Base):
|
||||||
|
"""课程页原始 HTML(站点改版时定位选择器用)。"""
|
||||||
|
|
||||||
|
__tablename__ = "snapshots"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
plan_id: Mapped[str] = mapped_column(String)
|
||||||
|
project_id: Mapped[str] = mapped_column(String)
|
||||||
|
url: Mapped[str] = mapped_column(String)
|
||||||
|
html: Mapped[str] = mapped_column(String)
|
||||||
|
created: Mapped[float] = mapped_column(Float)
|
||||||
+146
-647
@@ -1,124 +1,44 @@
|
|||||||
"""学习引擎:运行状态、课程 worker、视频播放、反馈 / 测验提交。
|
"""学习引擎:账号级编排(课程循环、会话续期、按平台分发)。
|
||||||
|
|
||||||
|
平台自己的学习流程在 plugins/ 里(各平台自己实现),引擎不认识具体站点。
|
||||||
日志统一走 runtime.log()。
|
日志统一走 runtime.log()。
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
||||||
|
|
||||||
|
from study_tool import database, notify
|
||||||
from study_tool import store
|
|
||||||
from study_tool.config import *
|
|
||||||
from study_tool.platforms import registry
|
from study_tool.platforms import registry
|
||||||
from study_tool.platforms.jspx import *
|
from study_tool.platforms.errors import SessionExpired
|
||||||
from study_tool.platforms.study import *
|
|
||||||
from study_tool.runtime import RUNS, RUNS_LOCK, log, set_state
|
from study_tool.runtime import RUNS, RUNS_LOCK, log, set_state
|
||||||
|
|
||||||
# ------------------------------------------------------------------ study engine
|
# ------------------------------------------------------------------ study engine
|
||||||
|
|
||||||
|
|
||||||
|
def _snap(key):
|
||||||
class CwItem:
|
"""当前运行状态里的进度快照:学完一门课后拿它和开课前对比。"""
|
||||||
def __init__(self, d, cookies):
|
st = RUNS.get(key) or {}
|
||||||
self.title = d["title"]
|
return {
|
||||||
href = d["href"]
|
"done": st.get("done"),
|
||||||
self.url = href if href.startswith("http") else JSPX + href
|
"total": st.get("total"),
|
||||||
self.s = new_session(cookies)
|
"progress": st.get("progress"),
|
||||||
self.rec = None
|
"course": st.get("course"),
|
||||||
self.status = "done" if d.get("done") else "pending"
|
}
|
||||||
self.last = None
|
|
||||||
|
|
||||||
def resolve(self):
|
|
||||||
try:
|
|
||||||
r = self.s.get(self.url, timeout=40)
|
|
||||||
except Exception as e:
|
|
||||||
return "exc:%s" % e
|
|
||||||
m = re.search(r"'id':'([0-9A-F]{32})'", r.text)
|
|
||||||
if not m:
|
|
||||||
return "no-id"
|
|
||||||
self.rec = m.group(1)
|
|
||||||
return "ok"
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
try:
|
|
||||||
r = self.s.post(EP_STUDY, data={"id": self.rec, "order": "server_start",
|
|
||||||
"updatePassPerc": "true"}, timeout=40)
|
|
||||||
return r.text.strip()[:40]
|
|
||||||
except Exception as e:
|
|
||||||
return "EXC:%s" % e
|
|
||||||
|
|
||||||
def beat(self):
|
|
||||||
try:
|
|
||||||
r = self.s.post(EP_STUDY, data={"id": self.rec, "order": "server_time",
|
|
||||||
"updatePassPerc": "true",
|
|
||||||
"videoSchedule": 30}, timeout=40)
|
|
||||||
return r.text.strip()[:60]
|
|
||||||
except Exception as e:
|
|
||||||
return "EXC:%s" % e
|
|
||||||
|
|
||||||
|
|
||||||
def submit_feedback(s, href, key):
|
def study_worker(
|
||||||
url = href if href.startswith("http") else JSPX + href
|
key,
|
||||||
r = s.get(url, timeout=40)
|
username,
|
||||||
soup = BeautifulSoup(r.text, "lxml")
|
cookies,
|
||||||
fields = {i.get("name"): i.get("value") for i in soup.select("form input[type=hidden]") if i.get("name")}
|
courses,
|
||||||
if fields.get("allowStudy") == "false":
|
workers,
|
||||||
log(key, "反馈表:allowStudy=false,跳过")
|
cap,
|
||||||
return
|
quiz_key,
|
||||||
r2 = s.post(JSPX + "/train/trainee/doFeedbackList.action", data=fields, timeout=40)
|
enable_quiz=False,
|
||||||
qs = BeautifulSoup(r2.text, "lxml")
|
enable_feedback=True,
|
||||||
answers = []
|
password="",
|
||||||
for it in qs.select("div.job-item"):
|
mode="fast",
|
||||||
qtype = it.get("questype")
|
parallel=2,
|
||||||
opts = [sp.get("data-name") for sp in it.select(".job-item-a span")]
|
):
|
||||||
ans = ("课程内容充实,讲师讲解清晰,对我的教学实践很有帮助,希望今后能增加更多一线教学案例。"
|
|
||||||
if qtype == "04" else (opts[0] if opts else "A"))
|
|
||||||
answers.append({"id": it.get("traineeid"), "quesId": it.get("qumid"),
|
|
||||||
"quesType": qtype, "quesNo": it.get("quesno"), "answer": ans})
|
|
||||||
payload = {"planId": fields.get("planId"), "objectId": fields.get("objectId"),
|
|
||||||
"answers": json.dumps(answers, ensure_ascii=False, separators=(",", ":")),
|
|
||||||
"submitStatus": "finish"}
|
|
||||||
rr = s.post(JSPX + "/train/trainee/saveFeedback.action", data=payload, timeout=40)
|
|
||||||
log(key, "反馈表:%d 题,%s" % (len(answers), "提交成功" if "MSG" not in rr.text else "返回 " + rr.text[:80]))
|
|
||||||
|
|
||||||
|
|
||||||
def submit_quiz(s, href, key, answer_key=None):
|
|
||||||
url = href if href.startswith("http") else JSPX + href
|
|
||||||
plan, _ = parse_ids(url)
|
|
||||||
m = re.search(r"objectId=([0-9A-Fa-f]+)", url)
|
|
||||||
obj = m.group(1) if m else ""
|
|
||||||
rr = s.get("%s/train/trainee/showTestQums.action?planId=%s&objectId=%s" % (JSPX, plan, obj), timeout=40)
|
|
||||||
qs = BeautifulSoup(rr.text, "lxml")
|
|
||||||
singles = (answer_key or {}).get("singles", {})
|
|
||||||
multis = (answer_key or {}).get("multis", {})
|
|
||||||
answers = []
|
|
||||||
for it in qs.select("div.job-item"):
|
|
||||||
t, qno = it.get("questype"), it.get("quesno")
|
|
||||||
opts = [sp.get("data-name") for sp in it.select(".job-item-a span")]
|
|
||||||
if t == "02":
|
|
||||||
ans = (multis.get(qno) or ("#".join(opts) if opts else "A"))
|
|
||||||
else:
|
|
||||||
ans = singles.get(qno) or (opts[0] if opts else "A")
|
|
||||||
answers.append({"id": it.get("traineeid"), "quesId": it.get("qumid"),
|
|
||||||
"quesType": t, "quesNo": qno, "answer": ans})
|
|
||||||
if not answers:
|
|
||||||
log(key, "测验:未取到题目,跳过")
|
|
||||||
return
|
|
||||||
payload = {"planId": plan, "objectId": obj,
|
|
||||||
"answers": json.dumps(answers, ensure_ascii=False, separators=(",", ":"))}
|
|
||||||
r3 = s.post(JSPX + "/train/trainee/endTest.action", data=payload, timeout=40)
|
|
||||||
r4 = s.get("%s/train/trainee/doTest.action?planId=%s&objectId=%s" % (JSPX, plan, obj), timeout=40)
|
|
||||||
sc = re.search(r"得分:\s*(\d+)\s*分",
|
|
||||||
re.sub(r"\s+", " ", BeautifulSoup(r4.text, "lxml").get_text(" ", strip=True)))
|
|
||||||
log(key, "测验:%d 题,%s,得分 %s" %
|
|
||||||
(len(answers), "提交成功" if "MSG" not in r3.text else r3.text[:60],
|
|
||||||
sc.group(1) if sc else "?"))
|
|
||||||
|
|
||||||
|
|
||||||
def study_worker(key, username, cookies, courses, workers, cap, quiz_key,
|
|
||||||
enable_quiz=False, enable_feedback=True, password="",
|
|
||||||
mode="fast", parallel=2):
|
|
||||||
"""Run each selected course in sequence for one account.
|
"""Run each selected course in sequence for one account.
|
||||||
|
|
||||||
enable_quiz 默认 False —— 测验答题正确率无法保证,可能因不及格卡住「完成」,故默认跳过。
|
enable_quiz 默认 False —— 测验答题正确率无法保证,可能因不及格卡住「完成」,故默认跳过。
|
||||||
@@ -127,25 +47,57 @@ def study_worker(key, username, cookies, courses, workers, cap, quiz_key,
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
set_state(key, status="discovering", log=[], done=0, total=0, started=time.time())
|
set_state(key, status="discovering", log=[], done=0, total=0, started=time.time())
|
||||||
|
notify.reset(key) # 新一轮:重试计数清零
|
||||||
failed = []
|
failed = []
|
||||||
log(key, "=== 开始:%s(共 %d 门课程)" % (username, len(courses)))
|
log(key, "=== 开始:%s(共 %d 门课程)" % (username, len(courses)))
|
||||||
log(key, "设置:自动交测验=%s,自动交反馈表=%s,学习方式=%s" %
|
log(
|
||||||
("开" if enable_quiz else "关", "开" if enable_feedback else "关",
|
key,
|
||||||
"快速(只调接口)" if mode != "normal" else "常规(真播,%d 路并发)" % parallel))
|
"设置:自动交测验=%s,自动交反馈表=%s,学习方式=%s"
|
||||||
|
% (
|
||||||
|
"开" if enable_quiz else "关",
|
||||||
|
"开" if enable_feedback else "关",
|
||||||
|
"快速(只调接口)" if mode != "normal" else "常规(真播,%d 路并发)" % parallel,
|
||||||
|
),
|
||||||
|
)
|
||||||
for ci, c in enumerate(courses, 1):
|
for ci, c in enumerate(courses, 1):
|
||||||
with RUNS_LOCK:
|
with RUNS_LOCK:
|
||||||
if (RUNS.get(key) or {}).get("status") == "stopped":
|
if (RUNS.get(key) or {}).get("status") == "stopped":
|
||||||
log(key, "已按要求停止")
|
log(key, "已按要求停止")
|
||||||
return
|
return
|
||||||
log(key, "--- [%d/%d] %s" % (ci, len(courses), c.get("name") or c["plan_id"]))
|
log(key, "--- [%d/%d] %s" % (ci, len(courses), c.get("name") or c["plan_id"]))
|
||||||
set_state(key, course=c.get("name", ""))
|
# 每门课重新计数:上一门课的 done / total / progress 不能带进来做对比
|
||||||
|
set_state(key, course=c.get("name", ""), done=0, total=0, progress="")
|
||||||
|
name = c.get("name") or ""
|
||||||
|
before = _snap(key)
|
||||||
try:
|
try:
|
||||||
run_one_course(key, username, cookies, c["plan_id"], c["project_id"], workers, cap,
|
run_one_course(
|
||||||
quiz_key, enable_quiz, enable_feedback, c.get("name") or "", password,
|
key,
|
||||||
mode, parallel)
|
username,
|
||||||
|
cookies,
|
||||||
|
c["plan_id"],
|
||||||
|
c["project_id"],
|
||||||
|
workers,
|
||||||
|
cap,
|
||||||
|
quiz_key,
|
||||||
|
enable_quiz,
|
||||||
|
enable_feedback,
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
mode,
|
||||||
|
parallel,
|
||||||
|
)
|
||||||
|
# 学完再体检一次:学完仍未完成 / 进度没推进都算故障
|
||||||
|
notify.check_progress(key, name, before, _snap(key))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed.append(c.get("name") or c["plan_id"])
|
failed.append(c.get("name") or c["plan_id"])
|
||||||
log(key, "课程处理异常:%s" % e)
|
log(key, "课程处理异常:%s" % e)
|
||||||
|
notify.fault(
|
||||||
|
"course",
|
||||||
|
key,
|
||||||
|
"课程处理异常",
|
||||||
|
"课程:%s\n异常:%s" % (name or c["plan_id"], str(e)[:300]),
|
||||||
|
{"course": name},
|
||||||
|
)
|
||||||
set_state(key, status="finished", finished_at=time.time())
|
set_state(key, status="finished", finished_at=time.time())
|
||||||
if failed:
|
if failed:
|
||||||
log(key, "=== 结束:%d/%d 门课程异常(%s)" % (len(failed), len(courses), "、".join(failed)[:120]))
|
log(key, "=== 结束:%d/%d 门课程异常(%s)" % (len(failed), len(courses), "、".join(failed)[:120]))
|
||||||
@@ -154,22 +106,39 @@ def study_worker(key, username, cookies, courses, workers, cap, quiz_key,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(key, "!! 运行异常:%s" % e)
|
log(key, "!! 运行异常:%s" % e)
|
||||||
set_state(key, status="error", error=str(e)[:300])
|
set_state(key, status="error", error=str(e)[:300])
|
||||||
|
notify.fault("run", key, "运行异常", str(e)[:300])
|
||||||
|
|
||||||
|
|
||||||
def run_one_course(key, username, cookies, plan, project, workers, cap, quiz_key,
|
def run_one_course(
|
||||||
enable_quiz=False, enable_feedback=True, name="", password="",
|
key,
|
||||||
mode="fast", parallel=2):
|
username,
|
||||||
|
cookies,
|
||||||
|
plan,
|
||||||
|
project,
|
||||||
|
workers,
|
||||||
|
cap,
|
||||||
|
quiz_key,
|
||||||
|
enable_quiz=False,
|
||||||
|
enable_feedback=True,
|
||||||
|
name="",
|
||||||
|
password="",
|
||||||
|
mode="fast",
|
||||||
|
parallel=2,
|
||||||
|
):
|
||||||
# 账号添加时选的平台决定登录 / 发现走哪套实现;新增平台不用改这里
|
# 账号添加时选的平台决定登录 / 发现走哪套实现;新增平台不用改这里
|
||||||
account = store.account_get(key) or {}
|
account = database.account_get(key) or {}
|
||||||
adapter = registry.get(account.get("platform"))
|
adapter = registry.get(account.get("platform"))
|
||||||
if adapter is None or adapter.discover is None:
|
if adapter is None or adapter.discover is None:
|
||||||
raise RuntimeError("平台 %s 没有可用的课程发现实现" % (account.get("platform") or "?"))
|
raise RuntimeError("平台 %s 没有可用的课程发现实现" % (account.get("platform") or "?"))
|
||||||
s = (adapter.session or new_session)(cookies)
|
if adapter.session is None:
|
||||||
|
raise RuntimeError("平台 %s 没有实现 session" % (account.get("platform") or "?"))
|
||||||
|
s = adapter.session(cookies)
|
||||||
try:
|
try:
|
||||||
d = adapter.discover(s, plan, project, name)
|
d = adapter.discover(s, plan, project, name)
|
||||||
except SessionExpired:
|
except SessionExpired:
|
||||||
# 先试无密码续期(CASTGC 通常还有效),不行再用浏览器里存的账号密码重登
|
# 先试无密码续期(CASTGC 通常还有效),不行再用浏览器里存的账号密码重登
|
||||||
log(key, "会话失效,尝试无密码续期…")
|
log(key, "会话失效,尝试无密码续期…")
|
||||||
|
notify.retry(key, "会话续期", "会话失效后自动续期 / 重登")
|
||||||
renewed = adapter.renew_session(s) if adapter.renew_session else False
|
renewed = adapter.renew_session(s) if adapter.renew_session else False
|
||||||
d = None
|
d = None
|
||||||
if renewed:
|
if renewed:
|
||||||
@@ -179,546 +148,76 @@ def run_one_course(key, username, cookies, plan, project, workers, cap, quiz_key
|
|||||||
d = None
|
d = None
|
||||||
if d is None:
|
if d is None:
|
||||||
if not password or adapter.login is None:
|
if not password or adapter.login is None:
|
||||||
|
notify.fault(
|
||||||
|
"login",
|
||||||
|
key,
|
||||||
|
"会话失效且无法续期",
|
||||||
|
"课程:%s\n账号没有保存密码或平台不支持密码登录,需要手动重新登录。" % (name or plan),
|
||||||
|
{"course": name},
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
log(key, "续期失败,用账号密码重新登录…")
|
log(key, "续期失败,用账号密码重新登录…")
|
||||||
|
try:
|
||||||
s, _ = adapter.login(username, password)
|
s, _ = adapter.login(username, password)
|
||||||
d = adapter.discover(s, plan, project, name)
|
d = adapter.discover(s, plan, project, name)
|
||||||
|
except Exception as e:
|
||||||
|
notify.fault(
|
||||||
|
"login",
|
||||||
|
key,
|
||||||
|
"重新登录失败",
|
||||||
|
"课程:%s\n异常:%s" % (name or plan, str(e)[:300]),
|
||||||
|
{"course": name},
|
||||||
|
)
|
||||||
|
raise
|
||||||
# 新平台可能刚在 discover 里换到 study.dgjy.net 的会话(Middle-Token),存回去给后续课程用
|
# 新平台可能刚在 discover 里换到 study.dgjy.net 的会话(Middle-Token),存回去给后续课程用
|
||||||
set_state(key, course=d["course"], progress=d["progress"], user=d["user"],
|
set_state(
|
||||||
cookies=(adapter.dump_cookies or dump_cookies)(s))
|
key,
|
||||||
|
course=d["course"],
|
||||||
|
progress=d["progress"],
|
||||||
|
user=d["user"],
|
||||||
|
cookies=(adapter.dump_cookies(s) if adapter.dump_cookies else []),
|
||||||
|
)
|
||||||
log(key, "课程:%s | 进度 %s | %s" % (d["course"], d["progress"], d["info"]))
|
log(key, "课程:%s | 进度 %s | %s" % (d["course"], d["progress"], d["info"]))
|
||||||
pf = registry.get(d.get("platform"))
|
pf = registry.get(d.get("platform"))
|
||||||
gen = d.get("generation")
|
gen = d.get("generation")
|
||||||
gen_label = {"legacy": "旧版页面", "spa": "新版页面"}.get(gen, "")
|
gen_label = {"legacy": "旧版页面", "spa": "新版页面"}.get(gen, "")
|
||||||
log(key, "平台:%s%s | 活动:课件 %d、反馈表 %d、测验 %d" %
|
log(
|
||||||
((pf.label if pf else d.get("platform", "?")),
|
key,
|
||||||
|
"平台:%s%s | 活动:课件 %d、反馈表 %d、测验 %d"
|
||||||
|
% (
|
||||||
|
(pf.label if pf else d.get("platform", "?")),
|
||||||
(" · " + gen_label) if gen_label else "",
|
(" · " + gen_label) if gen_label else "",
|
||||||
len(d["courseware"]), len(d["feedback"]), len(d["test"])))
|
len(d["courseware"]),
|
||||||
if gen == "spa":
|
len(d["feedback"]),
|
||||||
log(key, "新版课程 ID:%s%s" %
|
len(d["test"]),
|
||||||
(d.get("train_id") or "?",
|
),
|
||||||
(" | 页面:" + (d.get("page_url") or "")[:110]) if d.get("page_url") else ""))
|
)
|
||||||
|
|
||||||
if not d["courseware"] and not d["feedback"] and not d["test"]:
|
if not d["courseware"] and not d["feedback"] and not d["test"]:
|
||||||
|
notify.fault(
|
||||||
|
"progress",
|
||||||
|
key,
|
||||||
|
"课程没有可识别的活动",
|
||||||
|
"课程:%s\n课件 / 反馈表 / 测验都是 0,多半是页面改版或课程没分配到位。" % (d["course"] or name),
|
||||||
|
{"course": d["course"] or name},
|
||||||
|
)
|
||||||
raise RuntimeError("课程里没有任何可识别的活动(课件/反馈表/测验都是 0)")
|
raise RuntimeError("课程里没有任何可识别的活动(课件/反馈表/测验都是 0)")
|
||||||
|
|
||||||
# 平台插件:按 course["platform"] 取实现;新增平台不用改这里
|
# 平台插件:按 course["platform"] 取实现;新增平台不用改这里
|
||||||
if pf is None or pf.study is None:
|
if pf is None or pf.study is None:
|
||||||
raise RuntimeError("平台 %s 没有可用的学习实现" % d.get("platform"))
|
raise RuntimeError("平台 %s 没有可用的学习实现" % d.get("platform"))
|
||||||
pf.study(key, s, d, workers=workers, cap=cap, enable_quiz=enable_quiz,
|
pf.study(
|
||||||
enable_feedback=enable_feedback, mode=mode, parallel=parallel,
|
key,
|
||||||
quiz_key=quiz_key, cookies=cookies, plan=plan, project=project, name=name)
|
s,
|
||||||
|
d,
|
||||||
|
workers=workers,
|
||||||
def _study_builtin(key, s, d, workers=1, cap=30, enable_quiz=False, enable_feedback=False,
|
cap=cap,
|
||||||
mode="fast", parallel=1, quiz_key="", cookies=None, plan="", project="",
|
enable_quiz=enable_quiz,
|
||||||
name=""):
|
enable_feedback=enable_feedback,
|
||||||
"""旧版 jspx 的内置流程:反馈表 + 测验 + 刷课件。"""
|
mode=mode,
|
||||||
if enable_feedback:
|
parallel=parallel,
|
||||||
for fb in d["feedback"]:
|
quiz_key=quiz_key,
|
||||||
if fb["done"]:
|
cookies=cookies,
|
||||||
log(key, "反馈表已完成,跳过")
|
plan=plan,
|
||||||
continue
|
project=project,
|
||||||
try:
|
name=name,
|
||||||
submit_feedback(s, fb["href"], key)
|
)
|
||||||
except Exception as e:
|
|
||||||
log(key, "反馈表异常:%s" % e)
|
|
||||||
else:
|
|
||||||
for fb in d["feedback"]:
|
|
||||||
log(key, "反馈表:已按设置跳过(%s)" % ("已完成" if fb["done"] else "未完成"))
|
|
||||||
|
|
||||||
if enable_quiz:
|
|
||||||
for t in d["test"]:
|
|
||||||
if t["done"]:
|
|
||||||
log(key, "测验已完成,跳过")
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
submit_quiz(s, t["href"], key, quiz_key)
|
|
||||||
except Exception as e:
|
|
||||||
log(key, "测验异常:%s" % e)
|
|
||||||
else:
|
|
||||||
for t in d["test"]:
|
|
||||||
if t["done"]:
|
|
||||||
log(key, "测验:已完成,跳过")
|
|
||||||
else:
|
|
||||||
log(key, "测验:⚠ 已按设置跳过答题(避免因正确率不足卡住「完成」),"
|
|
||||||
"需要时请在页面上手动作答,或打开「自动交测验」开关")
|
|
||||||
|
|
||||||
objs = [CwItem(c, cookies) for c in d["courseware"]]
|
|
||||||
todo = [o for o in objs if o.status != "done"]
|
|
||||||
done0 = len(objs) - len(todo)
|
|
||||||
set_state(key, status="studying", total=len(objs), done=done0)
|
|
||||||
if todo:
|
|
||||||
log(key, "解析 %d 个学习记录…" % len(todo))
|
|
||||||
with ThreadPoolExecutor(max_workers=min(workers, len(todo))) as ex:
|
|
||||||
list(ex.map(lambda x: x.resolve(), todo))
|
|
||||||
live = [o for o in todo if o.rec]
|
|
||||||
log(key, "已解析 %d/%d,并发启动会话" % (len(live), len(todo)))
|
|
||||||
with ThreadPoolExecutor(max_workers=min(workers, len(live))) as ex:
|
|
||||||
list(ex.map(lambda x: x.start(), live))
|
|
||||||
|
|
||||||
t0 = time.time()
|
|
||||||
while time.time() - t0 < cap * 60:
|
|
||||||
with RUNS_LOCK:
|
|
||||||
if (RUNS.get(key) or {}).get("status") == "stopped":
|
|
||||||
log(key, "已按要求停止")
|
|
||||||
return
|
|
||||||
pending = [o for o in live if o.status != "done"]
|
|
||||||
if not pending:
|
|
||||||
break
|
|
||||||
with ThreadPoolExecutor(max_workers=min(workers, len(pending))) as ex:
|
|
||||||
resps = list(ex.map(lambda x: x.beat(), pending))
|
|
||||||
newly = []
|
|
||||||
for o, resp in zip(pending, resps):
|
|
||||||
o.last = resp
|
|
||||||
if resp == "study_over":
|
|
||||||
o.status = "done"
|
|
||||||
newly.append(o)
|
|
||||||
el = int(time.time() - t0)
|
|
||||||
dc = len([o for o in live if o.status == "done"]) + done0
|
|
||||||
set_state(key, done=dc, elapsed=el)
|
|
||||||
log(key, "第 %d 轮 %dm%02ds 完成 %d/%d" % (el // 60 + 1, el // 60, el % 60, dc, len(objs)))
|
|
||||||
for o in newly:
|
|
||||||
log(key, " ✓ %s" % o.title[:40])
|
|
||||||
time.sleep(60)
|
|
||||||
else:
|
|
||||||
log(key, "所有课件均已完成")
|
|
||||||
|
|
||||||
try:
|
|
||||||
d2 = discover(s, plan, project, name)
|
|
||||||
set_state(key, progress=d2["progress"], info=d2["info"], cookies=dump_cookies(s))
|
|
||||||
log(key, "课程结束:%s | %s" % (d2["progress"], d2["info"]))
|
|
||||||
except Exception as e:
|
|
||||||
log(key, "复查失败:%s" % e)
|
|
||||||
|
|
||||||
|
|
||||||
def run_study_course(key, s, d, workers, cap, enable_quiz, enable_feedback,
|
|
||||||
mode="fast", parallel=2):
|
|
||||||
"""新版平台(study.dgjy.net / Vue SPA)课程:视频走 study/log 接口,
|
|
||||||
反馈表走 evaluation/result,测验保持手动作答。
|
|
||||||
|
|
||||||
mode=fast 只调接口上报(直链 mp4 仍退回真播),速度最快
|
|
||||||
mode=normal 开无头浏览器真播视频,parallel 控制同时播几个
|
|
||||||
"""
|
|
||||||
cookies = dump_cookies(s)
|
|
||||||
page_url = d.get("page_url") or ""
|
|
||||||
train_id = d.get("train_id") or ""
|
|
||||||
client = StudyClient(s, page_url)
|
|
||||||
|
|
||||||
# ---- 先确认已加入培训 ----
|
|
||||||
# 没加入时 study/log 仍然回 200,但服务端不记账:逐个课时重试只会刷一屏「上报未生效」。
|
|
||||||
if d.get("need_join"):
|
|
||||||
joined, why = ensure_study_join(s, client, d)
|
|
||||||
if joined:
|
|
||||||
log(key, "加入培训:%s" % why)
|
|
||||||
d["is_join"] = True
|
|
||||||
else:
|
|
||||||
log(key, "加入培训被拒:%s" % (why or "未知原因"))
|
|
||||||
probe = next((c for c in d["courseware"] if not c["done"]), None)
|
|
||||||
if probe:
|
|
||||||
good, last = study_complete_period(cookies, page_url, probe)
|
|
||||||
log(key, "试报 1 个课时:%s" % ("生效" if good else "未生效(%s)" % last))
|
|
||||||
if not good:
|
|
||||||
log(key, "该账号对这门课没有写入权限,本次跳过。"
|
|
||||||
"需要课程管理员把这门课分配到账号下(jspx 在学 ≠ 新版平台已加入)")
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
# ---- 反馈表 ----
|
|
||||||
if enable_feedback:
|
|
||||||
for fb in d["feedback"]:
|
|
||||||
if fb["done"]:
|
|
||||||
log(key, "反馈表已完成,跳过")
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
study_submit_feedback(client, fb, key, train_id)
|
|
||||||
except Exception as e:
|
|
||||||
log(key, "反馈表异常:%s" % e)
|
|
||||||
else:
|
|
||||||
for fb in d["feedback"]:
|
|
||||||
log(key, "反馈表:已按设置跳过(%s)" % ("已完成" if fb["done"] else "未完成"))
|
|
||||||
|
|
||||||
# ---- 测验 ----
|
|
||||||
for t in d["test"]:
|
|
||||||
score, passed = t.get("score"), t.get("passed_score")
|
|
||||||
if t["done"]:
|
|
||||||
if score is not None and passed is not None:
|
|
||||||
try:
|
|
||||||
if float(score) < float(passed):
|
|
||||||
log(key, "测验:已作答但不及格(%s/%s),需要在页面上重考" % (score, passed))
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
log(key, "测验:已完成,跳过")
|
|
||||||
elif enable_quiz:
|
|
||||||
log(key, "测验:新版平台不代答(%s 需在页面上手动作答)" % t["title"][:30])
|
|
||||||
else:
|
|
||||||
log(key, "测验:⚠ 已按设置跳过(%s),需要时请在页面上手动作答" % t["title"][:30])
|
|
||||||
|
|
||||||
# ---- 视频课时 ----
|
|
||||||
objs = d["courseware"]
|
|
||||||
todo = [c for c in objs if not c["done"]]
|
|
||||||
done0 = len(objs) - len(todo)
|
|
||||||
set_state(key, status="studying", total=len(objs), done=done0)
|
|
||||||
if not objs:
|
|
||||||
log(key, "没有视频课时")
|
|
||||||
return
|
|
||||||
|
|
||||||
kinds = {}
|
|
||||||
for c in objs:
|
|
||||||
kinds[c.get("kind") or "?"] = kinds.get(c.get("kind") or "?", 0) + 1
|
|
||||||
if kinds:
|
|
||||||
log(key, "视频类型:%s" % "、".join("%s %d 个" % (k, v) for k, v in kinds.items()))
|
|
||||||
|
|
||||||
if not todo:
|
|
||||||
log(key, "所有课件均已完成")
|
|
||||||
all_done = True
|
|
||||||
elif mode == "normal":
|
|
||||||
got = study_videos_normal(key, cookies, page_url, train_id, todo, parallel, cap, done0)
|
|
||||||
all_done = got >= len(objs)
|
|
||||||
set_state(key, done=got)
|
|
||||||
log(key, "视频课时:%d/%d 已完成" % (got, len(objs)))
|
|
||||||
else:
|
|
||||||
got = study_videos_fast(key, cookies, page_url, train_id, todo, workers, cap, done0)
|
|
||||||
all_done = got >= len(objs)
|
|
||||||
set_state(key, done=got)
|
|
||||||
log(key, "视频课时:%d/%d 已完成" % (got, len(objs)))
|
|
||||||
|
|
||||||
# ---- 课程容器(periodType 10/13):子课时学完不会自动完成 ----
|
|
||||||
if all_done and d.get("containers"):
|
|
||||||
for ct in d["containers"]:
|
|
||||||
if ct.get("done"):
|
|
||||||
continue
|
|
||||||
try: # 先重算子课程计数:并发上报会丢增量,计数不到容器就不完成
|
|
||||||
study_cal_final_score(client, ct["kcdm"])
|
|
||||||
log(key, " 已重算子课程完成计数(kcdm=%s)" % ct["kcdm"][:8])
|
|
||||||
except Exception as e:
|
|
||||||
log(key, " 重算子课程完成计数失败:%s" % str(e)[:80])
|
|
||||||
ok, msg = study_complete_container(client, ct)
|
|
||||||
log(key, " %s 课程节点「%s」:%s" % ("✓" if ok else "×", ct["name"][:24], msg))
|
|
||||||
try:
|
|
||||||
study_cal_final_score(client, train_id)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
_reread_study(key, s, d)
|
|
||||||
except Exception as e:
|
|
||||||
log(key, "复查失败:%s" % e)
|
|
||||||
|
|
||||||
|
|
||||||
# 平台靠 document.hidden / visibilitychange 判断「用户切走了」,一 hidden 就暂停并记异常。
|
|
||||||
# 多路真播时把可见性钉死成 visible,不然后台标签页会被判成离开。
|
|
||||||
VIS_KEEP_JS = """
|
|
||||||
(() => {
|
|
||||||
const d = document;
|
|
||||||
try { Object.defineProperty(d, 'hidden', {get: () => false, configurable: true}); } catch (e) {}
|
|
||||||
try { Object.defineProperty(d, 'webkitHidden', {get: () => false, configurable: true}); } catch (e) {}
|
|
||||||
try { Object.defineProperty(d, 'visibilityState', {get: () => 'visible', configurable: true}); } catch (e) {}
|
|
||||||
d.addEventListener('visibilitychange', e => e.stopImmediatePropagation(), true);
|
|
||||||
})();
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _classhour_of(url):
|
|
||||||
m = re.search(r"[?&]classhour=([0-9]+)", url or "")
|
|
||||||
return m.group(1) if m else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _wait_video(pg, want_id, secs=60):
|
|
||||||
"""等播放页就绪:("ok", (frame, duration)) / ("redirect", 被切到的课时) / ("timeout", "")。"""
|
|
||||||
end = time.time() + secs
|
|
||||||
while time.time() < end:
|
|
||||||
cur = _classhour_of(pg.url)
|
|
||||||
if cur and want_id and cur != str(want_id):
|
|
||||||
return "redirect", cur
|
|
||||||
for fr in pg.frames:
|
|
||||||
try:
|
|
||||||
st = fr.evaluate("""() => {
|
|
||||||
const v = Array.from(document.querySelectorAll('video')).filter(x => x.duration > 30)[0];
|
|
||||||
return v ? {d: v.duration} : null; }""")
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if st and st.get("d"):
|
|
||||||
return "ok", (fr, float(st["d"]))
|
|
||||||
pg.wait_for_timeout(1000)
|
|
||||||
return "timeout", ""
|
|
||||||
|
|
||||||
|
|
||||||
def _kick_play(frame, seek):
|
|
||||||
"""保活:静音、1 倍速、必要时 seek 到结尾前 3 秒(seek=False 就是老老实实从头播)。"""
|
|
||||||
try:
|
|
||||||
frame.evaluate("""(seek) => {
|
|
||||||
const v = Array.from(document.querySelectorAll('video')).filter(x => x.duration > 30)[0];
|
|
||||||
if (!v) return;
|
|
||||||
v.muted = true;
|
|
||||||
try { v.playbackRate = 1; } catch (e) {}
|
|
||||||
if (seek && v.currentTime < v.duration - 5) {
|
|
||||||
try { v.currentTime = Math.max(0, v.duration - 3); } catch (e) {}
|
|
||||||
}
|
|
||||||
if (v.paused) v.play().catch(() => {});
|
|
||||||
}""", seek)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _dialog_text(pg):
|
|
||||||
try:
|
|
||||||
el = pg.query_selector(".el-message-box__message")
|
|
||||||
return (el.inner_text() or "").strip() if el else ""
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def play_one_period(key, p, cookies, train_id, item, timeout=900, seek=False, attach_cdp=False):
|
|
||||||
"""真播一个课时到结尾。返回 (ok, 说明, 平台要求先学的课时 id)。
|
|
||||||
|
|
||||||
平台在进入播放页时会先查「是不是轮到这一节」,不按顺序会被弹回并跳到它指定的课时,
|
|
||||||
这时返回的第三个值就是那一节,调用方据此重排队列。
|
|
||||||
"""
|
|
||||||
pid = str(item["period_id"])
|
|
||||||
url = ("%s/training/courseVideo?classhour=%s&isJoin=1&needJoin=1&courseId=%s"
|
|
||||||
"&isFront=1&trainCourseId=%s" % (STUDY, pid, train_id, train_id))
|
|
||||||
posted = {"done": False}
|
|
||||||
browser, own = None, False
|
|
||||||
try:
|
|
||||||
if attach_cdp:
|
|
||||||
try:
|
|
||||||
browser = p.chromium.connect_over_cdp("http://127.0.0.1:9222")
|
|
||||||
except Exception:
|
|
||||||
browser = None
|
|
||||||
if browser is None:
|
|
||||||
browser = p.chromium.launch(headless=True, args=[
|
|
||||||
"--autoplay-policy=no-user-gesture-required", "--mute-audio",
|
|
||||||
"--disable-background-timer-throttling", "--disable-renderer-backgrounding"])
|
|
||||||
own = True
|
|
||||||
ctx = browser.new_context()
|
|
||||||
else:
|
|
||||||
ctx = browser.contexts[0] if browser.contexts else browser.new_context()
|
|
||||||
for c in cookies:
|
|
||||||
if "dgjy" in (c.get("domain") or ""):
|
|
||||||
try:
|
|
||||||
ctx.add_cookies([{"name": c["name"], "value": c.get("value") or "",
|
|
||||||
"domain": (c.get("domain") or "").lstrip("."),
|
|
||||||
"path": c.get("path") or "/"}])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
pg = ctx.new_page()
|
|
||||||
pg.add_init_script(VIS_KEEP_JS)
|
|
||||||
|
|
||||||
def on_req(r):
|
|
||||||
try:
|
|
||||||
if "period/study/log" in r.url and '"isComplete":1' in (r.post_data or "").replace(" ", ""):
|
|
||||||
posted["done"] = True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
pg.on("request", on_req)
|
|
||||||
pg.goto(url, wait_until="domcontentloaded", timeout=60000)
|
|
||||||
|
|
||||||
status, val = _wait_video(pg, pid, 60)
|
|
||||||
if status == "redirect":
|
|
||||||
return False, "平台按顺序放行,页面被切到课时 %s" % val, str(val)
|
|
||||||
if status != "ok":
|
|
||||||
return False, "没等到 <video>(可能需要专用播放器的课时)", ""
|
|
||||||
frame = val[0]
|
|
||||||
|
|
||||||
t0 = time.time()
|
|
||||||
while time.time() - t0 < timeout:
|
|
||||||
if posted["done"]:
|
|
||||||
return True, "", ""
|
|
||||||
msg = _dialog_text(pg)
|
|
||||||
if "顺序" in msg or "未完成课时" in msg:
|
|
||||||
return False, "平台按顺序放行:%s" % msg[:30], _classhour_of(pg.url)
|
|
||||||
_kick_play(frame, seek)
|
|
||||||
pg.wait_for_timeout(2000)
|
|
||||||
return False, "超时未等到播放结束上报", ""
|
|
||||||
except Exception as e:
|
|
||||||
return False, str(e)[:120], ""
|
|
||||||
finally:
|
|
||||||
if own and browser is not None:
|
|
||||||
try:
|
|
||||||
browser.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def study_videos_fast(key, cookies, page_url, train_id, todo, workers, cap, done0=0):
|
|
||||||
"""快速学习:直接调 study/log 上报;直链 mp4 接口不认,退回真播。返回完成总数。"""
|
|
||||||
deadline = time.time() + cap * 60
|
|
||||||
hard = [c for c in todo if c.get("kind") != "direct"]
|
|
||||||
soft = [c for c in todo if c.get("kind") == "direct"]
|
|
||||||
ok = done0
|
|
||||||
if hard:
|
|
||||||
log(key, "并发提交 %d 个视频课时(workers=%d)…" % (len(hard), min(workers, len(hard))))
|
|
||||||
ex = ThreadPoolExecutor(max_workers=min(workers, len(hard)))
|
|
||||||
futs = {ex.submit(study_complete_period, cookies, page_url, it): it for it in hard}
|
|
||||||
try:
|
|
||||||
for fut in as_completed(futs, timeout=max(1, deadline - time.time())):
|
|
||||||
it = futs[fut]
|
|
||||||
try:
|
|
||||||
good, msg = fut.result()
|
|
||||||
except Exception as e:
|
|
||||||
good, msg = False, str(e)[:80]
|
|
||||||
if good:
|
|
||||||
ok += 1
|
|
||||||
log(key, " ✓ %s(%ss)" % (it["title"][:40], it.get("duration") or 0))
|
|
||||||
else:
|
|
||||||
log(key, " × %s 上报未生效:%s" % (it["title"][:40], msg))
|
|
||||||
set_state(key, done=ok)
|
|
||||||
except TimeoutError:
|
|
||||||
log(key, "已到时间上限(%d 分钟),剩余课时留待下次" % cap)
|
|
||||||
except Exception as e:
|
|
||||||
log(key, "批量提交中断:%s" % e)
|
|
||||||
finally:
|
|
||||||
ex.shutdown(wait=False, cancel_futures=True)
|
|
||||||
if soft:
|
|
||||||
log(key, "另有 %d 个直链视频课时需要真播放到结尾(%s)"
|
|
||||||
% (len(soft), "、".join(c["title"][:16] for c in soft[:4])))
|
|
||||||
for it in soft:
|
|
||||||
if (RUNS.get(key) or {}).get("status") == "stopped":
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
good = play_period_to_end(key, cookies, page_url,
|
|
||||||
train_id, it["period_id"], it["title"])
|
|
||||||
except Exception as e:
|
|
||||||
log(key, " × %s 播放失败:%s" % (it["title"][:30], str(e)[:100]))
|
|
||||||
continue
|
|
||||||
if good:
|
|
||||||
ok += 1
|
|
||||||
log(key, " ✓ %s(播放到结尾)" % it["title"][:40])
|
|
||||||
else:
|
|
||||||
log(key, " × %s 未确认完成" % it["title"][:40])
|
|
||||||
set_state(key, done=ok)
|
|
||||||
return ok
|
|
||||||
|
|
||||||
|
|
||||||
def _play_wave(key, cookies, train_id, wave, deadline, seek):
|
|
||||||
"""一波并发:每路一个独立 playwright + 独立浏览器,互不遮挡。"""
|
|
||||||
def run(it):
|
|
||||||
try:
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
with sync_playwright() as p:
|
|
||||||
return play_one_period(key, p, cookies, train_id, it,
|
|
||||||
timeout=max(60, deadline - time.time()), seek=seek)
|
|
||||||
except Exception as e:
|
|
||||||
return False, str(e)[:100], ""
|
|
||||||
out = []
|
|
||||||
ex = ThreadPoolExecutor(max_workers=len(wave))
|
|
||||||
try:
|
|
||||||
futs = {ex.submit(run, it): it for it in wave}
|
|
||||||
for fut in as_completed(futs):
|
|
||||||
try:
|
|
||||||
ok, why, wanted = fut.result()
|
|
||||||
except Exception as e:
|
|
||||||
ok, why, wanted = False, str(e)[:100], ""
|
|
||||||
out.append((futs[fut], ok, why, wanted))
|
|
||||||
finally:
|
|
||||||
ex.shutdown(wait=False)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def study_videos_normal(key, cookies, page_url, train_id, todo, parallel, cap, done0=0):
|
|
||||||
"""常规学习:开无头浏览器真播视频,parallel 路并发。
|
|
||||||
|
|
||||||
平台可能按课节顺序放行(不在顺序上会被弹回),所以先按并发跑一波;
|
|
||||||
一旦被弹回就把并发降到 1,并把平台指定的那一节挪到队首,按顺序补回去。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from playwright.sync_api import sync_playwright # noqa: F401
|
|
||||||
except Exception as e:
|
|
||||||
log(key, "缺少 playwright,常规学习不可用:%s" % e)
|
|
||||||
return done0
|
|
||||||
|
|
||||||
parallel = max(1, min(int(parallel or 1), 5, len(todo)))
|
|
||||||
log(key, "常规学习:%d 个课时真播,%d 路并发" % (len(todo), parallel))
|
|
||||||
deadline = time.time() + cap * 60
|
|
||||||
ok, pending, order_only, bounced = done0, list(todo), False, {}
|
|
||||||
while pending and time.time() < deadline:
|
|
||||||
if (RUNS.get(key) or {}).get("status") == "stopped":
|
|
||||||
log(key, "已按要求停止")
|
|
||||||
break
|
|
||||||
wave, pending = pending[:parallel], pending[parallel:]
|
|
||||||
log(key, "本轮 %d 路:%s" % (len(wave), "、".join(x["title"][:14] for x in wave)))
|
|
||||||
for it, good, why, wanted in _play_wave(key, cookies, train_id, wave, deadline, seek=False):
|
|
||||||
if good:
|
|
||||||
ok += 1
|
|
||||||
log(key, " ✓ %s(真播到结尾)" % it["title"][:40])
|
|
||||||
else:
|
|
||||||
log(key, " × %s:%s" % (it["title"][:30], why))
|
|
||||||
if wanted:
|
|
||||||
order_only = True
|
|
||||||
n = bounced[it["period_id"]] = bounced.get(it["period_id"], 0) + 1
|
|
||||||
if n >= 3: # 一直被顺序校验弹回,别再空转
|
|
||||||
log(key, " × %s:连续被顺序校验弹回,跳过" % it["title"][:30])
|
|
||||||
else:
|
|
||||||
i = next((i for i, x in enumerate(pending)
|
|
||||||
if str(x["period_id"]) == str(wanted)), None)
|
|
||||||
if i is None: # 平台要的那一节正在播或不在这门课里,先排到队尾
|
|
||||||
pending.append(it)
|
|
||||||
else: # 先播平台要的那一节,再回来播这一个
|
|
||||||
pending.insert(0, pending.pop(i))
|
|
||||||
set_state(key, done=ok)
|
|
||||||
if order_only and parallel > 1:
|
|
||||||
parallel = 1
|
|
||||||
log(key, "检测到平台按顺序放行,并发降为 1(剩下的逐个播)")
|
|
||||||
return ok
|
|
||||||
|
|
||||||
|
|
||||||
def play_period_to_end(key, cookies, page_url, train_id, period_id, title, timeout=900):
|
|
||||||
"""直链视频(fileType=other):平台只认 onPlayEnd 上报的 isComplete=1。
|
|
||||||
|
|
||||||
做法:Playwright 打开课程播放页(带登录 cookie)→ 秒跳到 duration-3 秒 → 播放 →
|
|
||||||
等接口出现 isComplete=1 → 回读 completeTime 复核。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
except Exception as e:
|
|
||||||
log(key, " 缺少 playwright,直链视频无法自动播完:%s" % e)
|
|
||||||
return False
|
|
||||||
item = {"period_id": period_id, "title": title}
|
|
||||||
try:
|
|
||||||
with sync_playwright() as p:
|
|
||||||
ok, why, _ = play_one_period(key, p, cookies, train_id, item,
|
|
||||||
timeout=timeout, seek=True, attach_cdp=True)
|
|
||||||
except Exception as e:
|
|
||||||
log(key, " 播放异常:%s" % str(e)[:120])
|
|
||||||
return False
|
|
||||||
if not ok:
|
|
||||||
log(key, " 未播完:%s" % why)
|
|
||||||
return False
|
|
||||||
time.sleep(2)
|
|
||||||
try:
|
|
||||||
c = StudyClient(new_session(cookies), page_url)
|
|
||||||
return study_is_done(c, period_id)
|
|
||||||
except Exception:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def study_sync_gyx(client, train_id):
|
|
||||||
"""培训里的「课程」是 periodType 10/13 的容器节点,不跟着里面的课时自动完成。
|
|
||||||
|
|
||||||
这条同步接口要课程管理员权限:学生账号会回「无权操作该课程」,调一次没坏处,
|
|
||||||
管理员账号跑的时候能直接把容器刷成完成。
|
|
||||||
"""
|
|
||||||
j = client.call("GET", "/course/front/study/sync/gyxCourseData/%s" % train_id)
|
|
||||||
if j.get("code") == 200:
|
|
||||||
return "已同步"
|
|
||||||
return "跳过(%s)" % str(j.get("msg") or ("code=%s" % j.get("code")))[:40]
|
|
||||||
|
|
||||||
|
|
||||||
def _reread_study(key, s, d):
|
|
||||||
"""课程跑完后按 trainCourseId 直接复查(不重新解析 jspx 页面)。"""
|
|
||||||
client = StudyClient(s, d.get("page_url") or "")
|
|
||||||
try:
|
|
||||||
log(key, "同步莞易学课程完成状态:%s" % study_sync_gyx(client, d["train_id"]))
|
|
||||||
except Exception as e:
|
|
||||||
log(key, "同步莞易学课程完成状态失败:%s" % str(e)[:100])
|
|
||||||
try:
|
|
||||||
d2 = discover_study(client, d["train_id"], d["course"])
|
|
||||||
except SessionExpired:
|
|
||||||
client.bootstrap()
|
|
||||||
d2 = discover_study(client, d["train_id"], d["course"])
|
|
||||||
set_state(key, progress=d2["progress"], info=d2["info"], cookies=dump_cookies(s))
|
|
||||||
log(key, "课程结束:%s | %s" % (d2["progress"], d2["info"]))
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"""大模型调用(OpenAI 兼容 /chat/completions)。"""
|
"""大模型调用(OpenAI 兼容 /chat/completions)。
|
||||||
import time
|
|
||||||
|
|
||||||
|
后续所有大模型相关代码都放在这个目录里:新增供应商 / 提示词 / 重试策略时,
|
||||||
|
在这里加模块,不要让路由或平台插件直接拼 HTTP 请求。
|
||||||
|
"""
|
||||||
import requests
|
import requests
|
||||||
from study_tool.webapp import app
|
|
||||||
|
|
||||||
from study_tool.config import *
|
|
||||||
from study_tool.settings_store import *
|
|
||||||
|
|
||||||
def llm_chat(messages, cfg, max_tokens=None, json_mode=True):
|
def llm_chat(messages, cfg, max_tokens=None, json_mode=True):
|
||||||
"""OpenAI 兼容的 /chat/completions。不支持 response_format 的服务端会自动去掉重试一次。"""
|
"""OpenAI 兼容的 /chat/completions。不支持 response_format 的服务端会自动去掉重试一次。"""
|
||||||
@@ -40,24 +39,3 @@ def llm_chat(messages, cfg, max_tokens=None, json_mode=True):
|
|||||||
if not content:
|
if not content:
|
||||||
raise RuntimeError("LLM 返回空内容(可能被 max_tokens 截断,或被网关过滤)")
|
raise RuntimeError("LLM 返回空内容(可能被 max_tokens 截断,或被网关过滤)")
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/llm/test")
|
|
||||||
def api_llm_test(b: LlmTestIn):
|
|
||||||
cfg = dict(load_settings()["llm"])
|
|
||||||
if b.base_url.strip():
|
|
||||||
cfg["base_url"] = b.base_url.strip()
|
|
||||||
if b.model.strip():
|
|
||||||
cfg["model"] = b.model.strip()
|
|
||||||
if b.api_key.strip():
|
|
||||||
cfg["api_key"] = b.api_key.strip()
|
|
||||||
cfg["timeout"] = max(10, min(120, int(b.timeout or 60)))
|
|
||||||
if not cfg.get("api_key"):
|
|
||||||
return {"ok": False, "error": "没有 api_key"}
|
|
||||||
t0 = time.time()
|
|
||||||
try:
|
|
||||||
reply = llm_chat([{"role": "user", "content": "只回复两个字符:ok"}], cfg,
|
|
||||||
max_tokens=8, json_mode=False)
|
|
||||||
return {"ok": True, "ms": int((time.time() - t0) * 1000), "reply": (reply or "")[:80]}
|
|
||||||
except Exception as e:
|
|
||||||
return {"ok": False, "error": str(e)[:300]}
|
|
||||||
+24
-33
@@ -1,23 +1,21 @@
|
|||||||
"""HTTP 请求模型(Pydantic)。"""
|
"""HTTP 请求模型(Pydantic)。"""
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# ------------------------------------------------------------------------ models
|
# models
|
||||||
|
|
||||||
|
|
||||||
class LoginIn(BaseModel):
|
class LoginIn(BaseModel):
|
||||||
username: str = ""
|
username: str = ""
|
||||||
password: str = ""
|
password: str = ""
|
||||||
account_id: str = "" # 只给账号 id 时,用户名/密码从 SQLite 里取,密码不出库
|
account_id: str = "" # 只给账号 id 时,用户名/密码从 SQLite 里取,密码不出库
|
||||||
platform: str = "guanyixue" # 没传 account_id 时按它选平台
|
platform: str = "" # 没传 account_id 时按它选平台
|
||||||
|
|
||||||
|
|
||||||
class AccountsIn(BaseModel):
|
|
||||||
accounts: list = []
|
|
||||||
|
|
||||||
|
|
||||||
class AccountIn(BaseModel):
|
class AccountIn(BaseModel):
|
||||||
id: str = ""
|
id: str = ""
|
||||||
username: str = ""
|
username: str = ""
|
||||||
platform: str = "guanyixue" # 添加账号时选的平台
|
platform: str = "" # 添加账号时选的平台
|
||||||
password: str | None = None # 不传 = 保留库里已有的密码
|
password: str | None = None # 不传 = 保留库里已有的密码
|
||||||
note: str = ""
|
note: str = ""
|
||||||
user: str = ""
|
user: str = ""
|
||||||
@@ -35,7 +33,7 @@ class AccountIn(BaseModel):
|
|||||||
|
|
||||||
class CookiesIn(BaseModel):
|
class CookiesIn(BaseModel):
|
||||||
cookies: list
|
cookies: list
|
||||||
platform: str = "guanyixue"
|
platform: str = ""
|
||||||
|
|
||||||
|
|
||||||
class DiscoverIn(BaseModel):
|
class DiscoverIn(BaseModel):
|
||||||
@@ -43,7 +41,7 @@ class DiscoverIn(BaseModel):
|
|||||||
plan_id: str
|
plan_id: str
|
||||||
project_id: str
|
project_id: str
|
||||||
name: str = ""
|
name: str = ""
|
||||||
platform: str = "guanyixue"
|
platform: str = ""
|
||||||
|
|
||||||
|
|
||||||
class StudyIn(BaseModel):
|
class StudyIn(BaseModel):
|
||||||
@@ -71,41 +69,34 @@ class CaptureIn(BaseModel):
|
|||||||
minutes: int = 10 # 抓包最长分钟数(1~30)
|
minutes: int = 10 # 抓包最长分钟数(1~30)
|
||||||
|
|
||||||
|
|
||||||
class QuizIn(BaseModel):
|
class NotifyCfgIn(BaseModel):
|
||||||
cookies: list = []
|
"""故障推送配置;空串 / 0 / None 都表示「不改」,避免只保存模型设置时被清空。"""
|
||||||
plan_id: str = ""
|
|
||||||
project_id: str = ""
|
|
||||||
name: str = ""
|
|
||||||
platform: str = "guanyixue"
|
|
||||||
# retake=True:交卷后点「再考一次」,让平台生成新答卷(消耗一次机会)
|
|
||||||
retake: bool = False
|
|
||||||
period_id: str = "" # 指定要重考/查看的课时(不传就按原逻辑挑第一个)
|
|
||||||
|
|
||||||
|
base_url: str = ""
|
||||||
class QuizSubmitIn(BaseModel):
|
app_key: str = ""
|
||||||
cookies: list = []
|
app_secret: str = "" # 留空 = 保留原密钥,前端不回显明文
|
||||||
plan_id: str = ""
|
channel_name: str = ""
|
||||||
object_id: str = ""
|
enabled: bool | None = None
|
||||||
generation: str = "legacy" # legacy / spa,决定走旧版还是新版交卷接口
|
on_error: bool | None = None
|
||||||
result_id: str = ""
|
on_login: bool | None = None
|
||||||
answers: dict = {} # {"1": "A", "2": "A#C"}
|
on_progress: bool | None = None
|
||||||
|
on_retry: bool | None = None
|
||||||
|
retry_threshold: int = 0
|
||||||
class QuizAnswerIn(BaseModel):
|
cooldown_min: int = 0
|
||||||
questions: list = [] # [{no,type,title,options:[{key,text}]}]
|
clear_app_secret: bool = False # True = 清掉已保存的 app_secret
|
||||||
prompt: str = ""
|
|
||||||
account_id: str = "" # 有账号就把日志写进它的「课程日志」,没有就写 key=ai
|
|
||||||
|
|
||||||
|
|
||||||
class LlmCfgIn(BaseModel):
|
class LlmCfgIn(BaseModel):
|
||||||
base_url: str = ""
|
base_url: str = ""
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
model: str = ""
|
model: str = ""
|
||||||
temperature: float = 0.2
|
temperature: float = 0.2 # 0 = 不改
|
||||||
timeout: int = 120
|
timeout: int = 120
|
||||||
|
clear_api_key: bool = False # True = 清掉已保存的 api_key
|
||||||
prompt: str = ""
|
prompt: str = ""
|
||||||
study_mode: str = "" # "" = 不改;fast / normal
|
study_mode: str = "" # "" = 不改;fast / normal
|
||||||
study_parallel: int = 0 # 0 = 不改;1~5
|
study_parallel: int = 0 # 0 = 不改;1~5
|
||||||
|
notify: NotifyCfgIn | None = None # 不传 = 不动推送配置
|
||||||
|
|
||||||
|
|
||||||
class LlmTestIn(BaseModel):
|
class LlmTestIn(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""系统通知中心:故障上报 → 推送渠道插件。
|
||||||
|
|
||||||
|
渠道插件放在 `plugins/<名字>/`:`plugin.json` 里写 `"kind": "notify"`,
|
||||||
|
`main.py` 实现一个 `Channel` 子类并在模块末尾 `NOTIFY = notify.register(X())`。
|
||||||
|
引擎 / 路由 / 平台插件只调本模块的 `fault()` / `retry()`,不 import 任何具体推送服务;
|
||||||
|
换渠道 = 换一个插件。
|
||||||
|
|
||||||
|
故障类型(kind)
|
||||||
|
run 整体运行异常
|
||||||
|
course 单门课程处理异常
|
||||||
|
login 登录失败 / 会话失效且无法续期
|
||||||
|
progress 学习进度异常(学完仍未完成、进度没推进、没有可识别活动)
|
||||||
|
retry 学习过程中同一原因反复重试
|
||||||
|
|
||||||
|
节流:同一个 (类型, 账号) 在 `cooldown_min` 分钟内只推一次;`retry` 只在计数
|
||||||
|
刚达到 `retry_threshold` 时推一次,避免一屏一屏地刷推送。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from study_tool import database
|
||||||
|
from study_tool.config.settings import load_settings
|
||||||
|
from study_tool.runtime import log
|
||||||
|
|
||||||
|
KINDS = {
|
||||||
|
"run": "运行异常",
|
||||||
|
"course": "课程异常",
|
||||||
|
"login": "登录/会话异常",
|
||||||
|
"progress": "学习进度异常",
|
||||||
|
"retry": "多次重试",
|
||||||
|
}
|
||||||
|
# 类型 → 设置项里的开关字段
|
||||||
|
SWITCH = {
|
||||||
|
"run": "on_error",
|
||||||
|
"course": "on_error",
|
||||||
|
"login": "on_login",
|
||||||
|
"progress": "on_progress",
|
||||||
|
"retry": "on_retry",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CHANNELS = {} # 渠道 id -> Channel
|
||||||
|
_COOLDOWN = {} # (kind, key) -> 上次推送时间
|
||||||
|
_RETRY = {} # (key, what) -> 本轮重试次数
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class Channel:
|
||||||
|
"""推送渠道:一个插件一个渠道,`send()` 失败就抛异常。"""
|
||||||
|
|
||||||
|
id = ""
|
||||||
|
label = ""
|
||||||
|
|
||||||
|
def send(self, title, content, context=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 注册表
|
||||||
|
|
||||||
|
|
||||||
|
def register(channel):
|
||||||
|
_CHANNELS[channel.id] = channel
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(cid):
|
||||||
|
return _CHANNELS.pop(cid or "", None)
|
||||||
|
|
||||||
|
|
||||||
|
def get(cid):
|
||||||
|
return _CHANNELS.get(cid or "")
|
||||||
|
|
||||||
|
|
||||||
|
def all():
|
||||||
|
return sorted(_CHANNELS.values(), key=lambda c: c.id)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 发送
|
||||||
|
|
||||||
|
|
||||||
|
def _account(key):
|
||||||
|
acc = database.account_get(key) or {}
|
||||||
|
return acc.get("username") or acc.get("user") or key
|
||||||
|
|
||||||
|
|
||||||
|
def send(title, content, context=None):
|
||||||
|
"""直接推一条(测试推送也走这里);返回 (ok, msg)。"""
|
||||||
|
channels = all()
|
||||||
|
if not channels:
|
||||||
|
return False, "没有可用的推送渠道:plugins/ 下缺少 kind=notify 的插件"
|
||||||
|
errors = []
|
||||||
|
for ch in channels:
|
||||||
|
try:
|
||||||
|
ch.send(title, content, context)
|
||||||
|
return True, "已通过「%s」推送" % (ch.label or ch.id)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append("%s:%s" % (ch.label or ch.id, e))
|
||||||
|
return False, ";".join(errors) or "推送失败"
|
||||||
|
|
||||||
|
|
||||||
|
def fault(kind, key, title, content, context=None):
|
||||||
|
"""上报一个系统故障;真的推出去了返回 True。"""
|
||||||
|
cfg = load_settings()["notify"]
|
||||||
|
if not cfg.get("enabled", True) or not cfg.get(SWITCH.get(kind, ""), True):
|
||||||
|
return False
|
||||||
|
key = key or "system"
|
||||||
|
now = time.time()
|
||||||
|
cool = max(1, int(cfg.get("cooldown_min") or 10)) * 60
|
||||||
|
with _LOCK:
|
||||||
|
if now - _COOLDOWN.get((kind, key), 0) < cool:
|
||||||
|
return False
|
||||||
|
_COOLDOWN[(kind, key)] = now
|
||||||
|
ctx = dict(context or {})
|
||||||
|
ctx.update({"kind": kind, "key": key, "account": _account(key)})
|
||||||
|
ok, msg = send("【%s】%s" % (KINDS.get(kind, "系统故障"), title), _body(key, content, ctx), ctx)
|
||||||
|
log(key, "故障推送%s:%s(%s)" % ("成功" if ok else "失败", title, msg), level="warn")
|
||||||
|
if not ok:
|
||||||
|
# 没推出去就别占着整段冷却:留 1 分钟再试,否则一次网络抖动会把通知压掉十分钟
|
||||||
|
with _LOCK:
|
||||||
|
_COOLDOWN[(kind, key)] = now - cool + 60
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def _body(key, content, context):
|
||||||
|
lines = ["账号:%s" % _account(key)]
|
||||||
|
if context.get("course"):
|
||||||
|
lines.append("课程:%s" % context["course"])
|
||||||
|
lines.append("时间:%s" % time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
lines.append("")
|
||||||
|
lines.append(content or "")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 重试计数
|
||||||
|
|
||||||
|
|
||||||
|
def retry(key, what, detail=""):
|
||||||
|
"""记一次重试;同一轮里同一原因累计到阈值就推一次。返回当前次数。"""
|
||||||
|
cfg = load_settings()["notify"]
|
||||||
|
if not cfg.get("enabled", True) or not cfg.get("on_retry", True):
|
||||||
|
return 0
|
||||||
|
key = key or "system"
|
||||||
|
with _LOCK:
|
||||||
|
n = _RETRY.get((key, what), 0) + 1
|
||||||
|
_RETRY[(key, what)] = n
|
||||||
|
limit = max(2, int(cfg.get("retry_threshold") or 3))
|
||||||
|
if n == limit:
|
||||||
|
fault(
|
||||||
|
"retry",
|
||||||
|
key,
|
||||||
|
"%s 连续重试 %d 次" % (what, n),
|
||||||
|
detail or "该环节反复重试,通常是会话失效、接口不稳定或平台风控,请人工看一眼。",
|
||||||
|
)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def reset(key):
|
||||||
|
"""新一轮开始:清掉该账号的重试计数(冷却时间保留,避免刚推完又推)。"""
|
||||||
|
with _LOCK:
|
||||||
|
for k in [k for k in _RETRY if k[0] == key]:
|
||||||
|
del _RETRY[k]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 进度体检
|
||||||
|
|
||||||
|
|
||||||
|
def _ratio(text):
|
||||||
|
"""从「3/10 课时」「已完成 2/5 个活动」里取 (完成, 总数)。"""
|
||||||
|
m = re.search(r"(\d+)\s*/\s*(\d+)", str(text or ""))
|
||||||
|
return (int(m.group(1)), int(m.group(2))) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def check_progress(key, course, before, after):
|
||||||
|
"""课程学完后的进度体检:学完仍未完成 / 进度没动 → 推一次。"""
|
||||||
|
before, after = before or {}, after or {}
|
||||||
|
course = course or after.get("course") or ""
|
||||||
|
total, done = after.get("total"), after.get("done")
|
||||||
|
if total and done is not None and int(total) > 0 and int(done) < int(total):
|
||||||
|
return fault(
|
||||||
|
"progress",
|
||||||
|
key,
|
||||||
|
"课程学完仍未完成",
|
||||||
|
"课程:%s\n进度:%s/%s 个活动" % (course or "-", done, total),
|
||||||
|
{"course": course},
|
||||||
|
)
|
||||||
|
now, was = _ratio(after.get("progress")), _ratio(before.get("progress"))
|
||||||
|
if now and now[1] > 0 and now[0] < now[1]:
|
||||||
|
if was and now[0] <= was[0]:
|
||||||
|
return fault(
|
||||||
|
"progress",
|
||||||
|
key,
|
||||||
|
"学习进度没有推进",
|
||||||
|
"课程:%s\n进度:%s → %s" % (course or "-", before.get("progress"), after.get("progress")),
|
||||||
|
{"course": course},
|
||||||
|
)
|
||||||
|
return fault(
|
||||||
|
"progress",
|
||||||
|
key,
|
||||||
|
"课程学完仍未完成",
|
||||||
|
"课程:%s\n进度:%s" % (course or "-", after.get("progress")),
|
||||||
|
{"course": course},
|
||||||
|
)
|
||||||
|
return False
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
"""平台插件包。
|
"""平台包。
|
||||||
|
|
||||||
内置平台在 `load_builtin()` 里导入(导入即注册)。新增平台:
|
这里放平台**运行时**:`Platform` 契约(`base.py`)、能力映射(`contract.py`)、
|
||||||
1. 新建 `platforms/<名字>.py`,实现 `Platform.study`、声明 `entries` 登录 / 学习入口,
|
错误类型(`errors.py`)、注册表(`registry.py`)。
|
||||||
并 `registry.register(...)`;
|
|
||||||
2. 在下面的 `load_builtin()` 里加一行 import;
|
平台实现都放在插件目录 `plugins/<名字>/`(`plugin.json` + `main.py`,可以再拆实现模块),
|
||||||
3. 路由 / 引擎不用改 —— 它们只认 `course["platform"]`。
|
由 `study_tool.plugins.load_plugins()` 扫描加载;新增或迁移插件都不用改这里的代码。
|
||||||
|
要是有多个插件共用的实现模块,才放回本目录。
|
||||||
|
|
||||||
|
路由 / 引擎不用改 —— 它们只认 `course["platform"]`。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from study_tool.platforms import registry # noqa: F401 供 engine、路由使用
|
from study_tool.platforms import registry # noqa: F401 供 engine、路由使用
|
||||||
from study_tool.platforms.base import Platform # noqa: F401
|
from study_tool.platforms.base import Platform # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
def load_builtin():
|
def load_builtin():
|
||||||
"""导入内置平台,触发注册。幂等,app 启动时调用一次。"""
|
"""加载 `plugins/` 下的全部插件(导入即注册)。幂等,app 启动时调用一次。"""
|
||||||
from study_tool.platforms import gdgxk # noqa: F401
|
from study_tool.plugins import load_plugins
|
||||||
from study_tool.platforms import guanyixue # noqa: F401
|
|
||||||
|
load_plugins()
|
||||||
|
|||||||
@@ -1,41 +1,72 @@
|
|||||||
"""平台插件契约。
|
"""平台插件契约:一个平台 = 一个 `Platform` 子类。
|
||||||
|
|
||||||
新增一个平台 = 在 `platforms/` 下加一个模块,构造 `Platform` 并 `registry.register()`,
|
子类用类属性声明元数据,用**方法**实现自己确实有的阶段;没实现的阶段不要写,
|
||||||
然后把它加进 `platforms/__init__.py` 的 `load_builtin()`。引擎只按
|
保持基类的 `None`(`capabilities()` 按非空槽位推导,引擎按能力决定走不走这个分支):
|
||||||
`course["platform"]` 去注册表取实现,不再 import 具体平台。
|
|
||||||
|
class Gdgxk(Platform):
|
||||||
|
id = "gdgxk"
|
||||||
|
label = "广东公需课"
|
||||||
|
entries = ("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn")
|
||||||
|
|
||||||
|
def session(self, cookies=None): ...
|
||||||
|
def login(self, username, password): ...
|
||||||
|
def study(self, key, s, d, **opts): ...
|
||||||
|
|
||||||
|
PLATFORM = registry.register(Gdgxk())
|
||||||
|
|
||||||
|
新增平台 = 在 `plugins/<名字>/` 下加一个这样的 `main.py`(附 `plugin.json`),加载器扫目录自动 import。
|
||||||
|
引擎只按 `course["platform"]` 去注册表取实现,不 import 具体平台。
|
||||||
|
|
||||||
`entries` 声明登录入口 / 学习入口的 URL 片段,`registry.detect(url, html)` 用它识别
|
`entries` 声明登录入口 / 学习入口的 URL 片段,`registry.detect(url, html)` 用它识别
|
||||||
页面属于哪个平台;同一平台的不同代页面(如莞易学的旧版 / 新版)用 `course["generation"]`
|
页面属于哪个平台;同一平台的不同代页面(如莞易学的旧版 / 新版)用 `course["generation"]`
|
||||||
区分,由平台内部自行分发。
|
区分,由平台内部自行分发。
|
||||||
|
|
||||||
Platform.study 的签名约定:
|
槽位方法签名(第一个参数是 self,其余同自由函数):
|
||||||
|
|
||||||
study(key, s, d, **opts)
|
study(key, s, d, **opts)
|
||||||
key 运行 key(账号 id,日志也用它)
|
key 运行 key(账号 id,日志也用它)
|
||||||
s requests.Session(带该账号 cookie)
|
s requests.Session(带该账号 cookie)
|
||||||
d discover() 返回的课程描述(course/courseware/feedback/test/platform/generation/...)
|
d discover() 返回的课程描述(course/courseware/feedback/test/platform/generation/...)
|
||||||
opts workers / cap / enable_quiz / enable_feedback / mode / parallel /
|
opts workers / cap / enable_quiz / enable_feedback / mode / parallel /
|
||||||
quiz_key / cookies / plan / project / name
|
quiz_key / cookies / plan / project / name
|
||||||
|
session(cookies) -> requests.Session
|
||||||
|
dump_cookies(session) -> [cookie]
|
||||||
|
login(username, password) -> (session, user)
|
||||||
|
list_courses(session) -> [course]
|
||||||
|
discover(session, plan_id, project_id, name) -> 课程描述
|
||||||
|
renew_session(session) -> bool,无密码续期
|
||||||
|
profile(session) -> 显示名
|
||||||
|
quiz(session, d, b) -> {"ok": ..., "questions": [...]} d 是 discover() 的结果,b 是请求体
|
||||||
|
quiz_submit(session, b) -> {"ok": ..., "score": ...} 按用户答案交卷
|
||||||
|
capture(key, cookies, minutes) 另需配套 captures(key) / capture_reset(key)
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Callable, Optional
|
from study_tool.platforms.contract import capabilities_of
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Platform:
|
class Platform:
|
||||||
id: str # course["platform"] 的取值
|
id = "" # course["platform"] 的取值
|
||||||
label: str # 日志 / 界面里的展示名
|
label = "" # 日志 / 界面里的展示名
|
||||||
study: Optional[Callable] = None # 学一门课;None = 这个平台还没实现
|
entries = () # 登录 / 学习入口 URL 片段,用于识别平台
|
||||||
entries: tuple = () # 登录 / 学习入口 URL 片段,用于识别平台
|
|
||||||
# 账号级能力:添加账号时选了哪个平台,登录 / 发现 / 刷新课程就走哪个平台的实现
|
# 槽位:子类实现哪个阶段就把它覆盖成方法;保持 None = 该平台没有这项能力
|
||||||
session: Optional[Callable] = None # (cookies) -> requests.Session
|
study = None # 学一门课;None = 这个平台还没实现
|
||||||
dump_cookies: Optional[Callable] = None # (session) -> [cookie]
|
session = None
|
||||||
login: Optional[Callable] = None # (username, password) -> (session, user)
|
dump_cookies = None
|
||||||
list_courses: Optional[Callable] = None # (session) -> [course]
|
login = None
|
||||||
discover: Optional[Callable] = None # (session, plan_id, project_id, name) -> 课程描述
|
list_courses = None
|
||||||
renew_session: Optional[Callable] = None # (session) -> bool,无密码续期
|
discover = None
|
||||||
profile: Optional[Callable] = None # (session) -> 显示名
|
renew_session = None
|
||||||
|
profile = None
|
||||||
|
quiz = None # quiz(self, s, d, b) -> dict,取测验题目;前端据此显示「打开测验」
|
||||||
|
quiz_submit = None # quiz_submit(self, s, b) -> dict,按用户答案交卷
|
||||||
|
capture = None # capture(self, key, cookies, minutes),抓包调试;前端据此显示「抓包」
|
||||||
|
|
||||||
def matches(self, url="", html=""):
|
def matches(self, url="", html=""):
|
||||||
"""登录入口或学习入口命中任意片段,就认为页面属于本平台。"""
|
"""登录入口或学习入口命中任意片段,就认为页面属于本平台。"""
|
||||||
text = "%s\n%s" % (url or "", html or "")
|
text = "%s\n%s" % (url or "", html or "")
|
||||||
return any(e and e in text for e in self.entries)
|
return any(e and e in text for e in self.entries)
|
||||||
|
|
||||||
|
def capabilities(self):
|
||||||
|
"""已实现的能力名(见 contract.CAPABILITIES),/api/platforms 用它。"""
|
||||||
|
return capabilities_of(self)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""平台插件契约:生命周期、能力与槽位对应关系。
|
||||||
|
|
||||||
|
插件只实现自己平台**确实有**的阶段,缺的阶段不要实现,也不要抛 NotImplementedError;
|
||||||
|
引擎按能力判断是否走该分支(例如没有 `login` 的平台不显示密码登录)。
|
||||||
|
|
||||||
|
生命周期 槽位 能力名
|
||||||
|
登录 / 会话 login / session / renew_session / profile login / session / renew / profile
|
||||||
|
查询课程 list_courses courses
|
||||||
|
课程详情 discover discover
|
||||||
|
学习 study study
|
||||||
|
测验 quiz / quiz_submit quiz
|
||||||
|
抓包调试 capture capture
|
||||||
|
|
||||||
|
流程(按什么顺序调用)只有一个实现,在引擎里;平台只负责「这一步在这个站点怎么做」。
|
||||||
|
"""
|
||||||
|
|
||||||
|
CAPABILITIES = {
|
||||||
|
"login": ("login",),
|
||||||
|
"session": ("session",),
|
||||||
|
"renew": ("renew_session",),
|
||||||
|
"profile": ("profile",),
|
||||||
|
"courses": ("list_courses",),
|
||||||
|
"discover": ("discover",),
|
||||||
|
"study": ("study",),
|
||||||
|
"quiz": ("quiz", "quiz_submit"),
|
||||||
|
"capture": ("capture",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def capabilities_of(platform) -> list:
|
||||||
|
"""按已实现的槽位推导能力名,顺序固定(即 CAPABILITIES 的顺序)。"""
|
||||||
|
return [name for name, slots in CAPABILITIES.items() if all(getattr(platform, slot, None) for slot in slots)]
|
||||||
|
|
||||||
|
|
||||||
|
def sites_of(platform) -> list:
|
||||||
|
"""界面展示用的适配网站:去掉被更具体域名覆盖的父域(如 dgjy.net)。"""
|
||||||
|
entries = list(getattr(platform, "entries", ()) or ())
|
||||||
|
return [e for e in entries if not any(o != e and o.endswith("." + e) for o in entries)]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""平台层错误类型。
|
||||||
|
|
||||||
|
放在这里是为了让平台实现(guanyixue / gdgxk)不必反向 import 引擎。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SessionExpired(RuntimeError):
|
||||||
|
"""站点要求重新登录(cookie 已失效)。"""
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
"""广东公需课:jsglpt 登录 → jsxx 学习。
|
|
||||||
|
|
||||||
登录在 `jsglpt.gds.edu.cn`(账号 / 密码都是 base64),成功后用
|
|
||||||
`ssoItlmsGxkTicket.action` 换 ITLMS 票,带票进 `jsxx.gds.edu.cn` 拿学习会话;
|
|
||||||
两个域名的 cookie 都留在同一个 requests.Session 里。
|
|
||||||
|
|
||||||
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度靠 `updateViewTime1`
|
|
||||||
心跳上报(`lastViewTime` 是已看秒数,页面上「要求观看视频 N 分钟」才是完成门槛)。
|
|
||||||
平台按真实流逝时间放行(实测一次报 +300 只记 +60),所以只能 1 倍速慢慢刷。
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from study_tool import runtime, store
|
|
||||||
from study_tool.platforms import registry
|
|
||||||
from study_tool.platforms.base import Platform
|
|
||||||
|
|
||||||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
||||||
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36")
|
|
||||||
G = "https://jsglpt.gds.edu.cn"
|
|
||||||
X = "https://jsxx.gds.edu.cn"
|
|
||||||
PLATFORM_ID = "gdgxk"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _xhr(s, method, url, **kw):
|
|
||||||
"""这些上报接口必须带 Referer(指向课程页),否则直接 500。"""
|
|
||||||
kw.setdefault("headers", {})["X-Requested-With"] = "XMLHttpRequest"
|
|
||||||
return s.request(method, url, timeout=30, **kw)
|
|
||||||
|
|
||||||
|
|
||||||
def _expired(msg):
|
|
||||||
"""引擎认的是同一个 SessionExpired,这里延迟取避免循环 import。"""
|
|
||||||
from study_tool.engine import SessionExpired
|
|
||||||
return SessionExpired(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def _b64(text):
|
|
||||||
return base64.b64encode(str(text).encode("utf-8")).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def new_session(cookies=None):
|
|
||||||
s = requests.Session()
|
|
||||||
s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
|
|
||||||
for c in (cookies or []):
|
|
||||||
if "gds.edu.cn" not in (c.get("domain") or ""):
|
|
||||||
continue
|
|
||||||
s.cookies.set(c["name"], c.get("value") or "",
|
|
||||||
domain=(c.get("domain") or "").lstrip("."),
|
|
||||||
path=c.get("path") or "/")
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def dump_cookies(s):
|
|
||||||
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path}
|
|
||||||
for c in s.cookies]
|
|
||||||
|
|
||||||
|
|
||||||
def _sso(s):
|
|
||||||
"""jsglpt 会话换 ITLMS 票,票按 `ITLMS-xxx||ticketId` 两段交给 jsxx。
|
|
||||||
|
|
||||||
交接入口来自 jsglpt 首页那个隐藏表单:
|
|
||||||
`GET https://jsxx.gds.edu.cn/nts/loginToStoreByAccount?ticket=&ticketId=`
|
|
||||||
"""
|
|
||||||
r = s.post(G + "/teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=", timeout=30)
|
|
||||||
ticket = (r.text or "").strip()
|
|
||||||
if not ticket.startswith("ITLMS-") or "||" not in ticket:
|
|
||||||
raise _expired("取 ITLMS 票失败:%s" % (ticket[:80] or "空响应"))
|
|
||||||
t, tid = ticket.split("||", 1)
|
|
||||||
q = s.get(X + "/nts/loginToStoreByAccount", params={"ticket": t, "ticketId": tid}, timeout=30)
|
|
||||||
if "logout" not in q.text and "我的课程" not in q.text:
|
|
||||||
raise _expired("带票进学习平台失败,票可能已失效")
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
_OCR = None
|
|
||||||
|
|
||||||
|
|
||||||
def _ocr():
|
|
||||||
"""4 位图形验证码:ddddocr 离线识别。"""
|
|
||||||
global _OCR
|
|
||||||
if _OCR is None:
|
|
||||||
try:
|
|
||||||
import ddddocr
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError("登录要过图形验证码,需要先装 ddddocr(pip install ddddocr):%s" % e)
|
|
||||||
_OCR = ddddocr.DdddOcr(show_ad=False)
|
|
||||||
return _OCR
|
|
||||||
|
|
||||||
|
|
||||||
def _captcha(s):
|
|
||||||
raw = s.get(G + "/login/readCaptcha.action?d=%.6f" % time.time(), timeout=30).text
|
|
||||||
return base64.b64decode(raw.strip().strip('"').split(",", 1)[-1])
|
|
||||||
|
|
||||||
|
|
||||||
def _login_error(s):
|
|
||||||
html = s.get(G + "/login.jsp", timeout=30).text
|
|
||||||
m = re.search(r'id="errorText"[^>]*>([^<]*)<', html)
|
|
||||||
return (m.group(1).strip() if m else "")
|
|
||||||
|
|
||||||
|
|
||||||
def login(username, password):
|
|
||||||
s = new_session()
|
|
||||||
s.get(G + "/login.jsp", timeout=30)
|
|
||||||
err = ""
|
|
||||||
for _ in range(5):
|
|
||||||
data = {
|
|
||||||
"loginUser.userName": _b64(username),
|
|
||||||
"loginUser.loginType": "normal",
|
|
||||||
"loginUser.password": _b64(password),
|
|
||||||
"captcha": _ocr().classification(_captcha(s)),
|
|
||||||
}
|
|
||||||
r = s.post(G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
|
|
||||||
if r.status_code == 302 and "/default.jsp" in (r.headers.get("location") or ""):
|
|
||||||
_sso(s)
|
|
||||||
return s, profile(s) or username
|
|
||||||
err = _login_error(s)
|
|
||||||
if "验证码" not in err: # 不是验证码的问题,重试没意义
|
|
||||||
break
|
|
||||||
raise RuntimeError("登录失败:%s" % (err or "账号或密码不对"))
|
|
||||||
|
|
||||||
|
|
||||||
def renew_session(s):
|
|
||||||
try:
|
|
||||||
_sso(s)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def profile(s):
|
|
||||||
html = s.get(G + "/teacher/index.do", timeout=30).text
|
|
||||||
m = re.search(r"你好[,,]?\s*([^\s<]+)\s*老师", re.sub(r"\s+", " ", html))
|
|
||||||
if m:
|
|
||||||
return m.group(1)
|
|
||||||
m = re.search(r'class="user"[^>]*>\s*<[^>]*>\s*([^\s<]{2,20})', html)
|
|
||||||
return m.group(1) if m else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_courses(html):
|
|
||||||
"""课程列表页:每个 `showCourse('id','名称')` 前面就是这张卡片的信息。"""
|
|
||||||
out = []
|
|
||||||
for m in re.finditer(r"showCourse\('([^']+)','([^']*)'\)", html):
|
|
||||||
block = html[max(0, m.start() - 2500):m.start()]
|
|
||||||
year = re.search(r"培训年度:\s*<[^>]*>(\d{4})<", block)
|
|
||||||
hours = re.search(r"课程学时:\s*<[^>]*>(\d+)<", block)
|
|
||||||
st = re.search(r">\s*(未完成|已完成)\s*<", block)
|
|
||||||
out.append({
|
|
||||||
"plan_id": m.group(1),
|
|
||||||
"project_id": "",
|
|
||||||
"name": m.group(2).strip(),
|
|
||||||
"section": ("%s年公需课" % year.group(1)) if year else "",
|
|
||||||
"status_label": st.group(1) if st else "",
|
|
||||||
"info": ("%s 学时" % hours.group(1)) if hours else "",
|
|
||||||
"in_study": True,
|
|
||||||
"platform": PLATFORM_ID,
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def list_courses(s):
|
|
||||||
html = s.get(X + "/uc/store/courseRegister", timeout=30).text
|
|
||||||
out = _parse_courses(html)
|
|
||||||
if not out:
|
|
||||||
if "logout" not in html:
|
|
||||||
raise _expired("学习平台会话已失效,请重新登录")
|
|
||||||
return []
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def parse_course(html):
|
|
||||||
"""从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、要求时长、已看秒数。"""
|
|
||||||
d = {}
|
|
||||||
m = re.search(r"/a_([0-9a-f]{32})/study/video/user/", html)
|
|
||||||
if not m:
|
|
||||||
raise RuntimeError("学习页里没有找到课件上报路径,页面结构可能变了")
|
|
||||||
d["aid"] = "a_" + m.group(1)
|
|
||||||
m = re.search(r"updateVideoStatus1'[\s\S]{0,200}?id=([0-9a-f]{32})", html) \
|
|
||||||
or re.search(r"id=([0-9a-f]{32})&md5Uid=", html)
|
|
||||||
if not m:
|
|
||||||
raise RuntimeError("学习页里没有找到课件 id")
|
|
||||||
d["id"] = m.group(1)
|
|
||||||
m = re.search(r"md5Uid=([0-9a-f]{64,128})", html)
|
|
||||||
if not m:
|
|
||||||
raise RuntimeError("学习页里没有找到 md5Uid")
|
|
||||||
d["uid"] = m.group(1)
|
|
||||||
# `t` 是服务端记的「已看秒数」,不是总长;完成门槛是页面上的「要求观看视频 N 分钟」
|
|
||||||
m = re.search(r"\bt\s*=\s*'(\d+)'", html)
|
|
||||||
d["viewTime"] = int(m.group(1)) if m else 0
|
|
||||||
m = re.search(r"要求观看视频<span>(\d+)</span>分钟", html)
|
|
||||||
d["total"] = int(m.group(1)) * 60 if m else 0
|
|
||||||
m = re.search(r"url1\s*=\s*'([^']+)'", html)
|
|
||||||
d["video"] = m.group(1) if m else ""
|
|
||||||
d["title"] = (re.search(r"<title>([^<]*)</title>", html).group(1).strip()
|
|
||||||
if "<title>" in html else "")
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def _sign(s, cw, uid):
|
|
||||||
"""updateVideoStatus1:注册播放权,换回心跳签名(time / md5)。"""
|
|
||||||
r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/updateVideoStatus1",
|
|
||||||
data={"_method": "PUT", "id": cw["id"], "md5Uid": uid,
|
|
||||||
"playbackSessionId": cw["sid"], "playbackTakeover": "true"})
|
|
||||||
j = r.json() if r.text.strip().startswith("{") else {}
|
|
||||||
if j.get("responseCode") != "00":
|
|
||||||
raise RuntimeError("注册播放失败:%s" % (r.text[:120] or "空响应"))
|
|
||||||
return j["responseData"]
|
|
||||||
|
|
||||||
|
|
||||||
def _view_time(s, cw, uid, sign):
|
|
||||||
r = _xhr(s, "GET", X + "/" + cw["aid"] + "/study/video/user/getViewTime",
|
|
||||||
params={"id": cw["id"], "md5": sign.get("md5"), "expires": sign.get("time"),
|
|
||||||
"md5Uid": uid})
|
|
||||||
j = r.json() if r.text.strip().startswith("{") else {}
|
|
||||||
rd = j.get("responseData")
|
|
||||||
if isinstance(rd, dict):
|
|
||||||
return int(rd.get("viewTime") or 0)
|
|
||||||
return int(rd or 0)
|
|
||||||
|
|
||||||
|
|
||||||
def _progress(s, cid):
|
|
||||||
"""学习进度页:活动总数、已完成数、每个活动(aid / 名称 / 是否完成)。"""
|
|
||||||
html = s.get(X + "/" + cid + "/study/course/progess", timeout=30).text
|
|
||||||
if "studyCourse(" not in html:
|
|
||||||
raise _expired("学习平台会话已失效,请重新登录")
|
|
||||||
m = re.search(r"\$\('#actNum'\)\.text\('(\d+)'\)", html)
|
|
||||||
total = int(m.group(1)) if m else 0
|
|
||||||
m = re.search(r"\$\('#completeActNum'\)\.text\('(\d+)'\)", html)
|
|
||||||
done = int(m.group(1)) if m else 0
|
|
||||||
acts = []
|
|
||||||
for row in re.findall(r"<tr[^>]*>([\s\S]*?)</tr>", html):
|
|
||||||
m = re.search(r"studyCourse\('[^']*','[^']*','(a_[0-9a-f]{32})'\)[^>]*>([^<]+)<", row)
|
|
||||||
if m:
|
|
||||||
acts.append({"aid": m.group(1), "title": m.group(2).strip(),
|
|
||||||
"done": "u-ico-finish" in row})
|
|
||||||
return {"total": total or len(acts), "done": done, "acts": acts}
|
|
||||||
|
|
||||||
|
|
||||||
def discover(s, plan_id, project_id, name=""):
|
|
||||||
cid = plan_id
|
|
||||||
page = X + "/study/course/" + cid
|
|
||||||
s.headers["Referer"] = page
|
|
||||||
r = s.get(page + "?v=1", timeout=30)
|
|
||||||
if "loginBtn" in r.text and "md5Uid" not in r.text:
|
|
||||||
raise _expired("学习平台会话已失效,请重新登录")
|
|
||||||
# 一门课是「章 / 节 / 活动」的目录,不是单个视频;活动清单和完成情况都从进度页取
|
|
||||||
pr = _progress(s, cid)
|
|
||||||
if not pr["acts"]:
|
|
||||||
raise RuntimeError("进度页里没有找到活动列表,页面结构可能变了")
|
|
||||||
courseware = [{
|
|
||||||
"aid": a["aid"], "title": a["title"], "done": a["done"],
|
|
||||||
"href": X + "/" + a["aid"] + "/study/course/" + cid,
|
|
||||||
"total": 0, "viewTime": 0,
|
|
||||||
} for a in pr["acts"]]
|
|
||||||
return {
|
|
||||||
"user": profile(s),
|
|
||||||
"course": name or "公需课程",
|
|
||||||
"progress": "已完成 %d/%d 个活动" % (pr["done"], pr["total"]),
|
|
||||||
"info": page,
|
|
||||||
"courseware": courseware,
|
|
||||||
"feedback": [], "test": [],
|
|
||||||
"platform": PLATFORM_ID, "generation": "gxk",
|
|
||||||
"plan_id": cid, "project_id": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _stopped(key):
|
|
||||||
with runtime.RUNS_LOCK:
|
|
||||||
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
|
|
||||||
|
|
||||||
|
|
||||||
def _relogin(key):
|
|
||||||
"""掉线后按库里存的账号密码重登,返回新会话。"""
|
|
||||||
acc = store.account_get(key) or {}
|
|
||||||
pw = store.account_password(key)
|
|
||||||
if not pw:
|
|
||||||
raise _expired("会话掉线,且没有保存密码,无法自动重连")
|
|
||||||
runtime.log(key, "会话掉线,正在用账号密码自动重新登录…", level="warn")
|
|
||||||
s, _ = login(acc.get("username") or "", pw)
|
|
||||||
runtime.log(key, "重新登录成功,继续学习")
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def _study_one(key, s, cw, speed, beat):
|
|
||||||
"""打开活动页 → 注册播放 → 1x 心跳;会话失效时抛 SessionExpired 交给上层重登。"""
|
|
||||||
try:
|
|
||||||
_sso(s) # 一门课要跑几十分钟到几小时,每个活动前续一次票
|
|
||||||
except Exception:
|
|
||||||
pass # 续票失败但老会话还能用就继续
|
|
||||||
html = s.get(cw["href"], timeout=30).text
|
|
||||||
if "loginBtn" in html and "md5Uid" not in html:
|
|
||||||
raise _expired("学习页要求重新登录")
|
|
||||||
try:
|
|
||||||
info = parse_course(html)
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError("不是视频活动(%s)" % e)
|
|
||||||
if not info["total"]:
|
|
||||||
raise RuntimeError("页面上没有「要求观看视频 N 分钟」")
|
|
||||||
cw.update(id=info["id"], uid=info["uid"], total=info["total"], viewTime=info["viewTime"])
|
|
||||||
s.headers["Referer"] = cw["href"]
|
|
||||||
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": uuid.uuid4().hex}, cw["uid"])
|
|
||||||
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, cw["uid"], sign)
|
|
||||||
runtime.log(key, "开始学习:%s(要求 %d 秒,已看 %d 秒)" % (cw["title"], cw["total"], cur))
|
|
||||||
if cur < cw["total"]:
|
|
||||||
_learn(key, s, cw, speed, beat)
|
|
||||||
else:
|
|
||||||
runtime.log(key, "服务端已记 %d 秒,达到要求,等平台刷新完成状态" % cur, level="warn")
|
|
||||||
|
|
||||||
|
|
||||||
def study(key, s, d, **opts):
|
|
||||||
"""按进度页的活动清单逐个心跳;平台只按真实时间放行,所以默认 1 倍速慢慢刷。"""
|
|
||||||
speed = float(opts.get("speed") or 1)
|
|
||||||
beat = int(opts.get("beat") or 60)
|
|
||||||
cid = d["plan_id"]
|
|
||||||
total = len(d["courseware"])
|
|
||||||
runtime.set_state(key, status="studying", total=total, done=0)
|
|
||||||
skipped = []
|
|
||||||
for i, cw in enumerate(d["courseware"], 1):
|
|
||||||
if _stopped(key):
|
|
||||||
runtime.log(key, "已按要求停止")
|
|
||||||
return
|
|
||||||
if cw.get("done"):
|
|
||||||
runtime.log(key, "活动 %d/%d 已完成,跳过:%s" % (i, total, cw["title"]))
|
|
||||||
runtime.set_state(key, done=i)
|
|
||||||
continue
|
|
||||||
for attempt in (1, 2):
|
|
||||||
try:
|
|
||||||
_study_one(key, s, cw, speed, beat)
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
expired = e.__class__.__name__ == "SessionExpired"
|
|
||||||
if expired and attempt == 1:
|
|
||||||
s = _relogin(key) # 掉线:重登后把当前活动重新走一遍
|
|
||||||
continue
|
|
||||||
if expired:
|
|
||||||
raise
|
|
||||||
runtime.log(key, "活动「%s」跳过:%s" % (cw["title"], e), level="warn")
|
|
||||||
skipped.append(cw["title"])
|
|
||||||
break
|
|
||||||
runtime.set_state(key, done=i)
|
|
||||||
# 以平台进度页为准收尾:视频秒数够 ≠ 课程完成,考核没做就是没完成
|
|
||||||
try:
|
|
||||||
pr = _progress(s, cid)
|
|
||||||
except Exception as e:
|
|
||||||
if e.__class__.__name__ != "SessionExpired":
|
|
||||||
raise
|
|
||||||
s = _relogin(key)
|
|
||||||
pr = _progress(s, cid)
|
|
||||||
act_total = pr["total"] or total
|
|
||||||
runtime.set_state(key, status="done", done=pr["done"], total=act_total)
|
|
||||||
if pr["done"] >= act_total:
|
|
||||||
runtime.log(key, "平台进度:%d/%d 个活动已完成" % (pr["done"], act_total))
|
|
||||||
else:
|
|
||||||
runtime.log(key, "平台进度:%d/%d 个活动已完成,剩余需人工处理%s"
|
|
||||||
% (pr["done"], act_total,
|
|
||||||
("(" + "、".join(skipped) + ")") if skipped else ""),
|
|
||||||
level="warn")
|
|
||||||
|
|
||||||
|
|
||||||
def _learn(key, s, cw, speed, beat):
|
|
||||||
"""心跳上报。平台只按真实流逝时间放行(1x),所以目标值按墙钟推,不靠每拍累加。"""
|
|
||||||
uid, total = cw["uid"], cw["total"]
|
|
||||||
s.headers["Referer"] = cw.get("href") or X
|
|
||||||
sid = uuid.uuid4().hex
|
|
||||||
base = {"_method": "PUT", "aid": cw["aid"]}
|
|
||||||
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, uid)
|
|
||||||
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
|
|
||||||
start, t0, stall = cur, time.time(), 0
|
|
||||||
while cur < total:
|
|
||||||
if _stopped(key):
|
|
||||||
runtime.log(key, "已按要求停止")
|
|
||||||
return
|
|
||||||
time.sleep(beat)
|
|
||||||
want = min(total, start + int((time.time() - t0) * speed))
|
|
||||||
data = dict(base, isLimit="false", md5=sign.get("md5"), expires=sign.get("time"),
|
|
||||||
md5Uid=uid, lastViewTime=str(max(want, cur)), playbackSessionId=sid)
|
|
||||||
r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1",
|
|
||||||
data=data)
|
|
||||||
if "loginBtn" in (r.text or "") or not (r.text or "").strip().startswith("{"):
|
|
||||||
raise _expired("上报被踢回登录页(HTTP %s)" % r.status_code)
|
|
||||||
j = r.json()
|
|
||||||
rd = j.get("responseData") or {}
|
|
||||||
if j.get("responseCode") == "00" and rd.get("md5"):
|
|
||||||
sign = {"md5": rd["md5"], "time": rd.get("time")}
|
|
||||||
before = cur
|
|
||||||
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
|
|
||||||
runtime.log(key, "进度上报:目标 %d 秒 / 服务端 %d 秒 / 要求 %d 秒" % (want, cur, total))
|
|
||||||
stall = stall + 1 if cur <= before else 0
|
|
||||||
if stall >= 5:
|
|
||||||
runtime.log(key, "连续 5 次上报都没被平台接受,先停这个活动", level="warn")
|
|
||||||
return
|
|
||||||
runtime.log(key, "课件完成:%s(%d 秒)" % (cw["title"], total))
|
|
||||||
|
|
||||||
|
|
||||||
PLATFORM = registry.register(Platform(
|
|
||||||
id=PLATFORM_ID,
|
|
||||||
label="广东公需课",
|
|
||||||
study=study,
|
|
||||||
entries=("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"),
|
|
||||||
session=new_session,
|
|
||||||
dump_cookies=dump_cookies,
|
|
||||||
login=login,
|
|
||||||
list_courses=list_courses,
|
|
||||||
discover=discover,
|
|
||||||
renew_session=renew_session,
|
|
||||||
profile=profile,
|
|
||||||
))
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""莞易学平台:jspx.dgjy.net(登录入口)与 study.dgjy.net(学习入口)是同一个平台。
|
|
||||||
|
|
||||||
账号在「添加账号」里选择平台(`accounts.platform`),登录 / 刷新课程 / 发现课程都会
|
|
||||||
走这里的 `login` / `list_courses` / `discover`;学习时 `discover` 会把页面代际写进
|
|
||||||
`course["generation"]`(legacy / spa),由 `_study` 分发到对应实现。
|
|
||||||
新增其他平台时,照本文件写一个模块并在 `Platform.entries` 声明登录 / 学习入口。
|
|
||||||
"""
|
|
||||||
from study_tool.platforms import jspx, registry, study
|
|
||||||
from study_tool.platforms.base import Platform
|
|
||||||
|
|
||||||
ID = "guanyixue"
|
|
||||||
LABEL = "莞易学"
|
|
||||||
# 登录入口 / 学习入口;命中任意一个就归到莞易学
|
|
||||||
ENTRIES = ("jspx.dgjy.net", "study.dgjy.net", "dgjy.net")
|
|
||||||
|
|
||||||
|
|
||||||
def _profile(s):
|
|
||||||
return jspx.whoami(jspx.fetch_admin(s))
|
|
||||||
|
|
||||||
|
|
||||||
def _study(key, s, d, **opts):
|
|
||||||
if d.get("generation") == "spa":
|
|
||||||
return study._study(key, s, d, **opts)
|
|
||||||
return jspx._study(key, s, d, **opts)
|
|
||||||
|
|
||||||
|
|
||||||
PLATFORM = registry.register(Platform(
|
|
||||||
id=ID, label=LABEL, study=_study, entries=ENTRIES,
|
|
||||||
session=jspx.new_session, dump_cookies=jspx.dump_cookies,
|
|
||||||
login=jspx.login, list_courses=jspx.scrape_courses, discover=jspx.discover,
|
|
||||||
renew_session=jspx.ensure_jspx_session, profile=_profile,
|
|
||||||
))
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""平台注册表:引擎、路由都只通过这里拿平台实现。"""
|
"""平台注册表:引擎、路由都只通过这里拿平台实现。"""
|
||||||
|
|
||||||
from study_tool.platforms.base import Platform
|
from study_tool.platforms.base import Platform
|
||||||
|
|
||||||
_PLATFORMS = {}
|
_PLATFORMS = {}
|
||||||
@@ -9,6 +10,11 @@ def register(platform: Platform) -> Platform:
|
|||||||
return platform
|
return platform
|
||||||
|
|
||||||
|
|
||||||
|
def unregister(pid):
|
||||||
|
"""停用插件 / 重载前把实现从注册表摘掉;不在表里返回 None。"""
|
||||||
|
return _PLATFORMS.pop(pid or "", None)
|
||||||
|
|
||||||
|
|
||||||
def get(pid):
|
def get(pid):
|
||||||
"""按 course["platform"] 取平台实现。"""
|
"""按 course["platform"] 取平台实现。"""
|
||||||
return _PLATFORMS.get(pid or "")
|
return _PLATFORMS.get(pid or "")
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""插件目录:`plugins/<名字>/` = 一个插件。
|
||||||
|
|
||||||
|
plugin.json 注册信息:id / title / name / kind(只写元数据,不写任何能力绑定)
|
||||||
|
main.py `Platform` 子类(kind=platform,默认)或 `notify.Channel` 子类
|
||||||
|
(kind=notify,推送渠道),import 时用 `registry.register(...)` /
|
||||||
|
`notify.register(...)` 注册自己
|
||||||
|
|
||||||
|
对外四个函数(路由 `/api/plugins*` 直接用):
|
||||||
|
|
||||||
|
load_plugins() 启动时按启用清单加载全部插件
|
||||||
|
discover() 磁盘清单 + 启用 / 已加载状态(不 import 插件代码)
|
||||||
|
set_enabled(name, bool) 启用 / 停用,立即生效并落库
|
||||||
|
reload(name=None) 重新 import(改完插件代码不用重启服务)
|
||||||
|
|
||||||
|
启用清单存在设置项 `plugins` 里(`{"disabled": [...]}`):没列进去的默认启用,
|
||||||
|
新丢进目录的插件会自动生效;停用的插件从注册表摘掉,`/api/platforms` 里不再出现。
|
||||||
|
插件在 import 时用 `@app.post(...)` 注册的接口也算插件的:停用 / 重载时一并摘除,
|
||||||
|
重载不会重复注册;整个目录删掉则启动时根本不会 import,这些接口自然不存在。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from study_tool import database, notify, runtime
|
||||||
|
from study_tool.platforms import registry
|
||||||
|
from study_tool.platforms.contract import sites_of
|
||||||
|
from study_tool.webapp import app
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
SETTING = "plugins"
|
||||||
|
_LOADED = {} # 插件名 -> (kind, 注册时用的 id),停用 / 重载时据此摘下旧实现
|
||||||
|
_ROUTES = {} # 插件名 -> 它在 app 上注册的路由,停用 / 重载时一并摘掉
|
||||||
|
|
||||||
|
|
||||||
|
def _meta(name):
|
||||||
|
meta = HERE / name / "plugin.json"
|
||||||
|
if not meta.is_file():
|
||||||
|
raise RuntimeError("没有这个插件:%s" % name)
|
||||||
|
return json.loads(meta.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _disabled():
|
||||||
|
return set((database.get_setting(SETTING) or {}).get("disabled") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def _save_disabled(names):
|
||||||
|
database.set_setting(SETTING, {"disabled": sorted(names)})
|
||||||
|
|
||||||
|
|
||||||
|
def _forget(name):
|
||||||
|
"""把该插件的模块(含子模块)从 sys.modules 摘掉,下次 import 会重新执行。"""
|
||||||
|
pkg = "%s.%s" % (__name__, name)
|
||||||
|
for mod in [m for m in sys.modules if m == pkg or m.startswith(pkg + ".")]:
|
||||||
|
del sys.modules[mod]
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_routes(routes):
|
||||||
|
"""把插件注册过的路由从 app 摘掉(按对象身份,不会误删同路径的别的路由)。"""
|
||||||
|
ids = {id(r) for r in routes}
|
||||||
|
app.router.routes[:] = [r for r in app.router.routes if id(r) not in ids]
|
||||||
|
app.openapi_schema = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name):
|
||||||
|
"""import 一个插件并确认它注册成功;重复调用就是重新加载。"""
|
||||||
|
meta = _meta(name)
|
||||||
|
pid = meta.get("id") or ""
|
||||||
|
kind = meta.get("kind") or "platform"
|
||||||
|
_unload(name) # 旧实现先下线,id 改了也不会留下残影
|
||||||
|
n = len(app.router.routes) # import 期间新增的路由都算这个插件的
|
||||||
|
try:
|
||||||
|
importlib.import_module("%s.%s.main" % (__name__, name))
|
||||||
|
obj = notify.get(pid) if kind == "notify" else registry.get(pid)
|
||||||
|
if obj is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"插件 %s 没有注册%s id=%r" % (name, "推送渠道" if kind == "notify" else "平台", pid)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_drop_routes(app.router.routes[n:])
|
||||||
|
_unregister(kind, pid)
|
||||||
|
_forget(name)
|
||||||
|
raise
|
||||||
|
_LOADED[name] = (kind, pid)
|
||||||
|
_ROUTES[name] = app.router.routes[n:]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _unregister(kind, pid):
|
||||||
|
"""按插件类型从对应注册表摘掉:平台走 registry,推送渠道走 notify。"""
|
||||||
|
return notify.unregister(pid) if kind == "notify" else registry.unregister(pid)
|
||||||
|
|
||||||
|
|
||||||
|
def _unload(name):
|
||||||
|
hit = _LOADED.pop(name, None)
|
||||||
|
if hit:
|
||||||
|
_unregister(*hit)
|
||||||
|
_drop_routes(_ROUTES.pop(name, []))
|
||||||
|
_forget(name)
|
||||||
|
|
||||||
|
|
||||||
|
def discover():
|
||||||
|
"""磁盘上的插件清单;不 import 插件代码。"""
|
||||||
|
disabled = _disabled()
|
||||||
|
out = []
|
||||||
|
for meta in sorted(HERE.glob("*/plugin.json")):
|
||||||
|
name = meta.parent.name
|
||||||
|
info = json.loads(meta.read_text(encoding="utf-8"))
|
||||||
|
pid = info.get("id") or ""
|
||||||
|
kind = info.get("kind") or "platform"
|
||||||
|
obj = notify.get(pid) if kind == "notify" else registry.get(pid)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"id": pid,
|
||||||
|
"title": info.get("title") or "",
|
||||||
|
"kind": kind,
|
||||||
|
"enabled": name not in disabled,
|
||||||
|
"loaded": obj is not None,
|
||||||
|
"label": getattr(obj, "label", "") if obj else "",
|
||||||
|
"sites": sites_of(obj) if obj is not None and kind == "platform" else [],
|
||||||
|
"capabilities": (
|
||||||
|
obj.capabilities() if kind == "platform" and obj else (["push"] if obj else [])
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_plugins():
|
||||||
|
"""启动时加载启用清单里的全部插件。幂等。"""
|
||||||
|
for item in discover():
|
||||||
|
if not item["enabled"]:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_load(item["name"])
|
||||||
|
except Exception as e:
|
||||||
|
runtime.log("system", "插件 %s 加载失败:%s" % (item["name"], e), level="warn")
|
||||||
|
|
||||||
|
|
||||||
|
def set_enabled(name, enabled):
|
||||||
|
"""启用 / 停用插件并立即生效;返回最新的插件清单。"""
|
||||||
|
if enabled:
|
||||||
|
obj = _load(name) # 先加载,失败了不写进启用清单
|
||||||
|
disabled = _disabled()
|
||||||
|
disabled.discard(name)
|
||||||
|
_save_disabled(disabled)
|
||||||
|
runtime.log("system", "插件已启用:%s(%s)" % (name, getattr(obj, "label", name)))
|
||||||
|
else:
|
||||||
|
disabled = _disabled()
|
||||||
|
disabled.add(name)
|
||||||
|
_save_disabled(disabled)
|
||||||
|
_unload(name)
|
||||||
|
runtime.log("system", "插件已停用:%s" % name)
|
||||||
|
return discover()
|
||||||
|
|
||||||
|
|
||||||
|
def reload(name=None):
|
||||||
|
"""重新 import 插件;name 为空 = 重载所有已启用的插件。"""
|
||||||
|
items = discover()
|
||||||
|
if name:
|
||||||
|
item = next((i for i in items if i["name"] == name), None)
|
||||||
|
if item is None:
|
||||||
|
raise RuntimeError("没有这个插件:%s" % name)
|
||||||
|
if not item["enabled"]:
|
||||||
|
raise RuntimeError("插件 %s 已停用,先启用再重载" % name)
|
||||||
|
names = [name]
|
||||||
|
else:
|
||||||
|
names = [i["name"] for i in items if i["enabled"]]
|
||||||
|
for n in names:
|
||||||
|
_load(n)
|
||||||
|
runtime.log("system", "插件已重载:%s" % n)
|
||||||
|
return discover()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""呆呆面板推送插件:实现在 main.py,配置在「系统设置 → 推送通知」。"""
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""呆呆面板推送:换 token(缓存到过期)+ 发通知。
|
||||||
|
|
||||||
|
接口
|
||||||
|
POST {base_url}/api/open-api/token {app_key, app_secret} -> token
|
||||||
|
POST {base_url}/api/notifications/send Authorization: Bearer <token>
|
||||||
|
|
||||||
|
配置全部来自系统设置 `notify`(base_url / app_key / app_secret / channel_name),
|
||||||
|
本文件不写死任何地址或密钥。换 token 只在内存里缓存,重启后自动重取。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from study_tool import notify
|
||||||
|
from study_tool.config.settings import load_settings
|
||||||
|
|
||||||
|
TOKEN_PATH = "/api/open-api/token"
|
||||||
|
SEND_PATH = "/api/notifications/send"
|
||||||
|
TOKEN_TTL = 3600 # 接口没给有效期时的兜底
|
||||||
|
|
||||||
|
_CACHE = {} # (base_url, app_key) -> (token, 过期时间)
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _base(cfg):
|
||||||
|
return (cfg.get("base_url") or "https://corn.hanhan.ltd").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _find(obj, names, depth=0):
|
||||||
|
"""在返回体里找字段:顶层找不到就往 data / result 里找一层。"""
|
||||||
|
if not isinstance(obj, dict) or depth > 3:
|
||||||
|
return ""
|
||||||
|
for n in names:
|
||||||
|
v = obj.get(n)
|
||||||
|
if isinstance(v, (str, int, float)) and str(v).strip():
|
||||||
|
return v
|
||||||
|
for n in ("data", "result", "content", "body"):
|
||||||
|
v = _find(obj.get(n), names, depth + 1)
|
||||||
|
if v != "":
|
||||||
|
return v
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _json(r, what):
|
||||||
|
try:
|
||||||
|
return r.json()
|
||||||
|
except Exception:
|
||||||
|
raise RuntimeError("%s 返回非 JSON(HTTP %s):%s" % (what, r.status_code, (r.text or "")[:120]))
|
||||||
|
|
||||||
|
|
||||||
|
class DaidaiPush(notify.Channel):
|
||||||
|
id = "daidai_push"
|
||||||
|
label = "呆呆面板推送"
|
||||||
|
|
||||||
|
def _token(self, cfg, force=False):
|
||||||
|
ck = (_base(cfg), cfg.get("app_key"))
|
||||||
|
with _LOCK:
|
||||||
|
hit = _CACHE.get(ck)
|
||||||
|
if hit and not force and hit[1] > time.time() + 60:
|
||||||
|
return hit[0]
|
||||||
|
r = requests.post(
|
||||||
|
ck[0] + TOKEN_PATH,
|
||||||
|
json={"app_key": cfg["app_key"], "app_secret": cfg["app_secret"]},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
j = _json(r, "换 token")
|
||||||
|
token = str(_find(j, ("token", "access_token", "accessToken")) or "")
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("换 token 失败:%s" % str(j)[:200])
|
||||||
|
try:
|
||||||
|
ttl = int(float(_find(j, ("expires_in", "expire_in", "expiresIn")) or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ttl = 0
|
||||||
|
with _LOCK:
|
||||||
|
_CACHE[ck] = (token, time.time() + (ttl or TOKEN_TTL))
|
||||||
|
return token
|
||||||
|
|
||||||
|
def send(self, title, content, context=None):
|
||||||
|
cfg = load_settings()["notify"]
|
||||||
|
if not cfg.get("app_key") or not cfg.get("app_secret"):
|
||||||
|
raise RuntimeError("推送未配置:系统设置 → 推送通知 里填 app_key / app_secret")
|
||||||
|
base = _base(cfg)
|
||||||
|
payload = {
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"content_type": "html",
|
||||||
|
"context": {str(k): v for k, v in (context or {}).items()},
|
||||||
|
}
|
||||||
|
if cfg.get("channel_name"):
|
||||||
|
payload["channel_name"] = cfg["channel_name"]
|
||||||
|
for attempt in (0, 1):
|
||||||
|
token = self._token(cfg, force=attempt == 1)
|
||||||
|
r = requests.post(
|
||||||
|
base + SEND_PATH,
|
||||||
|
headers={"Authorization": "Bearer " + token},
|
||||||
|
json=payload,
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
if r.status_code in (401, 403) and attempt == 0:
|
||||||
|
continue # token 过期:丢掉缓存换一张再发一次
|
||||||
|
j = _json(r, "推送")
|
||||||
|
ok = str(_find(j, ("code",)) or 200) in ("0", "200") or _find(j, ("success", "ok")) in (True, "true")
|
||||||
|
if r.status_code >= 400 or not ok:
|
||||||
|
raise RuntimeError("推送失败:HTTP %s %s" % (r.status_code, str(j)[:200]))
|
||||||
|
return j
|
||||||
|
raise RuntimeError("推送失败:token 被拒(换新 token 后仍然 401/403)")
|
||||||
|
|
||||||
|
|
||||||
|
NOTIFY = notify.register(DaidaiPush())
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "daidai_push",
|
||||||
|
"title": "呆呆面板推送",
|
||||||
|
"name": "daidai_push",
|
||||||
|
"kind": "notify"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""广东公需课插件:实现在 `main.py`,注册信息在 `plugin.json`。"""
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
answer = [
|
||||||
|
{
|
||||||
|
"workId": "a_22f272254f7a427e8b7a6d2373753caf",
|
||||||
|
"answer": [
|
||||||
|
'A', 'D', 'B', 'B', 'A', 'D', 'B', 'B', 'D', 'B',
|
||||||
|
'ABCD', 'ABCDE', 'ABC', 'ACD', 'ABC', 'ABCD', 'ABCD', 'ABD', 'ABCD', 'ABC',
|
||||||
|
'A', 'A', 'B', 'B', 'B', 'B', 'A', 'A', 'A', 'B'
|
||||||
|
],
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"workId": "",
|
||||||
|
"answer": [
|
||||||
|
'A', 'C', 'D', 'D', 'B', 'D', 'B', 'B', 'D', 'B',
|
||||||
|
'ABC', 'ABC', 'AB', 'ABC', 'ABCD', 'ABCD', 'ABCD', 'BCD', 'BCD', 'ABC',
|
||||||
|
'A', 'B', 'A', 'B', 'B', 'B', 'A', 'B', 'B', 'A'
|
||||||
|
],
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
]
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"q": "生成式人工智能(GenAI)目前还没有在教育领域得到广泛应用。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "人工智能(AI)是指计算机系统模拟类似人类的认知能力,如学习、决策和解决问题的能力。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "生成式人工智能工具没有任何局限性,可以完全替代人类完成创作工作。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "ChatGPT 生成的参考文献、作者、URL等,有可能是编造的、不存在的。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "早期的机器人关节以一体化关节为主,将电机、减速器等部件深度融合设计。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "具身智能只需要实现视觉能力,不需要语言和行动能力。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "OpenClaw本身是收费软件,使用时会直接产生高额费用。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "为保障安全,建议不要在主电脑上直接运行 OpenClaw,可在专用备用电脑、云服务器或Docker 容器中运行。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "传统科研与AI驱动科研的之间关键转变 : 从“ 人驱动\"到 ”AI智能 \" 驱动。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "人工智能驱动的新型科研范式,本质上是在提升科研活动的系统效率、认知效率和处理效率。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "科学大模型建设的三大意义:支撑前沿科学问题研究、支撑交叉学科融合创新、支撑从科研到应用的衔接。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "未来科研平台竞争维度包括:硬件竞争、数据能力竞争、模型能力竞争、智能协同能力竞争。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "创新研发模式五大支柱:数据、模型、模仿、实验、反馈。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "6G不只是更快的通信,更重要的是网络、感知、计算、智能全面融合。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "社科研究的原因:技术发展速度远超伦理规范制定速度,技术能力边界扩大带来责任边界扩大,缺乏价值引导的技术可能走向“ 恶” 的方向。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "AI是道德中立的工具,它携带了开发者的价值观与社会偏见。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "执行型智能体不具备感知能力,仅能执行任务。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "OpenClaw集成的物理信息神经网络(PINNs)技术,属于物理信息机器学习的范畴。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "售前AI推送是根据用户的消费行为、兴趣爱好等信息,向用户推送个性化商品或服务的智能方式。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "仿真设定的参数仅包含激光功率和扫描速度,不涉及基板预热温度。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "智能AI质检中,运用卷积神经网络和目标检测模型可实现缺陷图像的特征识别及分类。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "三条训练损失曲线从高位快速下降后出现明显反弹波动,说明模型学习过程正常,未出现过拟合欠拟合。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "AI视觉系统仅能识别田间的杂草,无法识别病害斑点,因此无法实现精准施药。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "系统可根据实时水质监测数据,远程控制增氧泵、底部增氧机等设备,实现水质的远程调控。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "2025 年 12月,字节推出了 “豆包手机助手 ”正式版,合作伙伴包含中兴努比亚(M153工程机)。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "AI+消费在技术层面的核心挑战,仅包含多模态大模型消费应用的自主可控能力建设。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "家政服务机器人3.0时代的目标是打造具备通用家务能力的人形机器人,成为家庭“全面手” 。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "家用服务机器人的 “安全性 ” 特质,仅需避免物理碰撞,无需完善的主动安全防护机制。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "智能家居行业竞争的核心,目前仍局限于单一产品的功能参数比拼。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "MOVVA 推出的追觅 “ 天穹” 系列芯片尚未量产,还处于研发测试阶段。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "“ 脑机捕捉意图 ” 的核心作用是直接生成个性化数字内容,完成交互闭环的最后一步。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "伦理制度化的核心目标,是推动行业监管从原则倡导向制度化落地转变。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "ChatGPT 的本质是一个拥有过亿参数的大模型,其参数可通过机器学习自动找出。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "代码生成模块的能力包含根据需求生成 Python 、 JavaScript等语言的代码片段,以及自动补全与注释生成。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "使用DeepSeek推理模型时,需通过显式步骤拆解任务,强行拆解步骤能更好发挥能力。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "根据DeepSeek提示词技巧,提示词表达越模糊越像日常对话,就能获得更好效果。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "18世纪普鲁士人最先实施发明的课堂教学模式中,核心形式是学生自主讲解知识,教师负责聆听指导。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "生成式AI目前已具备人类的数学直觉能力,可完全替代人类进行创造性数学研究。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "人工智能+金融阶段的发展,建立在 IT信息系统稳定可靠、互联网环境成熟基础之上。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "2018 年谷歌借鉴 ELMo和GPT,推出大规模语言模型BERT,掀起NLP第二次大变革,进入魔改BERT时代。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "CTF-Net 是纯 CNN架构网络,专门解决纯Transformer架构全局感受野不足问题。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "城市交通系统是规模巨大、关联复杂、随机性强、混合交通流的大系统,利用交通大数据解决预报控制难题极具挑战性。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "MBA-Net模型需要依赖辅助模型,才能为被遮挡行人图像生成鲁棒表示特征。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "数据科学进步,推动复杂系统研究从数据机理融合驱动,向经验驱动、机理驱动范式变革。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "针对新建光伏电站历史运行数据不足,方案提出基于迁移学习的模型微调,可借同类站点提升发电量预测精度。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "人工智能大模型工程伦理问题,核心仅涉及科技法律二维,与权利、道德、生命维度无关。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "《人工智能能力建设普惠计划》2025年12月正式发布,聚焦发展中国家AI能力,缩小数字鸿沟。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "世界人工智能合作组织倡议2025年7月提出,总部拟设于上海。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "中国人工智能2025—2026重点:多模态突破、类脑智能、国产算力、产业落地。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "中国AI发展理念:以技术为本、智能向快。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "中国大模型首次用于地震救援,DeepSeek仅用7小时开发中缅应急系统。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "中国AI诊所落地沙特,全球首个AI诊所,覆盖30种呼吸疾病,诊断准确率近100%。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "AI技术法国家庭节电25%-30%,为联合国气候平台首批伙伴。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "未来AI注重普惠,缩小数字鸿沟、均衡发展。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "核心技术依赖进口、产品同质化;中小企业研发强度1.3%,远低于发达国家3%,难破高端市场。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "2025年基础理论重大突破、部分技术世界领先,成为产业升级经济转型主要动力。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "《“人工智能+制造”专项行动》保障:推进机制、资金支持、应用推广、监测评估。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "工业母机柔性化:AI深度融入数控,实现自适应作业、智能诊断、智能调度,大幅提升利用率精度。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "数据治理五步法:汇聚、清洗、标注、增强、治理。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "AI价值:驱动“新三样”从规模出海转向高质量出海,构建全球竞争壁垒。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "未来工厂三大特征:全流程自主运行、虚实实时同步、智能持续迭代。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "正视挑战、技术创新、标准统一,释放工业数据价值,实现智能化转型跨越。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "广东区域创新能力连续9年全国第一。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "广东高技术制造业占规上工业比重不足三成。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "耐心资本渠道:财政专项资金、政府投资基金、地方政府专项债、超长期特别国债。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "成长期赛道企业可在北交所、广东区域性股权市场上市。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "战略性新兴产业是新质生产力主要载体。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "核心驱动力:新质生产力推动战略性新兴产业持续升级。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "广东游戏营收、数字出版产值、动漫产值、电影票房等指标全国第一。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "截至2023年底,广东激光与增材制造产业有效发明专利全国第一。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "广东战略性新兴产业指导思想:加快从制造大省到制造强省转变。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "广东战略性新兴产业定位:打造世界先进水平产业基地。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "新质生产力理论是马克思主义生产力理论创新,丰富发展政治经济学。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "产业集群成长期竞争优势完全成熟,全球领先。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "新质生产力支撑仅含技术创新,不含数字化转型与人才支撑。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "2024年广东数字经济增加值占GDP持续提升,连续多年全国第一。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "工业机器人上游核心零部件:减速器、伺服电机、控制器,技术壁垒最高。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "小六轴机器人参与者少,国内暂无优势。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "腾讯音乐涨价短期拉动营收,不影响付费转化率。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "东伦敦科技城经验:无政府资金支持相关设施。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "新质生产力以数据为关键要素,强调知识创新应用。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "产业布局是产业发展规律体现,指一国地区产业空间分布组合。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "智能化技术底座:人工智能、大数据、云计算、5G、互联网。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "转型成效评估:结构优化、创新驱动、集群发展质量。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "家电产业链行为:产品差异化、并购、研发、价格行为。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "全球传统产业转移趋势:区域化、成本化、绿色化、多元化。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "广东传统产业升级原则:创新驱动、重点突破、开放合作、畅通循环、质效优先、绿色发展、市场主导、政府引导。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "构建高效服务业体系:推动传统服务向现代服务转型。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "佛山南海开通全国常态化无人机外卖航线。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "服务业4.0:大数据、AI、IoT驱动无缝服务演进。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "大都市创新城市:战略性新兴产业与服务业融合,推动产业升级高质量发展。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "2015-2020广东服务业增加值全国第一。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "广东服务业人才瓶颈:金融、信息、科研等领域高端人才短缺。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "现代服务业与先进制造、现代农业深度融合是优质服务业体系重点。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "服务业监管:企业自律+行业自律+政府监管+社会参与多元共治。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "1978-1999中国家电研发强、核心器件自主,不依赖进口。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "2019中国家电零售8910亿,行业成熟。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "2018-2024小米IoT与生活消费品收入占比25.1%→28.5%,结构多元。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "小米家电早期高性价比:入门定价、核心功能聚焦、快速抢占份额。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "2024小米之家约1.5万家,构建人车家全生态。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "小米电视低价互联网模式:硬件不赚钱,软件盈利。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "改革开放初期家电政策核心:扩大内需、全球化发展。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "2011-2019家电线上渠道10.4%→25.4%,社交电商、直播带货增长。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "我国人口转变特征:迅速性、政策干预、不彻底、不稳定、不平衡。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "广东20个战略性产业集群增加值占GDP17%,全国约40%。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "我国人口最多,人口发展事关民族复兴。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "中国人口转变2050年呈倒U型。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "人口高质量发展核心:规模、结构、质量、分布四维。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "经济高质量发展是中国式现代化物质根基与核心支撑。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "欧盟《新电池法》2024.8.17生效,禁止供应链碳足迹追溯。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "绿色转型阵痛:传统产业退出导致失业、地方财政下行等短期压力。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "截至2025年底氢燃料电池汽车销量近4万,实现0-1产业化突破。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "AI巡检提升效率8倍,成本降15%-30%。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "2026政策逻辑从控碳转向控能,核心惩罚能耗总量、限制发展空间。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "碳市场扩容后覆盖碳排放占比60%→30%+。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "高质量、绿色共生:摒弃高耗能高污染,实现经济生态协同。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "第一次生产力革命(蒸汽)标志:蒸汽机使用。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "工业5.0是工业4.0延续,新增绿色可持续与人机协作。",
|
||||||
|
"a": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "欧盟模式核心武器:硅谷+华尔街,对标美国法条标准。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "上海土地充足、生活成本低,传统劳动密集产业有优势。",
|
||||||
|
"a": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"q": "合肥创新模式:市场雨林型,靠供应链与市场活力。",
|
||||||
|
"a": "B"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"year": "",
|
||||||
|
"pick": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""广东公需课:jsglpt 登录 → jsxx 学习。
|
||||||
|
|
||||||
|
登录在 `jsglpt.gds.edu.cn`(账号 / 密码都是 base64),成功后用
|
||||||
|
`ssoItlmsGxkTicket.action` 换 ITLMS 票,带票进 `jsxx.gds.edu.cn` 拿学习会话;
|
||||||
|
两个域名的 cookie 都留在同一个 requests.Session 里。
|
||||||
|
|
||||||
|
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度靠 `updateViewTime1`
|
||||||
|
心跳上报(`lastViewTime` 是已看秒数,页面上「要求观看视频 N 分钟」才是完成门槛)。
|
||||||
|
平台按真实流逝时间放行(实测一次报 +300 只记 +60),所以只能 1 倍速慢慢刷。
|
||||||
|
|
||||||
|
本文件只留 `Platform` 子类与注册,工具按阶段拆到 `utils/`(登录 / 课程解析 / 心跳)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from study_tool import database, notify, runtime
|
||||||
|
from study_tool.platforms import registry
|
||||||
|
from study_tool.platforms.base import Platform
|
||||||
|
|
||||||
|
from .utils import course, exam, http, learn, login, register
|
||||||
|
|
||||||
|
|
||||||
|
MAX_ROUNDS = 5 # 最多「刷一轮 → 重取进度页」的轮数
|
||||||
|
MAX_STALL = 3 # 连续这么多轮平台完成数没增加就放弃,避免非视频任务点死循环
|
||||||
|
ROUND_WAIT = 60 # 两轮之间等平台刷新完成状态的秒数
|
||||||
|
EXAM_MARKS = ("考核", "考试") # 课程目录里这两类活动是试卷
|
||||||
|
|
||||||
|
|
||||||
|
def _exam_activity(courseware):
|
||||||
|
"""课程目录里的考核活动:考核是独立活动页(/<aid>/study/course/<cid>),题目只在它上面。"""
|
||||||
|
return next((c for c in courseware or [] if any(k in (c.get("title") or "") for k in EXAM_MARKS)), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _courseware(cid, acts):
|
||||||
|
"""进度页的活动清单 → 引擎用的 courseware 条目。"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"aid": a["aid"],
|
||||||
|
"title": a["title"],
|
||||||
|
"done": a["done"],
|
||||||
|
"href": http.X + "/" + a["aid"] + "/study/course/" + cid,
|
||||||
|
"total": 0,
|
||||||
|
"viewTime": 0,
|
||||||
|
}
|
||||||
|
for a in acts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Gdgxk(Platform):
|
||||||
|
"""广东公需课:jsglpt 登录(账号 / 密码都是 base64 + 图形验证码),jsxx 学习。
|
||||||
|
|
||||||
|
账号级能力都实现在这里;反馈表 / 测验这类平台没有的阶段不实现,保持基类的 None。
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = http.PLATFORM_ID
|
||||||
|
label = "广东公需课"
|
||||||
|
entries = ("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn")
|
||||||
|
|
||||||
|
def session(self, cookies=None):
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers.update({"User-Agent": http.UA, "Accept-Language": "zh-CN,zh;q=0.9"})
|
||||||
|
for c in cookies or []:
|
||||||
|
if "gds.edu.cn" not in (c.get("domain") or ""):
|
||||||
|
continue
|
||||||
|
s.cookies.set(
|
||||||
|
c["name"], c.get("value") or "", domain=(c.get("domain") or "").lstrip("."), path=c.get("path") or "/"
|
||||||
|
)
|
||||||
|
return s
|
||||||
|
|
||||||
|
def dump_cookies(self, s):
|
||||||
|
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path} for c in s.cookies]
|
||||||
|
|
||||||
|
def login(self, username, password):
|
||||||
|
s = self.session()
|
||||||
|
s.get(http.G + "/login.jsp", timeout=30)
|
||||||
|
err = ""
|
||||||
|
for _ in range(5):
|
||||||
|
data = {
|
||||||
|
"loginUser.userName": login.b64(username),
|
||||||
|
"loginUser.loginType": "normal",
|
||||||
|
"loginUser.password": login.b64(password),
|
||||||
|
"captcha": login.ocr().classification(login.captcha(s)),
|
||||||
|
}
|
||||||
|
r = s.post(http.G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
|
||||||
|
if r.status_code == 302 and "/default.jsp" in (r.headers.get("location") or ""):
|
||||||
|
login.sso(s)
|
||||||
|
return s, self.profile(s) or username
|
||||||
|
err = login.login_error(s)
|
||||||
|
if "验证码" not in err: # 不是验证码的问题,重试没意义
|
||||||
|
break
|
||||||
|
raise RuntimeError("登录失败:%s" % (err or "账号或密码不对"))
|
||||||
|
|
||||||
|
def renew_session(self, s):
|
||||||
|
try:
|
||||||
|
login.sso(s)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def profile(self, s):
|
||||||
|
"""个人中心里平台显示的用户名:学员是打码姓名(如「*栋」),老师是完整姓名。"""
|
||||||
|
html = s.get(http.G + "/teacher/index.do", timeout=30).text
|
||||||
|
# 左上角头像区:<a href="javascript:;" class="name" ...>姓名</a>
|
||||||
|
m = re.search(r'class="name"[^>]*>([^<]{1,20})</a>', html)
|
||||||
|
if m and m.group(1).strip():
|
||||||
|
return m.group(1).strip()
|
||||||
|
m = re.search(r"你好[,,]?\s*([^\s<]+)\s*老师", re.sub(r"\s+", " ", html))
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
def list_courses(self, s):
|
||||||
|
html = s.get(http.X + "/uc/store/courseRegister", timeout=30).text
|
||||||
|
out = course.parse_courses(html)
|
||||||
|
if not out and "logout" not in html:
|
||||||
|
raise http.expired("学习平台会话已失效,请重新登录")
|
||||||
|
# 没有目标年度的课:去选课超市选一门,再重新读列表
|
||||||
|
if not any(register.config()[0] in c["section"] for c in out) and register.ensure(s):
|
||||||
|
out = course.parse_courses(s.get(http.X + "/uc/store/courseRegister", timeout=30).text)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def discover(self, s, plan_id, project_id, name=""):
|
||||||
|
cid = plan_id
|
||||||
|
page = http.X + "/study/course/" + cid
|
||||||
|
s.headers["Referer"] = page
|
||||||
|
r = s.get(page + "?v=1", timeout=30)
|
||||||
|
if "loginBtn" in r.text and "md5Uid" not in r.text:
|
||||||
|
raise http.expired("学习平台会话已失效,请重新登录")
|
||||||
|
# 一门课是「章 / 节 / 活动」的目录,不是单个视频;活动清单和完成情况都从进度页取
|
||||||
|
pr = course.progress(s, cid)
|
||||||
|
if not pr["acts"]:
|
||||||
|
raise RuntimeError("进度页里没有找到活动列表,页面结构可能变了")
|
||||||
|
courseware = _courseware(cid, pr["acts"])
|
||||||
|
return {
|
||||||
|
"user": self.profile(s),
|
||||||
|
"course": name or "公需课程",
|
||||||
|
"progress": "已完成 %d/%d 个活动" % (pr["done"], pr["total"]),
|
||||||
|
"info": page,
|
||||||
|
"courseware": courseware,
|
||||||
|
"feedback": [],
|
||||||
|
"test": [],
|
||||||
|
"platform": http.PLATFORM_ID,
|
||||||
|
"generation": "gxk",
|
||||||
|
"plan_id": cid,
|
||||||
|
"project_id": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def study(self, key, s, d, **opts):
|
||||||
|
"""按进度页的活动清单逐个心跳;平台只按真实时间放行,所以默认 1 倍速慢慢刷。"""
|
||||||
|
speed = float(opts.get("speed") or 1)
|
||||||
|
beat = int(opts.get("beat") or 60)
|
||||||
|
cid = d["plan_id"]
|
||||||
|
cws = d["courseware"]
|
||||||
|
runtime.set_state(key, status="studying", total=len(cws), done=0)
|
||||||
|
best, stall, pr, remain = -1, 0, {"done": 0, "total": len(cws)}, []
|
||||||
|
for rnd in range(1, MAX_ROUNDS + 1):
|
||||||
|
todo = [c for c in cws if not c.get("done")]
|
||||||
|
base = len(cws) - len(todo)
|
||||||
|
for i, cw in enumerate(todo, 1):
|
||||||
|
if learn.stopped(key):
|
||||||
|
runtime.log(key, "已按要求停止")
|
||||||
|
return
|
||||||
|
for attempt in (1, 2):
|
||||||
|
try:
|
||||||
|
learn.study_one(key, s, cw, speed, beat)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
expired = e.__class__.__name__ == "SessionExpired"
|
||||||
|
if expired and attempt == 1:
|
||||||
|
s = self._relogin(key) # 掉线:重登后把当前活动重新走一遍
|
||||||
|
continue
|
||||||
|
if expired:
|
||||||
|
raise
|
||||||
|
runtime.log(key, "活动「%s」本轮跳过:%s" % (cw["title"], e), level="warn")
|
||||||
|
break
|
||||||
|
runtime.set_state(key, done=base + i)
|
||||||
|
# 以平台进度页为准:不管本轮刷了几个(包括一个都不用刷),都重取任务点状态
|
||||||
|
s, pr = self._progress(key, s, cid)
|
||||||
|
cws = _courseware(cid, pr["acts"])
|
||||||
|
remain = [c["title"] for c in cws if not c["done"]]
|
||||||
|
runtime.set_state(key, done=pr["done"], total=pr["total"])
|
||||||
|
if pr["done"] >= pr["total"] and not remain:
|
||||||
|
break
|
||||||
|
stall = 0 if pr["done"] > best else stall + 1
|
||||||
|
best = pr["done"]
|
||||||
|
if stall >= MAX_STALL:
|
||||||
|
break
|
||||||
|
runtime.log(
|
||||||
|
key,
|
||||||
|
"第 %d 轮结束,平台进度 %d/%d,仍有 %d 个任务点未完成,稍后自动重新学习:%s"
|
||||||
|
% (rnd, pr["done"], pr["total"], len(remain), "、".join(remain) or "(进度页未列出具体任务点)"),
|
||||||
|
level="warn",
|
||||||
|
)
|
||||||
|
notify.retry(
|
||||||
|
key,
|
||||||
|
"未完成任务点自动重学",
|
||||||
|
"平台进度 %d/%d,第 %d 轮仍有 %d 个任务点未完成" % (pr["done"], pr["total"], rnd, len(remain)),
|
||||||
|
)
|
||||||
|
if not learn.wait(key, ROUND_WAIT):
|
||||||
|
runtime.log(key, "已按要求停止")
|
||||||
|
return
|
||||||
|
if remain: # 视频都刷完了还有缺口,多半是课程考核;交完卷再按进度页复核一次
|
||||||
|
cw = _exam_activity(cws)
|
||||||
|
if cw:
|
||||||
|
s = self._exam(key, s, cw["href"], opts)
|
||||||
|
s, pr = self._progress(key, s, cid)
|
||||||
|
remain = [c["title"] for c in _courseware(cid, pr["acts"]) if not c["done"]]
|
||||||
|
runtime.set_state(key, status="done", done=pr["done"], total=pr["total"])
|
||||||
|
if pr["done"] >= pr["total"] and not remain:
|
||||||
|
runtime.log(key, "平台进度:%d/%d 个活动已完成" % (pr["done"], pr["total"]))
|
||||||
|
else:
|
||||||
|
runtime.log(
|
||||||
|
key,
|
||||||
|
"平台进度:%d/%d 个活动已完成,自动重学后仍未完成,需人工处理%s"
|
||||||
|
% (pr["done"], pr["total"], ("(" + "、".join(remain) + ")") if remain else ""),
|
||||||
|
level="warn",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _exam(self, key, s, url, opts):
|
||||||
|
"""课程考核:题库优先、大模型兜底;题库没覆盖全部题目就不交卷。掉线重登后重试一次。"""
|
||||||
|
auto = opts.get("exam_auto")
|
||||||
|
auto = True if auto is None else bool(auto)
|
||||||
|
try:
|
||||||
|
s, msg = exam.run(key, s, url, auto)
|
||||||
|
except Exception as e:
|
||||||
|
if e.__class__.__name__ != "SessionExpired":
|
||||||
|
runtime.log(key, "考核处理失败:%s" % e, level="warn")
|
||||||
|
return s
|
||||||
|
s = self._relogin(key)
|
||||||
|
try:
|
||||||
|
s, msg = exam.run(key, s, url, auto)
|
||||||
|
except Exception as e2:
|
||||||
|
runtime.log(key, "考核处理失败:%s" % e2, level="warn")
|
||||||
|
return s
|
||||||
|
if msg:
|
||||||
|
runtime.set_state(key, status="exam", total=1, done=1)
|
||||||
|
return s
|
||||||
|
|
||||||
|
def quiz(self, s, d, b):
|
||||||
|
"""课程考核的题目:考核是独立活动页,解析它上面的 testForm。已交卷也返回题目,方便回看。"""
|
||||||
|
cw = _exam_activity(d.get("courseware"))
|
||||||
|
if not cw:
|
||||||
|
return {"ok": False, "error": "这门课没有考核"}
|
||||||
|
cid = d["plan_id"]
|
||||||
|
html = s.get(cw["href"], timeout=30).text
|
||||||
|
done = exam.finished(html)
|
||||||
|
ex = exam.parse(html)
|
||||||
|
questions = [
|
||||||
|
{
|
||||||
|
"no": str(q["no"]),
|
||||||
|
"type": exam.TYPE_CODE.get(q["type"], "01"),
|
||||||
|
"title": q["title"],
|
||||||
|
"options": [{"key": o["key"], "text": o["text"]} for o in q["options"]],
|
||||||
|
}
|
||||||
|
for q in (ex or {}).get("questions") or []
|
||||||
|
]
|
||||||
|
if not done and not questions:
|
||||||
|
return {"ok": False, "error": "考核页里没有找到题目(页面结构可能变了)"}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"platform": http.PLATFORM_ID,
|
||||||
|
"plan_id": cid,
|
||||||
|
"object_id": cw["aid"],
|
||||||
|
"title": "课程考核",
|
||||||
|
"done": done,
|
||||||
|
"questions": questions,
|
||||||
|
}
|
||||||
|
|
||||||
|
def quiz_submit(self, s, b):
|
||||||
|
"""弹窗里点交卷:按用户选的字母答案提交,返回得分。"""
|
||||||
|
if not b.object_id:
|
||||||
|
return {"ok": False, "error": "缺少考核活动 ID,请重新打开测验"}
|
||||||
|
url = http.X + "/" + b.object_id + "/study/course/" + b.plan_id
|
||||||
|
return exam.submit_letters(s, url, b.answers)
|
||||||
|
|
||||||
|
def _progress(self, key, s, cid):
|
||||||
|
"""取进度页;掉线就重登后再取一次,返回 (会话, 进度)。"""
|
||||||
|
try:
|
||||||
|
return s, course.progress(s, cid)
|
||||||
|
except Exception as e:
|
||||||
|
if e.__class__.__name__ != "SessionExpired":
|
||||||
|
raise
|
||||||
|
notify.retry(key, "会话掉线重登", "取进度页时会话失效,自动重新登录")
|
||||||
|
s = self._relogin(key)
|
||||||
|
return s, course.progress(s, cid)
|
||||||
|
|
||||||
|
def _relogin(self, key):
|
||||||
|
"""掉线后按库里存的账号密码重登,返回新会话。"""
|
||||||
|
acc = database.account_get(key) or {}
|
||||||
|
pw = database.account_password(key)
|
||||||
|
if not pw:
|
||||||
|
raise http.expired("会话掉线,且没有保存密码,无法自动重连")
|
||||||
|
runtime.log(key, "会话掉线,正在用账号密码自动重新登录…", level="warn")
|
||||||
|
s, _ = self.login(acc.get("username") or "", pw)
|
||||||
|
# 新会话要写回账号和运行状态:否则库里留的还是刚失效的那份,
|
||||||
|
# 下次「刷新课程」立刻又判过期,反复触发重登。
|
||||||
|
cookies = self.dump_cookies(s)
|
||||||
|
database.account_save_cookies(key, cookies)
|
||||||
|
runtime.set_state(key, cookies=cookies)
|
||||||
|
runtime.log(key, "重新登录成功,继续学习")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORM = registry.register(Gdgxk())
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"id": "gdgxk",
|
||||||
|
"title": "广东省教师继续教育管理平台",
|
||||||
|
"name": "gdgxk"
|
||||||
|
}
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name 2026广东公需课·全自动完美版(后台保活+10分钟刷新)
|
||||||
|
// @namespace http://tampermonkey.net/
|
||||||
|
// @version 1.3
|
||||||
|
// @description 超强防掉线+后台保活不暂停+10分钟刷新+题库优先+自动考试
|
||||||
|
// @author 广东数学张叔
|
||||||
|
// @match https://jsxx.gds.edu.cn/*
|
||||||
|
// @grant none
|
||||||
|
// @run-at document-end
|
||||||
|
// @license MIT
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 基础配置
|
||||||
|
const QUICK_DELAY_MIN = 600;
|
||||||
|
const QUICK_DELAY_MAX = 1400;
|
||||||
|
let panel = null, progressText = null;
|
||||||
|
let isAnswering = false;
|
||||||
|
let activeTimer = null;
|
||||||
|
|
||||||
|
// 兜底遍历(仅视频题使用)
|
||||||
|
let bruteForceRound = 0;
|
||||||
|
const MAX_BRUTE_ROUND = 12;
|
||||||
|
|
||||||
|
// 强制看完视频再下一节
|
||||||
|
const FORCE_WATCH_END = true;
|
||||||
|
let videoWatched = false;
|
||||||
|
|
||||||
|
// 脚本开关
|
||||||
|
let scriptEnabled = true;
|
||||||
|
let isMinimized = false;
|
||||||
|
|
||||||
|
// ==============================================
|
||||||
|
// 后台保活:防止浏览器后台暂停脚本
|
||||||
|
// ==============================================
|
||||||
|
function enableBackgroundKeepAlive() {
|
||||||
|
setInterval(() => { }, 1000);
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
console.log('页面进入后台,继续保活');
|
||||||
|
} else {
|
||||||
|
console.log('页面回到前台');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('blur', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.dispatchEvent(new Event('focus'));
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================
|
||||||
|
// 自动刷新:每10分钟刷新一次页面
|
||||||
|
// ==============================================
|
||||||
|
const REFRESH_INTERVAL = 10 * 60 * 1000;
|
||||||
|
let lastRefreshTime = Date.now();
|
||||||
|
|
||||||
|
function checkAutoRefresh() {
|
||||||
|
if (!scriptEnabled) return;
|
||||||
|
if (Date.now() - lastRefreshTime > REFRESH_INTERVAL) {
|
||||||
|
console.log("已挂机10分钟,自动刷新保活");
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================
|
||||||
|
// 考核答案:双套识别逻辑【已反转,解决反了问题】
|
||||||
|
// ==============================================
|
||||||
|
function getExamAnswers() {
|
||||||
|
const idVal = document.querySelector('.aidHidden.ing')?.value;
|
||||||
|
if (idVal === 'a_22f272254f7a427e8b7a6d2373753caf') {
|
||||||
|
// 现在这里放第二套答案
|
||||||
|
return [
|
||||||
|
'A','D','B','B','A','D','B','B','D','B',
|
||||||
|
'ABCD','ABCDE','ABC','ACD','ABC','ABCD','ABCD','ABD','ABCD','ABC',
|
||||||
|
'A','A','B','B','B','B','A','A','A','B'
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// 现在这里放第一套答案
|
||||||
|
return [
|
||||||
|
'A','C','D','D','B','D','B','B','D','B',
|
||||||
|
'ABC','ABC','AB','ABC','ABCD','ABCD','ABCD','BCD','BCD','ABC',
|
||||||
|
'A','B','A','B','B','B','A','B','B','A'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================
|
||||||
|
// 视频题库
|
||||||
|
// ==============================================
|
||||||
|
const videoBank = new Map();
|
||||||
|
|
||||||
|
// 专题1
|
||||||
|
videoBank.set("生成式人工智能(GenAI)目前还没有在教育领域得到广泛应用。", "B");
|
||||||
|
videoBank.set("人工智能(AI)是指计算机系统模拟类似人类的认知能力,如学习、决策和解决问题的能力。", "A");
|
||||||
|
videoBank.set("生成式人工智能工具没有任何局限性,可以完全替代人类完成创作工作。", "B");
|
||||||
|
videoBank.set("ChatGPT 生成的参考文献、作者、URL等,有可能是编造的、不存在的。", "A");
|
||||||
|
videoBank.set("早期的机器人关节以一体化关节为主,将电机、减速器等部件深度融合设计。", "B");
|
||||||
|
videoBank.set("具身智能只需要实现视觉能力,不需要语言和行动能力。", "B");
|
||||||
|
videoBank.set("OpenClaw本身是收费软件,使用时会直接产生高额费用。", "B");
|
||||||
|
videoBank.set("为保障安全,建议不要在主电脑上直接运行 OpenClaw,可在专用备用电脑、云服务器或Docker 容器中运行。", "A");
|
||||||
|
videoBank.set("传统科研与AI驱动科研的之间关键转变 : 从“ 人驱动\"到 ”AI智能 \" 驱动。", "A");
|
||||||
|
videoBank.set("人工智能驱动的新型科研范式,本质上是在提升科研活动的系统效率、认知效率和处理效率。", "A");
|
||||||
|
videoBank.set("科学大模型建设的三大意义:支撑前沿科学问题研究、支撑交叉学科融合创新、支撑从科研到应用的衔接。", "A");
|
||||||
|
videoBank.set("未来科研平台竞争维度包括:硬件竞争、数据能力竞争、模型能力竞争、智能协同能力竞争。", "A");
|
||||||
|
videoBank.set("创新研发模式五大支柱:数据、模型、模仿、实验、反馈。", "B");
|
||||||
|
videoBank.set("6G不只是更快的通信,更重要的是网络、感知、计算、智能全面融合。", "A");
|
||||||
|
videoBank.set("社科研究的原因:技术发展速度远超伦理规范制定速度,技术能力边界扩大带来责任边界扩大,缺乏价值引导的技术可能走向“ 恶” 的方向。", "A");
|
||||||
|
videoBank.set("AI是道德中立的工具,它携带了开发者的价值观与社会偏见。", "A");
|
||||||
|
videoBank.set("执行型智能体不具备感知能力,仅能执行任务。", "B");
|
||||||
|
videoBank.set("OpenClaw集成的物理信息神经网络(PINNs)技术,属于物理信息机器学习的范畴。", "A");
|
||||||
|
videoBank.set("售前AI推送是根据用户的消费行为、兴趣爱好等信息,向用户推送个性化商品或服务的智能方式。", "A");
|
||||||
|
videoBank.set("仿真设定的参数仅包含激光功率和扫描速度,不涉及基板预热温度。", "B");
|
||||||
|
videoBank.set("智能AI质检中,运用卷积神经网络和目标检测模型可实现缺陷图像的特征识别及分类。", "A");
|
||||||
|
videoBank.set("三条训练损失曲线从高位快速下降后出现明显反弹波动,说明模型学习过程正常,未出现过拟合欠拟合。", "B");
|
||||||
|
videoBank.set("AI视觉系统仅能识别田间的杂草,无法识别病害斑点,因此无法实现精准施药。", "B");
|
||||||
|
videoBank.set("系统可根据实时水质监测数据,远程控制增氧泵、底部增氧机等设备,实现水质的远程调控。", "A");
|
||||||
|
videoBank.set("2025 年 12月,字节推出了 “豆包手机助手 ”正式版,合作伙伴包含中兴努比亚(M153工程机)。", "A");
|
||||||
|
videoBank.set("AI+消费在技术层面的核心挑战,仅包含多模态大模型消费应用的自主可控能力建设。", "B");
|
||||||
|
videoBank.set("家政服务机器人3.0时代的目标是打造具备通用家务能力的人形机器人,成为家庭“全面手” 。", "A");
|
||||||
|
videoBank.set("家用服务机器人的 “安全性 ” 特质,仅需避免物理碰撞,无需完善的主动安全防护机制。", "B");
|
||||||
|
videoBank.set("智能家居行业竞争的核心,目前仍局限于单一产品的功能参数比拼。", "B");
|
||||||
|
videoBank.set("MOVVA 推出的追觅 “ 天穹” 系列芯片尚未量产,还处于研发测试阶段。", "B");
|
||||||
|
videoBank.set("“ 脑机捕捉意图 ” 的核心作用是直接生成个性化数字内容,完成交互闭环的最后一步。", "B");
|
||||||
|
videoBank.set("伦理制度化的核心目标,是推动行业监管从原则倡导向制度化落地转变。", "A");
|
||||||
|
videoBank.set("ChatGPT 的本质是一个拥有过亿参数的大模型,其参数可通过机器学习自动找出。", "A");
|
||||||
|
videoBank.set("代码生成模块的能力包含根据需求生成 Python 、 JavaScript等语言的代码片段,以及自动补全与注释生成。", "A");
|
||||||
|
videoBank.set("使用DeepSeek推理模型时,需通过显式步骤拆解任务,强行拆解步骤能更好发挥能力。", "A");
|
||||||
|
videoBank.set("根据DeepSeek提示词技巧,提示词表达越模糊越像日常对话,就能获得更好效果。", "B");
|
||||||
|
videoBank.set("18世纪普鲁士人最先实施发明的课堂教学模式中,核心形式是学生自主讲解知识,教师负责聆听指导。", "B");
|
||||||
|
videoBank.set("生成式AI目前已具备人类的数学直觉能力,可完全替代人类进行创造性数学研究。", "B");
|
||||||
|
videoBank.set("人工智能+金融阶段的发展,建立在 IT信息系统稳定可靠、互联网环境成熟基础之上。", "A");
|
||||||
|
videoBank.set("2018 年谷歌借鉴 ELMo和GPT,推出大规模语言模型BERT,掀起NLP第二次大变革,进入魔改BERT时代。", "A");
|
||||||
|
videoBank.set("CTF-Net 是纯 CNN架构网络,专门解决纯Transformer架构全局感受野不足问题。", "A");
|
||||||
|
videoBank.set("城市交通系统是规模巨大、关联复杂、随机性强、混合交通流的大系统,利用交通大数据解决预报控制难题极具挑战性。", "A");
|
||||||
|
videoBank.set("MBA-Net模型需要依赖辅助模型,才能为被遮挡行人图像生成鲁棒表示特征。", "B");
|
||||||
|
videoBank.set("数据科学进步,推动复杂系统研究从数据机理融合驱动,向经验驱动、机理驱动范式变革。", "B");
|
||||||
|
videoBank.set("针对新建光伏电站历史运行数据不足,方案提出基于迁移学习的模型微调,可借同类站点提升发电量预测精度。", "A");
|
||||||
|
videoBank.set("人工智能大模型工程伦理问题,核心仅涉及科技法律二维,与权利、道德、生命维度无关。", "B");
|
||||||
|
videoBank.set("《人工智能能力建设普惠计划》2025年12月正式发布,聚焦发展中国家AI能力,缩小数字鸿沟。", "A");
|
||||||
|
videoBank.set("世界人工智能合作组织倡议2025年7月提出,总部拟设于上海。", "B");
|
||||||
|
videoBank.set("中国人工智能2025—2026重点:多模态突破、类脑智能、国产算力、产业落地。", "A");
|
||||||
|
videoBank.set("中国AI发展理念:以技术为本、智能向快。", "B");
|
||||||
|
videoBank.set("中国大模型首次用于地震救援,DeepSeek仅用7小时开发中缅应急系统。", "A");
|
||||||
|
videoBank.set("中国AI诊所落地沙特,全球首个AI诊所,覆盖30种呼吸疾病,诊断准确率近100%。", "A");
|
||||||
|
videoBank.set("AI技术法国家庭节电25%-30%,为联合国气候平台首批伙伴。", "A");
|
||||||
|
videoBank.set("未来AI注重普惠,缩小数字鸿沟、均衡发展。", "A");
|
||||||
|
videoBank.set("核心技术依赖进口、产品同质化;中小企业研发强度1.3%,远低于发达国家3%,难破高端市场。", "A");
|
||||||
|
videoBank.set("2025年基础理论重大突破、部分技术世界领先,成为产业升级经济转型主要动力。", "B");
|
||||||
|
videoBank.set("《“人工智能+制造”专项行动》保障:推进机制、资金支持、应用推广、监测评估。", "A");
|
||||||
|
videoBank.set("工业母机柔性化:AI深度融入数控,实现自适应作业、智能诊断、智能调度,大幅提升利用率精度。", "A");
|
||||||
|
videoBank.set("数据治理五步法:汇聚、清洗、标注、增强、治理。", "A");
|
||||||
|
videoBank.set("AI价值:驱动“新三样”从规模出海转向高质量出海,构建全球竞争壁垒。", "A");
|
||||||
|
videoBank.set("未来工厂三大特征:全流程自主运行、虚实实时同步、智能持续迭代。", "A");
|
||||||
|
videoBank.set("正视挑战、技术创新、标准统一,释放工业数据价值,实现智能化转型跨越。", "A");
|
||||||
|
|
||||||
|
// 专题2
|
||||||
|
videoBank.set("广东区域创新能力连续9年全国第一。", "A");
|
||||||
|
videoBank.set("广东高技术制造业占规上工业比重不足三成。", "B");
|
||||||
|
videoBank.set("耐心资本渠道:财政专项资金、政府投资基金、地方政府专项债、超长期特别国债。", "A");
|
||||||
|
videoBank.set("成长期赛道企业可在北交所、广东区域性股权市场上市。", "B");
|
||||||
|
videoBank.set("战略性新兴产业是新质生产力主要载体。", "A");
|
||||||
|
videoBank.set("核心驱动力:新质生产力推动战略性新兴产业持续升级。", "A");
|
||||||
|
videoBank.set("广东游戏营收、数字出版产值、动漫产值、电影票房等指标全国第一。", "A");
|
||||||
|
videoBank.set("截至2023年底,广东激光与增材制造产业有效发明专利全国第一。", "A");
|
||||||
|
videoBank.set("广东战略性新兴产业指导思想:加快从制造大省到制造强省转变。", "A");
|
||||||
|
videoBank.set("广东战略性新兴产业定位:打造世界先进水平产业基地。", "A");
|
||||||
|
videoBank.set("新质生产力理论是马克思主义生产力理论创新,丰富发展政治经济学。", "A");
|
||||||
|
videoBank.set("产业集群成长期竞争优势完全成熟,全球领先。", "B");
|
||||||
|
videoBank.set("新质生产力支撑仅含技术创新,不含数字化转型与人才支撑。", "B");
|
||||||
|
videoBank.set("2024年广东数字经济增加值占GDP持续提升,连续多年全国第一。", "B");
|
||||||
|
videoBank.set("工业机器人上游核心零部件:减速器、伺服电机、控制器,技术壁垒最高。", "A");
|
||||||
|
videoBank.set("小六轴机器人参与者少,国内暂无优势。", "B");
|
||||||
|
videoBank.set("腾讯音乐涨价短期拉动营收,不影响付费转化率。", "B");
|
||||||
|
videoBank.set("东伦敦科技城经验:无政府资金支持相关设施。", "B");
|
||||||
|
videoBank.set("新质生产力以数据为关键要素,强调知识创新应用。", "A");
|
||||||
|
videoBank.set("产业布局是产业发展规律体现,指一国地区产业空间分布组合。", "A");
|
||||||
|
videoBank.set("智能化技术底座:人工智能、大数据、云计算、5G、互联网。", "B");
|
||||||
|
videoBank.set("转型成效评估:结构优化、创新驱动、集群发展质量。", "A");
|
||||||
|
videoBank.set("家电产业链行为:产品差异化、并购、研发、价格行为。", "A");
|
||||||
|
videoBank.set("全球传统产业转移趋势:区域化、成本化、绿色化、多元化。", "A");
|
||||||
|
videoBank.set("广东传统产业升级原则:创新驱动、重点突破、开放合作、畅通循环、质效优先、绿色发展、市场主导、政府引导。", "B");
|
||||||
|
videoBank.set("构建高效服务业体系:推动传统服务向现代服务转型。", "A");
|
||||||
|
videoBank.set("佛山南海开通全国常态化无人机外卖航线。", "A");
|
||||||
|
videoBank.set("服务业4.0:大数据、AI、IoT驱动无缝服务演进。", "A");
|
||||||
|
videoBank.set("大都市创新城市:战略性新兴产业与服务业融合,推动产业升级高质量发展。", "A");
|
||||||
|
videoBank.set("2015-2020广东服务业增加值全国第一。", "A");
|
||||||
|
videoBank.set("广东服务业人才瓶颈:金融、信息、科研等领域高端人才短缺。", "A");
|
||||||
|
videoBank.set("现代服务业与先进制造、现代农业深度融合是优质服务业体系重点。", "A");
|
||||||
|
videoBank.set("服务业监管:企业自律+行业自律+政府监管+社会参与多元共治。", "A");
|
||||||
|
videoBank.set("1978-1999中国家电研发强、核心器件自主,不依赖进口。", "B");
|
||||||
|
videoBank.set("2019中国家电零售8910亿,行业成熟。", "A");
|
||||||
|
videoBank.set("2018-2024小米IoT与生活消费品收入占比25.1%→28.5%,结构多元。", "A");
|
||||||
|
videoBank.set("小米家电早期高性价比:入门定价、核心功能聚焦、快速抢占份额。", "A");
|
||||||
|
videoBank.set("2024小米之家约1.5万家,构建人车家全生态。", "A");
|
||||||
|
videoBank.set("小米电视低价互联网模式:硬件不赚钱,软件盈利。", "A");
|
||||||
|
videoBank.set("改革开放初期家电政策核心:扩大内需、全球化发展。", "B");
|
||||||
|
videoBank.set("2011-2019家电线上渠道10.4%→25.4%,社交电商、直播带货增长。", "A");
|
||||||
|
videoBank.set("我国人口转变特征:迅速性、政策干预、不彻底、不稳定、不平衡。", "A");
|
||||||
|
videoBank.set("广东20个战略性产业集群增加值占GDP17%,全国约40%。", "B");
|
||||||
|
videoBank.set("我国人口最多,人口发展事关民族复兴。", "A");
|
||||||
|
videoBank.set("中国人口转变2050年呈倒U型。", "A");
|
||||||
|
videoBank.set("人口高质量发展核心:规模、结构、质量、分布四维。", "B");
|
||||||
|
videoBank.set("经济高质量发展是中国式现代化物质根基与核心支撑。", "A");
|
||||||
|
videoBank.set("欧盟《新电池法》2024.8.17生效,禁止供应链碳足迹追溯。", "B");
|
||||||
|
videoBank.set("绿色转型阵痛:传统产业退出导致失业、地方财政下行等短期压力。", "A");
|
||||||
|
videoBank.set("截至2025年底氢燃料电池汽车销量近4万,实现0-1产业化突破。", "A");
|
||||||
|
videoBank.set("AI巡检提升效率8倍,成本降15%-30%。", "A");
|
||||||
|
videoBank.set("2026政策逻辑从控碳转向控能,核心惩罚能耗总量、限制发展空间。", "B");
|
||||||
|
videoBank.set("碳市场扩容后覆盖碳排放占比60%→30%+。", "B");
|
||||||
|
videoBank.set("高质量、绿色共生:摒弃高耗能高污染,实现经济生态协同。", "A");
|
||||||
|
videoBank.set("第一次生产力革命(蒸汽)标志:蒸汽机使用。", "A");
|
||||||
|
videoBank.set("工业5.0是工业4.0延续,新增绿色可持续与人机协作。", "A");
|
||||||
|
videoBank.set("欧盟模式核心武器:硅谷+华尔街,对标美国法条标准。", "B");
|
||||||
|
videoBank.set("上海土地充足、生活成本低,传统劳动密集产业有优势。", "B");
|
||||||
|
videoBank.set("合肥创新模式:市场雨林型,靠供应链与市场活力。", "B");
|
||||||
|
|
||||||
|
// ==============================================
|
||||||
|
// 悬浮面板
|
||||||
|
// ==============================================
|
||||||
|
function initDragPanel() {
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.style.cssText = `position:fixed;top:10px;right:10px;z-index:999999;background:#ECFDF5;color:#065F46;border-radius:12px;box-shadow:0 6px 18px rgba(0,0,0,0.2);min-width:280px;user-select:none;overflow:hidden;`;
|
||||||
|
const dragBar = document.createElement('div');
|
||||||
|
dragBar.style.cssText = `padding:10px 14px;background:#10B981;color:white;font-size:14px;font-weight:bold;cursor:move;display:flex;justify-content:space-between;align-items:center;`;
|
||||||
|
dragBar.innerText = "公需课全自动(后台保活版)";
|
||||||
|
const miniBtn = document.createElement('button');
|
||||||
|
miniBtn.innerText = "−";
|
||||||
|
miniBtn.style.cssText = `background:none;border:none;color:white;font-size:16px;cursor:pointer;`;
|
||||||
|
const toggleBtn = document.createElement('button');
|
||||||
|
toggleBtn.style.cssText = `margin:8px 12px;padding:8px 12px;width:calc(100% - 24px);border:none;border-radius:8px;font-size:14px;font-weight:bold;background:#10B981;color:white;cursor:pointer;`;
|
||||||
|
toggleBtn.innerText = "✅ 运行中|点击暂停";
|
||||||
|
const contentWrap = document.createElement('div');
|
||||||
|
progressText = document.createElement('div');
|
||||||
|
progressText.style.cssText = "padding:12px 14px;font-size:15px;line-height:1.5;text-align:center;";
|
||||||
|
progressText.textContent = '✅ 准备就绪 | 支持后台';
|
||||||
|
contentWrap.appendChild(progressText);
|
||||||
|
contentWrap.appendChild(toggleBtn);
|
||||||
|
dragBar.appendChild(miniBtn);
|
||||||
|
panel.appendChild(dragBar);
|
||||||
|
panel.appendChild(contentWrap);
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
let drag = false, ox, oy;
|
||||||
|
dragBar.onmousedown = e => { drag = true; ox = e.clientX - panel.getBoundingClientRect().left; oy = e.clientY - panel.getBoundingClientRect().top; panel.style.left = panel.getBoundingClientRect().left + "px"; panel.style.top = panel.getBoundingClientRect().top + "px"; panel.style.right = "auto"; };
|
||||||
|
document.onmousemove = e => { if (!drag) return; panel.style.left = e.clientX - ox + "px"; panel.style.top = e.clientY - oy + "px"; };
|
||||||
|
document.onmouseup = () => drag = false;
|
||||||
|
|
||||||
|
miniBtn.onclick = () => {
|
||||||
|
isMinimized = !isMinimized;
|
||||||
|
contentWrap.style.display = isMinimized ? "none" : "block";
|
||||||
|
miniBtn.innerText = isMinimized ? "□" : "−";
|
||||||
|
dragBar.innerText = isMinimized ? "公需课(后台挂着)" : "公需课全自动(后台保活版)";
|
||||||
|
dragBar.appendChild(miniBtn);
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleBtn.onclick = () => {
|
||||||
|
scriptEnabled = !scriptEnabled;
|
||||||
|
const v = document.querySelector('video');
|
||||||
|
if (scriptEnabled) {
|
||||||
|
toggleBtn.innerText = "✅ 运行中|点击暂停";
|
||||||
|
toggleBtn.style.background = "#10B981";
|
||||||
|
v?.play().catch(() => { });
|
||||||
|
progressText.textContent = "✅ 继续运行 | 支持后台";
|
||||||
|
} else {
|
||||||
|
toggleBtn.innerText = "⏸ 已暂停|点击启动";
|
||||||
|
toggleBtn.style.background = "#6B7280";
|
||||||
|
v?.pause();
|
||||||
|
progressText.textContent = "⏸ 已暂停";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================
|
||||||
|
// 超强真人行为模拟(防掉线)
|
||||||
|
// ==============================================
|
||||||
|
function humanLikeActivity() {
|
||||||
|
if (!scriptEnabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const w = window.innerWidth, h = window.innerHeight;
|
||||||
|
const x = Math.random() * w * 0.8 + w * 0.1;
|
||||||
|
const y = Math.random() * h * 0.8 + h * 0.1;
|
||||||
|
document.dispatchEvent(new MouseEvent('mousemove', { clientX: x, clientY: y, bubbles: true }));
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Math.random() > 0.6) {
|
||||||
|
window.scrollBy({ top: (Math.random() > 0.5 ? 1 : -1) * (Math.random() * 20 + 5), behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Math.random() > 0.85) {
|
||||||
|
const tg = document.querySelectorAll('body,div,section')[Math.floor(Math.random() * document.querySelectorAll('body,div,section').length)];
|
||||||
|
if (tg && !tg.closest('.layui-layer,.mylayer')) tg.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
clearTimeout(activeTimer);
|
||||||
|
activeTimer = setTimeout(humanLikeActivity, Math.floor(Math.random() * 14000) + 13000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randDelay() {
|
||||||
|
return Math.floor(Math.random() * (QUICK_DELAY_MAX - QUICK_DELAY_MIN)) + QUICK_DELAY_MIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================
|
||||||
|
// 视频答题:优先题库 → 多选组合火力覆盖
|
||||||
|
// ==============================================
|
||||||
|
function videoAnswer() {
|
||||||
|
if (!scriptEnabled || isAnswering) return;
|
||||||
|
|
||||||
|
const v = document.querySelector('video');
|
||||||
|
if (v && v.paused && !document.querySelector('.layui-layer,.mylayer-wrap')) {
|
||||||
|
v.play().catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
const txt = document.body.innerText;
|
||||||
|
if (txt.includes('你已完成') || txt.includes('已完成观看')) {
|
||||||
|
bruteForceRound = 0;
|
||||||
|
const n = document.querySelector('#studySelectAct .next');
|
||||||
|
if (n) n.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (txt.includes('正确') || txt.includes('错误') || txt.includes('回答') || txt.includes('确定')) {
|
||||||
|
bruteForceRound = 0;
|
||||||
|
const btn = document.querySelector('.layui-layer-btn0,.layui-layer-btn1,.mylayer-btn');
|
||||||
|
if (btn) btn.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const box = document.querySelector('.layui-layer,.mylayer-wrap');
|
||||||
|
if (!box) return;
|
||||||
|
|
||||||
|
const content = box.querySelector('.layui-layer-content,.mylayer-content');
|
||||||
|
const submit = box.querySelector('button[type="submit"],.btn,.layui-layer-btn0');
|
||||||
|
const radio = content?.querySelectorAll('input[type="radio"]');
|
||||||
|
const check = content?.querySelectorAll('input[type="checkbox"]');
|
||||||
|
if (!content || !submit || (radio.length < 2 && check.length < 2)) return;
|
||||||
|
|
||||||
|
isAnswering = true;
|
||||||
|
progressText.textContent = "📝 视频答题中";
|
||||||
|
|
||||||
|
const clean = s => s.replace(/\s+/g, '').replace(/[()()【】]/g, '');
|
||||||
|
const t = clean(content.innerText);
|
||||||
|
let ans = null;
|
||||||
|
|
||||||
|
for (const [k, v] of videoBank) {
|
||||||
|
const keyClean = clean(k);
|
||||||
|
if (keyClean.includes(t) || t.includes(keyClean)) {
|
||||||
|
ans = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ans) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (radio.length >= 2) {
|
||||||
|
const target = ans === 'A' ? radio[0] : radio[1];
|
||||||
|
target.checked = true;
|
||||||
|
target.click();
|
||||||
|
target.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
submit.click();
|
||||||
|
setTimeout(() => { isAnswering = false; progressText.textContent = "✅ 答题成功"; }, 500);
|
||||||
|
}, randDelay());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 题库未命中 → 火力覆盖(单选+多选组合试错)
|
||||||
|
progressText.textContent = `⚠️ 题库未命中,遍历第${bruteForceRound + 1}轮`;
|
||||||
|
|
||||||
|
if (radio.length >= 2) {
|
||||||
|
radio.forEach(r => r.checked = false);
|
||||||
|
const idx = bruteForceRound % radio.length;
|
||||||
|
radio[idx].checked = true;
|
||||||
|
radio[idx].click();
|
||||||
|
} else if (check.length >= 2) {
|
||||||
|
check.forEach(c => c.checked = false);
|
||||||
|
let sel = [];
|
||||||
|
switch(bruteForceRound){
|
||||||
|
case 0: sel=[0]; break;
|
||||||
|
case 1: sel=[1]; break;
|
||||||
|
case 2: sel=[0,1]; break;
|
||||||
|
case 3: sel=[2]; break;
|
||||||
|
case 4: sel=[0,2]; break;
|
||||||
|
case 5: sel=[1,2]; break;
|
||||||
|
case 6: sel=[0,1,2]; break;
|
||||||
|
case 7: sel=[3]; break;
|
||||||
|
case 8: sel=[0,3]; break;
|
||||||
|
case 9: sel=[1,3]; break;
|
||||||
|
case 10: sel=[2,3]; break;
|
||||||
|
case 11: sel=[0,1,2,3]; break;
|
||||||
|
}
|
||||||
|
sel.forEach(i=>{ if(check[i]){ check[i].checked=true; check[i].click(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
submit.click();
|
||||||
|
bruteForceRound = (bruteForceRound + 1) % MAX_BRUTE_ROUND;
|
||||||
|
setTimeout(() => { isAnswering = false; }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================
|
||||||
|
// 【多选题终极修复】不会消失、不会被清空
|
||||||
|
// ==============================================
|
||||||
|
function examAutoInOrder() {
|
||||||
|
if (!scriptEnabled || isAnswering) return;
|
||||||
|
const topicList = document.getElementById('topicList');
|
||||||
|
if (!topicList) return;
|
||||||
|
|
||||||
|
const ansList = getExamAnswers();
|
||||||
|
const items = Array.from(topicList.querySelectorAll('.m-topic-item'));
|
||||||
|
if (items.length < 10) return;
|
||||||
|
|
||||||
|
progressText.textContent = `📝 考核答题中(共${items.length}题)`;
|
||||||
|
const map = { A: 0, B: 1, C: 2, D: 3, E: 4 };
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
isAnswering = true;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
const ans = ansList[i]?.trim().toUpperCase();
|
||||||
|
if (!ans) continue;
|
||||||
|
|
||||||
|
progressText.textContent = `第${i + 1}题:${ans}`;
|
||||||
|
const r = item.querySelectorAll('input[type="radio"]');
|
||||||
|
const c = item.querySelectorAll('input[type="checkbox"]');
|
||||||
|
|
||||||
|
// 单选不变
|
||||||
|
if (r.length > 0) {
|
||||||
|
r.forEach(x => x.checked = false);
|
||||||
|
const idx = map[ans[0]];
|
||||||
|
if (idx !== undefined && r[idx]) {
|
||||||
|
r[idx].checked = true;
|
||||||
|
r[idx].click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多选:强制锁定勾选,网站无法清空
|
||||||
|
if (c.length > 0) {
|
||||||
|
// 先解除网站监听
|
||||||
|
c.forEach(x => {
|
||||||
|
x.onchange = null;
|
||||||
|
x.onclick = null;
|
||||||
|
});
|
||||||
|
c.forEach(x => x.checked = false);
|
||||||
|
for (let ch of ans) {
|
||||||
|
const idx = map[ch];
|
||||||
|
if (idx !== undefined && c[idx]) {
|
||||||
|
c[idx].checked = true;
|
||||||
|
c[idx].dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, randDelay()));
|
||||||
|
}
|
||||||
|
progressText.textContent = "✅ 已全部答完,请手动提交";
|
||||||
|
isAnswering = false;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================
|
||||||
|
// 视频监控 & 自动下一节
|
||||||
|
// ==============================================
|
||||||
|
function videoMonitor() {
|
||||||
|
if (!scriptEnabled) return;
|
||||||
|
const v = document.querySelector('video');
|
||||||
|
if (!v) {
|
||||||
|
if (document.body.innerText.includes('考核') || document.getElementById('topicList')) {
|
||||||
|
progressText.textContent = "⏳ 进入考核,自动答题";
|
||||||
|
setTimeout(examAutoInOrder, 1000);
|
||||||
|
} else {
|
||||||
|
progressText.textContent = "⏳ 等待视频加载";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = v.duration ? Math.round(v.currentTime / v.duration * 100) : 0;
|
||||||
|
progressText.textContent = `▶ 观看进度 ${p}% | 后台可挂`;
|
||||||
|
if (p >= 100) videoWatched = true;
|
||||||
|
|
||||||
|
const next = document.querySelector('#studySelectAct .next');
|
||||||
|
if (FORCE_WATCH_END && !videoWatched && next) {
|
||||||
|
next.style.pointerEvents = 'none';
|
||||||
|
next.style.opacity = '0.5';
|
||||||
|
} else if (next) {
|
||||||
|
next.style.pointerEvents = 'auto';
|
||||||
|
next.style.opacity = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoNext() {
|
||||||
|
if (!scriptEnabled) return;
|
||||||
|
const v = document.querySelector('video');
|
||||||
|
const popup = document.querySelector('.layui-layer,.mylayer-wrap');
|
||||||
|
if (v && !popup && v.ended && videoWatched) {
|
||||||
|
const n = document.querySelector('#studySelectAct .next');
|
||||||
|
if (n && !n.innerText.includes('考核')) n.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================
|
||||||
|
// 主循环
|
||||||
|
// ==============================================
|
||||||
|
function main() {
|
||||||
|
checkAutoRefresh();
|
||||||
|
videoAnswer();
|
||||||
|
videoMonitor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动
|
||||||
|
enableBackgroundKeepAlive();
|
||||||
|
initDragPanel();
|
||||||
|
humanLikeActivity();
|
||||||
|
setInterval(main, 700);
|
||||||
|
setInterval(autoNext, 1000);
|
||||||
|
})();
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"""广东省教师继续教育平台抓包工具(一次性取证用,不参与运行)。
|
"""广东省教师继续教育平台抓包工具(一次性取证用,不参与运行)。
|
||||||
|
|
||||||
用法:
|
用法(在仓库根目录执行):
|
||||||
python script/capture-jsglpt.py [分钟数]
|
python src/study_tool/plugins/gdgxk/script/capture-jsglpt.py [分钟数]
|
||||||
|
|
||||||
行为:开一个有头 Chromium(持久 profile,登录态会保留),打开 jsglpt 登录页,
|
行为:开一个有头 Chromium(持久 profile,登录态会保留),打开 jsglpt 登录页,
|
||||||
把你在窗口里操作期间产生的 XHR/fetch 全量落到 data/capture-jsglpt-<时间>.jsonl,
|
把你在窗口里操作期间产生的 XHR/fetch 全量落到 data/capture-jsglpt-<时间>.jsonl,
|
||||||
同时每 3 秒把 cookie 快照写到 data/capture-jsglpt-cookies.json。
|
同时每 3 秒把 cookie 快照写到 data/capture-jsglpt-cookies.json。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -14,7 +15,8 @@ import time
|
|||||||
|
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
# 脚本在 src/study_tool/plugins/gdgxk/script/,往上 5 层是仓库根
|
||||||
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 5))
|
||||||
DATA = os.path.join(ROOT, "data")
|
DATA = os.path.join(ROOT, "data")
|
||||||
LOGIN = "https://jsglpt.gds.edu.cn/login.jsp"
|
LOGIN = "https://jsglpt.gds.edu.cn/login.jsp"
|
||||||
MINUTES = float(sys.argv[1]) if len(sys.argv) > 1 else 40
|
MINUTES = float(sys.argv[1]) if len(sys.argv) > 1 else 40
|
||||||
@@ -35,8 +37,11 @@ def emit(rec):
|
|||||||
rec["n"] = seen
|
rec["n"] = seen
|
||||||
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||||
out.flush()
|
out.flush()
|
||||||
print(" %-4s %s %s -> %s" % (rec.get("rt", "doc"), rec.get("method", ""),
|
print(
|
||||||
rec.get("url", "")[:150], rec.get("status", "")), flush=True)
|
" %-4s %s %s -> %s"
|
||||||
|
% (rec.get("rt", "doc"), rec.get("method", ""), rec.get("url", "")[:150], rec.get("status", "")),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def on_response(r):
|
def on_response(r):
|
||||||
@@ -52,12 +57,18 @@ def on_response(r):
|
|||||||
pass
|
pass
|
||||||
body = ""
|
body = ""
|
||||||
# 只留文本类响应,视频/图片跳过
|
# 只留文本类响应,视频/图片跳过
|
||||||
if not (r.url or "").lower().split("?")[0].endswith((".mp4", ".m3u8", ".ts", ".png", ".jpg", ".gif", ".woff", ".woff2", ".css", ".js")):
|
if (
|
||||||
|
not (r.url or "")
|
||||||
|
.lower()
|
||||||
|
.split("?")[0]
|
||||||
|
.endswith((".mp4", ".m3u8", ".ts", ".png", ".jpg", ".gif", ".woff", ".woff2", ".css", ".js"))
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
body = r.text()[:8000]
|
body = r.text()[:8000]
|
||||||
except Exception:
|
except Exception:
|
||||||
body = "<binary>"
|
body = "<binary>"
|
||||||
emit({
|
emit(
|
||||||
|
{
|
||||||
"t": time.strftime("%H:%M:%S"),
|
"t": time.strftime("%H:%M:%S"),
|
||||||
"rt": rt,
|
"rt": rt,
|
||||||
"method": req.method,
|
"method": req.method,
|
||||||
@@ -67,7 +78,8 @@ def on_response(r):
|
|||||||
"status": r.status,
|
"status": r.status,
|
||||||
"resp_headers": h,
|
"resp_headers": h,
|
||||||
"resp": body,
|
"resp": body,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -76,7 +88,8 @@ def main():
|
|||||||
print("抓包输出:%s" % JSONL, flush=True)
|
print("抓包输出:%s" % JSONL, flush=True)
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
ctx = p.chromium.launch_persistent_context(
|
ctx = p.chromium.launch_persistent_context(
|
||||||
PROFILE, headless=False,
|
PROFILE,
|
||||||
|
headless=False,
|
||||||
args=["--disable-blink-features=AutomationControlled"],
|
args=["--disable-blink-features=AutomationControlled"],
|
||||||
viewport={"width": 1440, "height": 900},
|
viewport={"width": 1440, "height": 900},
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""广东公需课自测:用浏览器抓到的 cookie 走一遍 列表 → 发现 → 心跳。
|
||||||
|
|
||||||
|
用法(在仓库根目录执行):
|
||||||
|
PYTHONPATH=src python src/study_tool/plugins/gdgxk/script/test-gdgxk.py [课件id]
|
||||||
|
cookie 来自 capture-jsglpt.py 落下的 data/capture-jsglpt-cookies.json。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from study_tool.plugins.gdgxk.main import PLATFORM as gdgxk
|
||||||
|
from study_tool.plugins.gdgxk.utils import course, http
|
||||||
|
|
||||||
|
cid = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
|
cookies = json.load(open("data/capture-jsglpt-cookies.json", encoding="utf-8"))
|
||||||
|
s = gdgxk.session(cookies)
|
||||||
|
|
||||||
|
courses = gdgxk.list_courses(s)
|
||||||
|
print("课程数:", len(courses))
|
||||||
|
for c in courses:
|
||||||
|
print(" ", c["plan_id"], c["name"])
|
||||||
|
|
||||||
|
if not cid and courses:
|
||||||
|
cid = courses[0]["plan_id"]
|
||||||
|
|
||||||
|
d = gdgxk.discover(s, cid, "")
|
||||||
|
print("\n课程:", d["course"])
|
||||||
|
print("进度:", d["progress"])
|
||||||
|
print("视频:", d["info"])
|
||||||
|
cw = d["courseware"][0]
|
||||||
|
print("课件:", cw["id"], "| 总长", cw["total"], "| 已看", cw["viewTime"])
|
||||||
|
|
||||||
|
sid = uuid.uuid4().hex
|
||||||
|
sign = course.sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, cw["uid"])
|
||||||
|
print("\n播放注册签名:", sign)
|
||||||
|
print("服务端已看秒数:", course.view_time(s, cw, cw["uid"], sign))
|
||||||
|
|
||||||
|
r = http.xhr(
|
||||||
|
s,
|
||||||
|
"POST",
|
||||||
|
http.X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1",
|
||||||
|
data={
|
||||||
|
"_method": "PUT",
|
||||||
|
"aid": cw["aid"],
|
||||||
|
"isLimit": "false",
|
||||||
|
"md5": sign["md5"],
|
||||||
|
"expires": sign["time"],
|
||||||
|
"md5Uid": cw["uid"],
|
||||||
|
"lastViewTime": str(min(cw["viewTime"] + 60, cw["total"])),
|
||||||
|
"playbackSessionId": sid,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print("\n心跳响应:", r.status_code, r.text[:300])
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""广东公需课插件的实现模块:`main.py` 只留 `Platform` 子类,工具按阶段拆到这里。
|
||||||
|
|
||||||
|
http.py 域名 / UA / 平台 id,XHR 上报封装与统一过期异常
|
||||||
|
login.py base64 账号密码 + 图形验证码 + ITLMS 换票
|
||||||
|
course.py 课程列表卡片、学习页上报参数、进度页活动清单
|
||||||
|
learn.py 活动页打开 → 注册播放 → 1x 心跳上报
|
||||||
|
"""
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""公需课题库:题库 JSON 放 plugins/gdgxk/bank/(明年换题库就加一个新文件),匹配与 AI 兜底走核心 bank。"""
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from study_tool import bank as qbank
|
||||||
|
|
||||||
|
BANK_DIR = Path(__file__).resolve().parents[1] / "bank"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def load():
|
||||||
|
return qbank.load(BANK_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def answer(key, questions):
|
||||||
|
"""题库优先、没命中走大模型,返回 {题号: {"answer": ["A"], "source": "bank"|"llm"}}。"""
|
||||||
|
return qbank.answer(key, questions, load())
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""课程页解析:课程列表卡片、学习页上报参数、进度页活动清单。"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from . import http
|
||||||
|
|
||||||
|
|
||||||
|
def parse_courses(html):
|
||||||
|
"""课程列表页:每个 `showCourse('id','名称')` 前面就是这张卡片的信息。"""
|
||||||
|
out = []
|
||||||
|
for m in re.finditer(r"showCourse\('([^']+)','([^']*)'\)", html):
|
||||||
|
block = html[max(0, m.start() - 2500):m.start()]
|
||||||
|
year = re.search(r"培训年度:\s*<[^>]*>(\d{4})<", block)
|
||||||
|
hours = re.search(r"课程学时:\s*<[^>]*>(\d+)<", block)
|
||||||
|
st = re.search(r">\s*(未完成|已完成)\s*<", block)
|
||||||
|
out.append({
|
||||||
|
"plan_id": m.group(1),
|
||||||
|
"project_id": "",
|
||||||
|
"name": m.group(2).strip(),
|
||||||
|
"section": ("%s年公需课" % year.group(1)) if year else "",
|
||||||
|
"status_label": st.group(1) if st else "",
|
||||||
|
"info": ("%s 学时" % hours.group(1)) if hours else "",
|
||||||
|
"in_study": True,
|
||||||
|
"platform": http.PLATFORM_ID,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_course(html):
|
||||||
|
"""从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、要求时长、已看秒数。"""
|
||||||
|
d = {}
|
||||||
|
m = re.search(r"/a_([0-9a-f]{32})/study/video/user/", html)
|
||||||
|
if not m:
|
||||||
|
raise RuntimeError("学习页里没有找到课件上报路径,页面结构可能变了")
|
||||||
|
d["aid"] = "a_" + m.group(1)
|
||||||
|
m = re.search(r"updateVideoStatus1'[\s\S]{0,200}?id=([0-9a-f]{32})", html) \
|
||||||
|
or re.search(r"id=([0-9a-f]{32})&md5Uid=", html)
|
||||||
|
if not m:
|
||||||
|
raise RuntimeError("学习页里没有找到课件 id")
|
||||||
|
d["id"] = m.group(1)
|
||||||
|
m = re.search(r"md5Uid=([0-9a-f]{64,128})", html)
|
||||||
|
if not m:
|
||||||
|
raise RuntimeError("学习页里没有找到 md5Uid")
|
||||||
|
d["uid"] = m.group(1)
|
||||||
|
# `t` 是服务端记的「已看秒数」,不是总长;完成门槛是页面上的「要求观看视频 N 分钟」
|
||||||
|
m = re.search(r"\bt\s*=\s*'(\d+)'", html)
|
||||||
|
d["viewTime"] = int(m.group(1)) if m else 0
|
||||||
|
m = re.search(r"要求观看视频<span>(\d+)</span>分钟", html)
|
||||||
|
d["total"] = int(m.group(1)) * 60 if m else 0
|
||||||
|
m = re.search(r"url1\s*=\s*'([^']+)'", html)
|
||||||
|
d["video"] = m.group(1) if m else ""
|
||||||
|
d["title"] = (re.search(r"<title>([^<]*)</title>", html).group(1).strip()
|
||||||
|
if "<title>" in html else "")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def sign(s, cw, uid):
|
||||||
|
"""updateVideoStatus1:注册播放权,换回心跳签名(time / md5)。"""
|
||||||
|
r = http.xhr(s, "POST", http.X + "/" + cw["aid"] + "/study/video/user/updateVideoStatus1",
|
||||||
|
data={"_method": "PUT", "id": cw["id"], "md5Uid": uid,
|
||||||
|
"playbackSessionId": cw["sid"], "playbackTakeover": "true"})
|
||||||
|
j = r.json() if r.text.strip().startswith("{") else {}
|
||||||
|
if j.get("responseCode") != "00":
|
||||||
|
raise RuntimeError("注册播放失败:%s" % (r.text[:120] or "空响应"))
|
||||||
|
return j["responseData"]
|
||||||
|
|
||||||
|
|
||||||
|
def view_time(s, cw, uid, sign):
|
||||||
|
r = http.xhr(s, "GET", http.X + "/" + cw["aid"] + "/study/video/user/getViewTime",
|
||||||
|
params={"id": cw["id"], "md5": sign.get("md5"), "expires": sign.get("time"),
|
||||||
|
"md5Uid": uid})
|
||||||
|
j = r.json() if r.text.strip().startswith("{") else {}
|
||||||
|
rd = j.get("responseData")
|
||||||
|
if isinstance(rd, dict):
|
||||||
|
return int(rd.get("viewTime") or 0)
|
||||||
|
return int(rd or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def progress(s, cid):
|
||||||
|
"""学习进度页:活动总数、已完成数、每个活动(aid / 名称 / 是否完成)。"""
|
||||||
|
html = s.get(http.X + "/" + cid + "/study/course/progess", timeout=30).text
|
||||||
|
if "studyCourse(" not in html:
|
||||||
|
raise http.expired("学习平台会话已失效,请重新登录")
|
||||||
|
m = re.search(r"\$\('#actNum'\)\.text\('(\d+)'\)", html)
|
||||||
|
total = int(m.group(1)) if m else 0
|
||||||
|
m = re.search(r"\$\('#completeActNum'\)\.text\('(\d+)'\)", html)
|
||||||
|
done = int(m.group(1)) if m else 0
|
||||||
|
acts = []
|
||||||
|
for row in re.findall(r"<tr[^>]*>([\s\S]*?)</tr>", html):
|
||||||
|
m = re.search(r"studyCourse\('[^']*','[^']*','(a_[0-9a-f]{32})'\)[^>]*>([^<]+)<", row)
|
||||||
|
if m:
|
||||||
|
acts.append({"aid": m.group(1), "title": m.group(2).strip(),
|
||||||
|
"done": "u-ico-finish" in row})
|
||||||
|
return {"total": total or len(acts), "done": done, "acts": acts}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""课程考核:题目内嵌在课程学习页 HTML 里,交卷是 POST finishTest2(Spring 的 _method=put)。
|
||||||
|
|
||||||
|
页面结构(服务端渲染,非 JSON 接口):
|
||||||
|
form#testForm[action=.../ncts/test/delivery/<id>/finishTest2]
|
||||||
|
input[type=hidden][name=qti_item_Q1:2:1_RESPONSE]
|
||||||
|
#topicList .m-topic-item × 30
|
||||||
|
h3.title > .item-num + .qs-type([单选题]/[多选题]/[是非题])+ 题干
|
||||||
|
ol.m-question-lst > li > label > span「A、选项」+ input[name=qti_response_Q1:2:1_RESPONSE value=Choice0]
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from study_tool import runtime
|
||||||
|
|
||||||
|
from . import bank as qbank
|
||||||
|
from . import http
|
||||||
|
|
||||||
|
KINDS = (("多选", "multi"), ("是非", "judge"), ("单选", "single"))
|
||||||
|
# 前端测验弹窗用的题型码:01 单选 / 02 多选 / 03 判断
|
||||||
|
TYPE_CODE = {"single": "01", "multi": "02", "judge": "03"}
|
||||||
|
SCORE = re.compile(r"已得[::]\s*(\d+(?:\.\d+)?)\s*分")
|
||||||
|
|
||||||
|
|
||||||
|
def finished(html):
|
||||||
|
"""页面上已经显示「您已完成测验 / 已得 N 分」就不再重做。"""
|
||||||
|
return "已完成测验" in html or bool(SCORE.search(html))
|
||||||
|
|
||||||
|
|
||||||
|
def score(html):
|
||||||
|
m = SCORE.search(html)
|
||||||
|
return float(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse(html):
|
||||||
|
"""课程学习页 → 考核表单;没有考核返回 None。"""
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
form = soup.find("form", id="testForm")
|
||||||
|
if not form:
|
||||||
|
return None
|
||||||
|
d = {"action": urljoin(http.X + "/", form.get("action") or ""), "fields": {}, "questions": []}
|
||||||
|
# 隐藏字段照原样回传:form 里的全收,qti_item_* 再按整页兜一次(题块可能不在 form 内)
|
||||||
|
for i in form.select("input[type=hidden]") + soup.select("input[type=hidden][name^=qti_item_]"):
|
||||||
|
if i.get("name"):
|
||||||
|
d["fields"].setdefault(i["name"], i.get("value") or "")
|
||||||
|
for it in soup.select("#topicList .m-topic-item"): # 题目列表不一定包在 form 里,按整页找
|
||||||
|
h3 = it.select_one("h3.title")
|
||||||
|
if not h3:
|
||||||
|
continue
|
||||||
|
kind = next((v for k, v in KINDS if k in h3.get_text()), "single")
|
||||||
|
for tag in h3.select(".item-num, .qs-type"):
|
||||||
|
tag.decompose()
|
||||||
|
opts = []
|
||||||
|
for li in it.select(".m-question-lst > li"):
|
||||||
|
inp = li.find("input")
|
||||||
|
if not inp:
|
||||||
|
continue
|
||||||
|
span = li.select_one("span")
|
||||||
|
text = (span or li).get_text(strip=True)
|
||||||
|
m = re.match(r"([A-F])[、..]\s*(.*)", text)
|
||||||
|
opts.append({
|
||||||
|
"key": m.group(1) if m else "",
|
||||||
|
"text": (m.group(2) if m else text).strip(),
|
||||||
|
"value": inp.get("value"),
|
||||||
|
"name": inp.get("name"),
|
||||||
|
})
|
||||||
|
d["questions"].append({
|
||||||
|
"no": len(d["questions"]) + 1,
|
||||||
|
"type": kind,
|
||||||
|
"title": h3.get_text(" ", strip=True),
|
||||||
|
"options": opts,
|
||||||
|
})
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def answer(key, ex):
|
||||||
|
"""题库优先、没命中走大模型,再把选项字母换成平台提交值(Choice0..4)。"""
|
||||||
|
got = qbank.answer(key, ex["questions"])
|
||||||
|
out = {}
|
||||||
|
for q in ex["questions"]:
|
||||||
|
hit = got.get(str(q["no"]))
|
||||||
|
if not hit:
|
||||||
|
continue
|
||||||
|
vals = [o["value"] for o in q["options"] if o["key"] in hit["answer"]]
|
||||||
|
if vals:
|
||||||
|
out[q["no"]] = vals
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def submit(s, ex, answers):
|
||||||
|
"""按页面 ajaxSubmit 的做法交卷:POST 表单 action,body 带 _method=put。"""
|
||||||
|
pairs = [("_method", "put")] + list(ex["fields"].items())
|
||||||
|
for q in ex["questions"]:
|
||||||
|
name = q["options"][0]["name"] if q["options"] else ""
|
||||||
|
for v in answers.get(q["no"], []):
|
||||||
|
pairs.append((name, v))
|
||||||
|
r = s.post(
|
||||||
|
ex["action"],
|
||||||
|
data=pairs,
|
||||||
|
headers={"X-Requested-With": "XMLHttpRequest", "Referer": ex["action"]},
|
||||||
|
timeout=40,
|
||||||
|
)
|
||||||
|
if "loginBtn" in r.text and len(r.text) < 3000:
|
||||||
|
raise http.expired("交卷时会话已失效,请重新登录")
|
||||||
|
return r.text
|
||||||
|
|
||||||
|
|
||||||
|
def submit_letters(s, url, letters):
|
||||||
|
"""按用户在弹窗里选的字母答案交卷:重取一次考核页拿隐藏字段,保证表单是当前会话的。"""
|
||||||
|
html = s.get(url, timeout=30).text
|
||||||
|
ex = parse(html)
|
||||||
|
if not ex or not ex["questions"]:
|
||||||
|
return {"ok": False, "error": "重新取题失败,无法提交"}
|
||||||
|
answers = {}
|
||||||
|
for q in ex["questions"]:
|
||||||
|
raw = str((letters or {}).get(str(q["no"])) or "").strip().upper()
|
||||||
|
vals = [o["value"] for o in q["options"] if o["key"] and o["key"] in raw]
|
||||||
|
if vals:
|
||||||
|
answers[q["no"]] = vals
|
||||||
|
if not answers:
|
||||||
|
return {"ok": False, "error": "没有可提交的答案"}
|
||||||
|
back = submit(s, ex, answers)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"answers": len(answers),
|
||||||
|
"total": len(ex["questions"]),
|
||||||
|
"score": score(back),
|
||||||
|
"msg": "提交成功",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run(key, s, url, auto=True):
|
||||||
|
"""取考核活动页 → 解析考核 → 答题 → 交卷。返回 (会话, 结果说明);没有考核或没做完返回空串。
|
||||||
|
|
||||||
|
考核是独立活动页(`/<aid>/study/course/<cid>`),课程主页上没有 testForm。
|
||||||
|
题库没覆盖全部题目时不交卷(auto=False 或缺口 > 0),只记 warn,避免瞎蒙拉低分数。
|
||||||
|
"""
|
||||||
|
html = s.get(url, timeout=30).text
|
||||||
|
if finished(html):
|
||||||
|
sc = score(html)
|
||||||
|
return s, "考核已完成" + (",得分 %s" % sc if sc is not None else "")
|
||||||
|
ex = parse(html)
|
||||||
|
if not ex or not ex["questions"]:
|
||||||
|
return s, ""
|
||||||
|
answers = answer(key, ex)
|
||||||
|
miss = [q["no"] for q in ex["questions"] if q["no"] not in answers]
|
||||||
|
if miss:
|
||||||
|
runtime.log(
|
||||||
|
key,
|
||||||
|
"考核共 %d 题,%d 题没答案(第 %s 题),本次不交卷"
|
||||||
|
% (len(ex["questions"]), len(miss), "、".join(map(str, miss[:10]))),
|
||||||
|
level="warn",
|
||||||
|
)
|
||||||
|
return s, ""
|
||||||
|
if not auto:
|
||||||
|
runtime.log(key, "考核已填好 %d 题,按配置不自动交卷" % len(answers))
|
||||||
|
return s, ""
|
||||||
|
back = submit(s, ex, answers)
|
||||||
|
sc = score(back)
|
||||||
|
msg = "考核已交卷" + (",得分 %s" % sc if sc is not None else "")
|
||||||
|
if sc is not None and sc < 60:
|
||||||
|
runtime.log(key, msg + "(低于 60 分,需人工重考)", level="warn")
|
||||||
|
else:
|
||||||
|
runtime.log(key, msg)
|
||||||
|
return s, msg
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""请求底座:域名 / UA / 平台 id,以及上报接口的 XHR 封装。"""
|
||||||
|
from study_tool.platforms.errors import SessionExpired
|
||||||
|
|
||||||
|
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36")
|
||||||
|
G = "https://jsglpt.gds.edu.cn"
|
||||||
|
X = "https://jsxx.gds.edu.cn"
|
||||||
|
PLATFORM_ID = "gdgxk"
|
||||||
|
|
||||||
|
|
||||||
|
def xhr(s, method, url, **kw):
|
||||||
|
"""这些上报接口必须带 Referer(指向课程页),否则直接 500。"""
|
||||||
|
kw.setdefault("headers", {})["X-Requested-With"] = "XMLHttpRequest"
|
||||||
|
return s.request(method, url, timeout=30, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def expired(msg):
|
||||||
|
"""统一错误类型:引擎据此触发续期 / 重新登录。"""
|
||||||
|
return SessionExpired(msg)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""学习过程:活动页打开 → 注册播放 → 按墙钟推的 1x 心跳上报。"""
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from study_tool import runtime
|
||||||
|
|
||||||
|
from . import course, http, login
|
||||||
|
|
||||||
|
|
||||||
|
def stopped(key):
|
||||||
|
with runtime.RUNS_LOCK:
|
||||||
|
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def wait(key, seconds):
|
||||||
|
"""可中断的等待;期间被停止返回 False。"""
|
||||||
|
for _ in range(seconds):
|
||||||
|
if stopped(key):
|
||||||
|
return False
|
||||||
|
time.sleep(1)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def study_one(key, s, cw, speed, beat):
|
||||||
|
"""打开活动页 → 注册播放 → 1x 心跳;会话失效时抛 SessionExpired 交给上层重登。"""
|
||||||
|
try:
|
||||||
|
login.sso(s) # 一门课要跑几十分钟到几小时,每个活动前续一次票
|
||||||
|
except Exception:
|
||||||
|
pass # 续票失败但老会话还能用就继续
|
||||||
|
html = s.get(cw["href"], timeout=30).text
|
||||||
|
if "loginBtn" in html and "md5Uid" not in html:
|
||||||
|
raise http.expired("学习页要求重新登录")
|
||||||
|
try:
|
||||||
|
info = course.parse_course(html)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError("不是视频活动(%s)" % e)
|
||||||
|
if not info["total"]:
|
||||||
|
raise RuntimeError("页面上没有「要求观看视频 N 分钟」")
|
||||||
|
cw.update(id=info["id"], uid=info["uid"], total=info["total"], viewTime=info["viewTime"])
|
||||||
|
s.headers["Referer"] = cw["href"]
|
||||||
|
sign = course.sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": uuid.uuid4().hex}, cw["uid"])
|
||||||
|
cur = course.view_time(s, {"aid": cw["aid"], "id": cw["id"]}, cw["uid"], sign)
|
||||||
|
runtime.log(key, "开始学习:%s(要求 %d 秒,已看 %d 秒)" % (cw["title"], cw["total"], cur))
|
||||||
|
if cur < cw["total"]:
|
||||||
|
learn(key, s, cw, speed, beat)
|
||||||
|
else:
|
||||||
|
runtime.log(key, "服务端已记 %d 秒,达到要求,等平台刷新完成状态" % cur, level="warn")
|
||||||
|
|
||||||
|
|
||||||
|
def learn(key, s, cw, speed, beat):
|
||||||
|
"""心跳上报。平台只按真实流逝时间放行(1x),所以目标值按墙钟推,不靠每拍累加。"""
|
||||||
|
uid, total = cw["uid"], cw["total"]
|
||||||
|
s.headers["Referer"] = cw.get("href") or http.X
|
||||||
|
sid = uuid.uuid4().hex
|
||||||
|
base = {"_method": "PUT", "aid": cw["aid"]}
|
||||||
|
sign = course.sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, uid)
|
||||||
|
cur = course.view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
|
||||||
|
start, t0, stall = cur, time.time(), 0
|
||||||
|
while cur < total:
|
||||||
|
if stopped(key):
|
||||||
|
runtime.log(key, "已按要求停止")
|
||||||
|
return
|
||||||
|
time.sleep(beat)
|
||||||
|
want = min(total, start + int((time.time() - t0) * speed))
|
||||||
|
data = dict(base, isLimit="false", md5=sign.get("md5"), expires=sign.get("time"),
|
||||||
|
md5Uid=uid, lastViewTime=str(max(want, cur)), playbackSessionId=sid)
|
||||||
|
r = http.xhr(s, "POST", http.X + "/" + cw["aid"] + "/study/video/user/" + cw["id"]
|
||||||
|
+ "/updateViewTime1", data=data)
|
||||||
|
if "loginBtn" in (r.text or "") or not (r.text or "").strip().startswith("{"):
|
||||||
|
raise http.expired("上报被踢回登录页(HTTP %s)" % r.status_code)
|
||||||
|
j = r.json()
|
||||||
|
rd = j.get("responseData") or {}
|
||||||
|
if j.get("responseCode") == "00" and rd.get("md5"):
|
||||||
|
sign = {"md5": rd["md5"], "time": rd.get("time")}
|
||||||
|
before = cur
|
||||||
|
cur = course.view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
|
||||||
|
pct = min(100, int(cur * 100 / total)) if total else 0
|
||||||
|
runtime.log(
|
||||||
|
key,
|
||||||
|
"进度上报:平台已记 %d/%d 秒(%d%%),本次上报到 %d 秒" % (cur, total, pct, want),
|
||||||
|
)
|
||||||
|
stall = stall + 1 if cur <= before else 0
|
||||||
|
if stall >= 5:
|
||||||
|
runtime.log(key, "连续 5 次上报都没被平台接受,先停这个活动", level="warn")
|
||||||
|
return
|
||||||
|
runtime.log(key, "课件完成:%s(平台已记 %d/%d 秒)" % (cw["title"], cur, total))
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""登录链路:base64 账号密码、图形验证码识别、ITLMS 换票。"""
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
from . import http
|
||||||
|
|
||||||
|
_OCR = None
|
||||||
|
|
||||||
|
|
||||||
|
def b64(text):
|
||||||
|
return base64.b64encode(str(text).encode("utf-8")).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def ocr():
|
||||||
|
"""4 位图形验证码:ddddocr 离线识别。"""
|
||||||
|
global _OCR
|
||||||
|
if _OCR is None:
|
||||||
|
try:
|
||||||
|
import ddddocr
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError("登录要过图形验证码,需要先装 ddddocr(pip install ddddocr):%s" % e)
|
||||||
|
_OCR = ddddocr.DdddOcr(show_ad=False)
|
||||||
|
return _OCR
|
||||||
|
|
||||||
|
|
||||||
|
def captcha(s):
|
||||||
|
raw = s.get(http.G + "/login/readCaptcha.action?d=%.6f" % time.time(), timeout=30).text
|
||||||
|
return base64.b64decode(raw.strip().strip('"').split(",", 1)[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def login_error(s):
|
||||||
|
html = s.get(http.G + "/login.jsp", timeout=30).text
|
||||||
|
m = re.search(r'id="errorText"[^>]*>([^<]*)<', html)
|
||||||
|
return (m.group(1).strip() if m else "")
|
||||||
|
|
||||||
|
|
||||||
|
def sso(s):
|
||||||
|
"""jsglpt 会话换 ITLMS 票,票按 `ITLMS-xxx||ticketId` 两段交给 jsxx。
|
||||||
|
|
||||||
|
交接入口来自 jsglpt 首页那个隐藏表单:
|
||||||
|
`GET https://jsxx.gds.edu.cn/nts/loginToStoreByAccount?ticket=&ticketId=`
|
||||||
|
"""
|
||||||
|
r = s.post(http.G + "/teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=", timeout=30)
|
||||||
|
ticket = (r.text or "").strip()
|
||||||
|
if not ticket.startswith("ITLMS-") or "||" not in ticket:
|
||||||
|
raise http.expired("取 ITLMS 票失败:%s" % (ticket[:80] or "空响应"))
|
||||||
|
t, tid = ticket.split("||", 1)
|
||||||
|
q = s.get(http.X + "/nts/loginToStoreByAccount", params={"ticket": t, "ticketId": tid}, timeout=30)
|
||||||
|
if "logout" not in q.text and "我的课程" not in q.text:
|
||||||
|
raise http.expired("带票进学习平台失败,票可能已失效")
|
||||||
|
return s
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""选课兜底:课程列表里没有目标年度的课时,去选课超市 /uc/store/course/ 挑一门选上。
|
||||||
|
|
||||||
|
配置在插件目录 `config.json`:`year` 目标年度(空 = 当前年份),`pick` 都没选时选第几门(从 1 开始,默认 1)。
|
||||||
|
页面状态:按钮「进入学习」= 这门就是已选的课;「已有选课」= 已经选了另一门;其余视为未选。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from study_tool import runtime
|
||||||
|
|
||||||
|
from . import http
|
||||||
|
|
||||||
|
CONFIG = Path(__file__).resolve().parents[1] / "config.json"
|
||||||
|
STORE = http.X + "/uc/store/course/"
|
||||||
|
|
||||||
|
|
||||||
|
def config():
|
||||||
|
c = json.loads(CONFIG.read_text("utf-8")) if CONFIG.exists() else {}
|
||||||
|
return str(c.get("year") or date.today().year), max(int(c.get("pick") or 1), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _get(s, url):
|
||||||
|
s.headers["Referer"] = STORE
|
||||||
|
return s.get(url, timeout=30).text
|
||||||
|
|
||||||
|
|
||||||
|
def ensure(s):
|
||||||
|
"""选课超市里已有选中的课就直接返回 True;都没选就按 pick 选一门。成功返回 True。"""
|
||||||
|
year, pick = config()
|
||||||
|
html = _get(s, STORE)
|
||||||
|
hrefs = re.findall(r'href=["\']([^"\']*store/course/[^"\']+)["\']', html)
|
||||||
|
urls = [u for u in dict.fromkeys(urljoin(STORE, h) for h in hrefs) if u.rstrip("/") != STORE.rstrip("/")]
|
||||||
|
if not urls:
|
||||||
|
runtime.log("system", "公需课选课:选课超市里没有解析到课程链接(%d 字节)" % len(html), "warn")
|
||||||
|
return False
|
||||||
|
pages = [(u, _get(s, u)) for u in urls]
|
||||||
|
if any("进入学习" in p for _, p in pages):
|
||||||
|
runtime.log("system", "公需课选课:%s 年度已有选中的课程,无需再选" % year)
|
||||||
|
return True
|
||||||
|
url, page = pages[min(pick, len(pages)) - 1]
|
||||||
|
if "已有选课" in page:
|
||||||
|
runtime.log("system", "公需课选课:已经选了其他课程,页面不允许再选", "warn")
|
||||||
|
return False
|
||||||
|
m = re.search(r"<(a|button)\b([^>]*)>[^<]*选课[^<]*</\1>", page)
|
||||||
|
attrs = m.group(2) if m else ""
|
||||||
|
target = re.search(r'href=["\']([^"\']+)["\']', attrs)
|
||||||
|
target = target.group(1) if target and not target.group(1).startswith(("javascript", "#")) else None
|
||||||
|
target = target or (re.search(r'["\'](/[^"\']+|https?://[^"\']+)["\']', attrs) or [None, None])[1]
|
||||||
|
if not target:
|
||||||
|
runtime.log("system", "公需课选课:没有在课程页找到选课按钮的请求地址,页面结构可能变了", "warn")
|
||||||
|
return False
|
||||||
|
r = s.get(urljoin(url, target), headers={"Referer": url}, timeout=30)
|
||||||
|
runtime.log("system", "公需课选课:已选第 %d 门(%s),响应 %d:%s" % (pick, url, r.status_code, r.text[:120]))
|
||||||
|
return r.ok
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""莞易学插件:`main.py` 注册平台,`utils/` 放按阶段拆分的工具,`detect.py` 识别页面代际。"""
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
"""抓包:把页面 XHR/fetch 落成 data/capture-*.json。"""
|
"""抓包:把页面 XHR/fetch 落成 data/capture-*.json。"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from study_tool import store
|
from ... import database
|
||||||
from study_tool.config import *
|
from study_tool.runtime import RUNS, log, set_state
|
||||||
from study_tool.runtime import RUNS, log
|
|
||||||
|
from .config import STUDY
|
||||||
|
|
||||||
# ------------------------------------------------------- 抓包(排查平台行为)
|
# ------------------------------------------------------- 抓包(排查平台行为)
|
||||||
CAPTURES = {}
|
CAPTURES = {}
|
||||||
@@ -23,8 +26,7 @@ def capture_worker(key, cookies, minutes=10):
|
|||||||
log(key, "抓包需要 playwright:%s" % e)
|
log(key, "抓包需要 playwright:%s" % e)
|
||||||
return
|
return
|
||||||
recs = CAPTURES.setdefault(key, [])
|
recs = CAPTURES.setdefault(key, [])
|
||||||
log(key, "抓包开始:浏览器里手动走一遍(点课程 → 播几秒 → 交评价),"
|
log(key, "抓包开始:浏览器里手动走一遍(点课程 → 播几秒 → 交评价),完事点「停止抓包」,最多 %d 分钟" % minutes)
|
||||||
"完事点「停止抓包」,最多 %d 分钟" % minutes)
|
|
||||||
|
|
||||||
def on_resp(r):
|
def on_resp(r):
|
||||||
try:
|
try:
|
||||||
@@ -37,9 +39,16 @@ def capture_worker(key, cookies, minutes=10):
|
|||||||
body = r.text()[:4000]
|
body = r.text()[:4000]
|
||||||
except Exception:
|
except Exception:
|
||||||
body = ""
|
body = ""
|
||||||
recs.append({"t": time.strftime("%H:%M:%S"), "method": r.request.method,
|
recs.append(
|
||||||
"url": r.url, "req": (r.request.post_data or "")[:1000],
|
{
|
||||||
"status": r.status, "resp": body})
|
"t": time.strftime("%H:%M:%S"),
|
||||||
|
"method": r.request.method,
|
||||||
|
"url": r.url,
|
||||||
|
"req": (r.request.post_data or "")[:1000],
|
||||||
|
"status": r.status,
|
||||||
|
"resp": body,
|
||||||
|
}
|
||||||
|
)
|
||||||
u = urlsplit(r.url)
|
u = urlsplit(r.url)
|
||||||
log(key, " · %s %s%s → %s" % (r.request.method, u.netloc, u.path, r.status))
|
log(key, " · %s %s%s → %s" % (r.request.method, u.netloc, u.path, r.status))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -52,9 +61,16 @@ def capture_worker(key, cookies, minutes=10):
|
|||||||
for c in cookies:
|
for c in cookies:
|
||||||
if "dgjy" in (c.get("domain") or ""):
|
if "dgjy" in (c.get("domain") or ""):
|
||||||
try:
|
try:
|
||||||
ctx.add_cookies([{"name": c["name"], "value": c.get("value") or "",
|
ctx.add_cookies(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": c["name"],
|
||||||
|
"value": c.get("value") or "",
|
||||||
"domain": (c.get("domain") or "").lstrip("."),
|
"domain": (c.get("domain") or "").lstrip("."),
|
||||||
"path": c.get("path") or "/"}])
|
"path": c.get("path") or "/",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -83,8 +99,7 @@ def capture_worker(key, cookies, minutes=10):
|
|||||||
path = ""
|
path = ""
|
||||||
if recs:
|
if recs:
|
||||||
try:
|
try:
|
||||||
path = os.path.join(store.DATA_DIR,
|
path = os.path.join(database.DATA_DIR, "capture-%s-%s.json" % (key, time.strftime("%Y%m%d-%H%M%S")))
|
||||||
"capture-%s-%s.json" % (key, time.strftime("%Y%m%d-%H%M%S")))
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(recs, f, ensure_ascii=False, indent=1)
|
json.dump(recs, f, ensure_ascii=False, indent=1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
"""全局常量 / 环境变量。
|
"""莞易学插件常量:站点地址、登录授权入口、课程板块、页面快照目录。
|
||||||
|
|
||||||
平台地址、默认值只在这里写一份,其它模块 `from study_tool.config import *`。
|
只给 `plugins/guanyixue/` 内部用;其它平台各自在自己的插件目录里放常量。
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
# 包内路径 -> 项目根目录:data / static / script 这些运行时资源都留在仓库根
|
from study_tool.config import DATA
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
|
||||||
DATA = os.path.join(ROOT, "data")
|
|
||||||
LOGDIR = os.path.join(DATA, "logs") # 旧版日志目录;只有首次迁移会读它
|
|
||||||
STATIC_DIR = os.path.join(ROOT, "static")
|
|
||||||
os.makedirs(LOGDIR, exist_ok=True)
|
|
||||||
|
|
||||||
def _find_node():
|
def _find_node():
|
||||||
"""Locate a node.exe: $NODE_BIN > PATH > common install dirs."""
|
"""Locate a node.exe: $NODE_BIN > PATH > common install dirs."""
|
||||||
@@ -26,7 +21,7 @@ def _find_node():
|
|||||||
|
|
||||||
|
|
||||||
NODE = _find_node()
|
NODE = _find_node()
|
||||||
DESENC = os.path.join(ROOT, "script", "desenc.js")
|
DESENC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "script", "desenc.js") # 随插件目录走
|
||||||
|
|
||||||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
"(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0")
|
"(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0")
|
||||||
@@ -67,7 +62,3 @@ PLAN_STATUS = [
|
|||||||
]
|
]
|
||||||
# 只有这两种状态还能进去学;已结束的只能查看
|
# 只有这两种状态还能进去学;已结束的只能查看
|
||||||
STUDYABLE_STATUS = {"TAKING_PLAN", "NO_BEGAIN_PLAN"}
|
STUDYABLE_STATUS = {"TAKING_PLAN", "NO_BEGAIN_PLAN"}
|
||||||
|
|
||||||
|
|
||||||
class SessionExpired(RuntimeError):
|
|
||||||
"""站点要求重新登录(cookie 已失效)。"""
|
|
||||||
@@ -9,8 +9,14 @@
|
|||||||
LEGACY_MARKERS = ("tier-tree-one", "tier3")
|
LEGACY_MARKERS = ("tier-tree-one", "tier3")
|
||||||
|
|
||||||
# 新版 SPA 标记:Vue 入口、培训课程接口路由、study 域名
|
# 新版 SPA 标记:Vue 入口、培训课程接口路由、study 域名
|
||||||
SPA_MARKERS = ("/training/curriculum", "/training/courseVideo", "/training/comment",
|
SPA_MARKERS = (
|
||||||
"static/js/app.", "study.dgjy.net/training", "study.dgjy.net/#/")
|
"/training/curriculum",
|
||||||
|
"/training/courseVideo",
|
||||||
|
"/training/comment",
|
||||||
|
"static/js/app.",
|
||||||
|
"study.dgjy.net/training",
|
||||||
|
"study.dgjy.net/#/",
|
||||||
|
)
|
||||||
SPA_HOST = "study.dgjy.net"
|
SPA_HOST = "study.dgjy.net"
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""莞易学平台:jspx.dgjy.net(登录入口)与 study.dgjy.net(学习入口)是同一个平台。
|
||||||
|
|
||||||
|
账号在「添加账号」里选择平台(`accounts.platform`),登录 / 刷新课程 / 发现课程都会
|
||||||
|
走这里的 `login` / `list_courses` / `discover`;学习时 `discover` 会把页面代际写进
|
||||||
|
`course["generation"]`(legacy / spa),由 `study` 分发到对应实现。
|
||||||
|
工具按阶段拆在 `utils/`:http / login / course / feedback / learn / spa;
|
||||||
|
抓包调试在 `capture.py`,测验解析 / 交卷 / AI 答题在 `quiz.py`。
|
||||||
|
新增其他平台时,照本文件写一个 `Platform` 子类并在 `entries` 声明登录 / 学习入口。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from study_tool import runtime
|
||||||
|
from study_tool.platforms import registry
|
||||||
|
from study_tool.platforms.base import Platform
|
||||||
|
|
||||||
|
from . import capture, quiz # noqa: F401 quiz 导入即注册 /api/quiz 路由
|
||||||
|
from .utils import course, http, learn, login, spa
|
||||||
|
|
||||||
|
|
||||||
|
class Guanyixue(Platform):
|
||||||
|
id = "guanyixue"
|
||||||
|
label = "莞易学"
|
||||||
|
# 登录入口 / 学习入口;命中任意一个就归到莞易学
|
||||||
|
entries = ("jspx.dgjy.net", "study.dgjy.net", "dgjy.net")
|
||||||
|
|
||||||
|
def session(self, cookies=None):
|
||||||
|
return http.new_session(cookies)
|
||||||
|
|
||||||
|
def dump_cookies(self, s):
|
||||||
|
return http.dump_cookies(s)
|
||||||
|
|
||||||
|
def login(self, username, password):
|
||||||
|
return login.login(username, password)
|
||||||
|
|
||||||
|
def list_courses(self, s):
|
||||||
|
return course.scrape_courses(s)
|
||||||
|
|
||||||
|
def discover(self, s, plan_id, project_id, name=""):
|
||||||
|
return course.discover(s, plan_id, project_id, name)
|
||||||
|
|
||||||
|
def renew_session(self, s):
|
||||||
|
return login.ensure_jspx_session(s)
|
||||||
|
|
||||||
|
def profile(self, s):
|
||||||
|
return login.whoami(login.fetch_admin(s))
|
||||||
|
|
||||||
|
def quiz(self, s, d, b):
|
||||||
|
return quiz.fetch_quiz(s, d, b)
|
||||||
|
|
||||||
|
def quiz_submit(self, s, b):
|
||||||
|
return quiz.submit_quiz(s, b)
|
||||||
|
|
||||||
|
def study(self, key, s, d, **opts):
|
||||||
|
if d.get("generation") == "spa":
|
||||||
|
page = (d.get("page_url") or "")[:110]
|
||||||
|
runtime.log(key, "新版课程 ID:%s%s" % (d.get("train_id") or "?", (" | 页面:" + page) if page else ""))
|
||||||
|
return spa._study(key, s, d, **opts)
|
||||||
|
return learn._study(key, s, d, **opts)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 抓包调试
|
||||||
|
def capture(self, key, cookies, minutes=10):
|
||||||
|
return capture.capture_worker(key, cookies, minutes)
|
||||||
|
|
||||||
|
def captures(self, key):
|
||||||
|
return capture.CAPTURES.get(key) or []
|
||||||
|
|
||||||
|
def capture_reset(self, key):
|
||||||
|
capture.CAPTURES[key] = []
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORM = registry.register(Guanyixue())
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"id": "guanyixue",
|
||||||
|
"title": "莞易学(东莞市教师培训平台)",
|
||||||
|
"name": "guanyixue"
|
||||||
|
}
|
||||||
@@ -1,22 +1,72 @@
|
|||||||
"""测验:题目解析、交卷、AI 答题(流式)。"""
|
"""测验:题目解析、交卷、AI 答题(流式)。"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from study_tool.config.settings import load_settings
|
||||||
|
from study_tool.llm import llm_chat
|
||||||
|
from study_tool.platforms import registry
|
||||||
|
from study_tool.platforms.errors import SessionExpired
|
||||||
|
from study_tool.runtime import log
|
||||||
from study_tool.webapp import app
|
from study_tool.webapp import app
|
||||||
|
|
||||||
from study_tool.config import *
|
from .config import JSPX, STUDY
|
||||||
from study_tool.engine import *
|
from .utils.course import parse_ids
|
||||||
from study_tool.llm import *
|
from .utils.http import new_session
|
||||||
from study_tool.models import *
|
from .utils.spa import StudyClient
|
||||||
from study_tool.platforms import registry
|
|
||||||
from study_tool.runtime import log
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ quiz
|
# ------------------------------------------------------------------ quiz
|
||||||
|
|
||||||
STUDY_QMAP = {"SINGLECHOICE": "01", "MULTIPLECHOICE": "02", "JUDGMENT": "03",
|
|
||||||
"TRUEFALSE": "03", "FILLIN": "04", "SHORTANSWER": "04", "ESSAY": "04"}
|
class QuizIn(BaseModel):
|
||||||
|
cookies: list = []
|
||||||
|
plan_id: str = ""
|
||||||
|
project_id: str = ""
|
||||||
|
name: str = ""
|
||||||
|
platform: str = "guanyixue"
|
||||||
|
# retake=True:交卷后点「再考一次」,让平台生成新答卷(消耗一次机会)
|
||||||
|
retake: bool = False
|
||||||
|
period_id: str = "" # 指定要重考/查看的课时(不传就按原逻辑挑第一个)
|
||||||
|
|
||||||
|
|
||||||
|
class QuizSubmitIn(BaseModel):
|
||||||
|
cookies: list = []
|
||||||
|
platform: str = "" # 交卷按平台分发
|
||||||
|
plan_id: str = ""
|
||||||
|
object_id: str = ""
|
||||||
|
generation: str = "legacy" # legacy / spa,决定走旧版还是新版交卷接口
|
||||||
|
result_id: str = ""
|
||||||
|
answers: dict = {} # {"1": "A", "2": "A#C"}
|
||||||
|
|
||||||
|
|
||||||
|
class QuizAnswerIn(BaseModel):
|
||||||
|
questions: list = [] # [{no,type,title,options:[{key,text}]}]
|
||||||
|
prompt: str = ""
|
||||||
|
account_id: str = "" # 有账号就把日志写进它的「课程日志」,没有就写 key=ai
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ANSWER_PROMPT = (
|
||||||
|
"你是考试答题助手。根据题干和选项判断正确答案。\n"
|
||||||
|
"单选题返回一个选项字母;多选题返回多个字母,用 # 连接(如 A#C);判断题只返回 A 或 B(A=正确,B=错误)。\n"
|
||||||
|
"只输出 JSON:{\"answers\":{\"题号\":\"答案\"}},不要输出解释或多余文字。"
|
||||||
|
)
|
||||||
|
TYPE_LABEL = {"01": "单选", "02": "多选", "03": "判断", "04": "问答"}
|
||||||
|
|
||||||
|
STUDY_QMAP = {
|
||||||
|
"SINGLECHOICE": "01",
|
||||||
|
"MULTIPLECHOICE": "02",
|
||||||
|
"JUDGMENT": "03",
|
||||||
|
"TRUEFALSE": "03",
|
||||||
|
"FILLIN": "04",
|
||||||
|
"SHORTANSWER": "04",
|
||||||
|
"ESSAY": "04",
|
||||||
|
}
|
||||||
|
|
||||||
# 判断题在新版平台里没有 metasData 选项(只有一条 sort/content/option 全 null 的占位),
|
# 判断题在新版平台里没有 metasData 选项(只有一条 sort/content/option 全 null 的占位),
|
||||||
# 真正可选项是前端写死的「正确 / 错误」,交卷时 replay 用 0/1 下标表示。
|
# 真正可选项是前端写死的「正确 / 错误」,交卷时 replay 用 0/1 下标表示。
|
||||||
@@ -37,8 +87,7 @@ def answer_tokens(raw, options=None, qtype="01"):
|
|||||||
parts = [p for p in re.split(r"[#,\s、/]+", str(raw or "").strip().upper()) if p]
|
parts = [p for p in re.split(r"[#,\s、/]+", str(raw or "").strip().upper()) if p]
|
||||||
# 多选常被模型写成 AC / ABC 这种连写,按选项字母拆开
|
# 多选常被模型写成 AC / ABC 这种连写,按选项字母拆开
|
||||||
if keys:
|
if keys:
|
||||||
parts = [c for p in parts for c in (list(p) if len(p) > 1 and all(ch in keys for ch in p)
|
parts = [c for p in parts for c in (list(p) if len(p) > 1 and all(ch in keys for ch in p) else [p])]
|
||||||
else [p])]
|
|
||||||
out, seen = [], set()
|
out, seen = [], set()
|
||||||
for p in parts:
|
for p in parts:
|
||||||
if p in keys:
|
if p in keys:
|
||||||
@@ -84,16 +133,19 @@ def study_parse_paper(data):
|
|||||||
if rp:
|
if rp:
|
||||||
try:
|
try:
|
||||||
idx = json.loads(rp) if isinstance(rp, str) else rp
|
idx = json.loads(rp) if isinstance(rp, str) else rp
|
||||||
picked = "#".join(opts[int(i)]["key"] for i in idx
|
picked = "#".join(opts[int(i)]["key"] for i in idx if 0 <= int(i) < len(opts))
|
||||||
if 0 <= int(i) < len(opts))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
picked = ""
|
picked = ""
|
||||||
out.append({"no": str(int(it.get("seq") or 0) + 1),
|
out.append(
|
||||||
|
{
|
||||||
|
"no": str(int(it.get("seq") or 0) + 1),
|
||||||
"type": qtype,
|
"type": qtype,
|
||||||
"title": _plain(it.get("stem"))[:600],
|
"title": _plain(it.get("stem"))[:600],
|
||||||
"options": opts,
|
"options": opts,
|
||||||
"item_id": it.get("itemResultId"),
|
"item_id": it.get("itemResultId"),
|
||||||
"picked": picked})
|
"picked": picked,
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -165,15 +217,15 @@ def study_parse_review(data, show_answers=True):
|
|||||||
if not opts and qtype == "03":
|
if not opts and qtype == "03":
|
||||||
opts = [dict(o) for o in JUDGE_OPTIONS]
|
opts = [dict(o) for o in JUDGE_OPTIONS]
|
||||||
|
|
||||||
picked = (_idx_letters(it.get("replay"), opts)
|
picked = (
|
||||||
if qtype in ("01", "02", "03") else _answer_text(it.get("replay")))
|
_idx_letters(it.get("replay"), opts) if qtype in ("01", "02", "03") else _answer_text(it.get("replay"))
|
||||||
|
)
|
||||||
correct, is_correct = "", None
|
correct, is_correct = "", None
|
||||||
if show_answers:
|
if show_answers:
|
||||||
if qtype in ("01", "02", "03"):
|
if qtype in ("01", "02", "03"):
|
||||||
correct = _idx_letters(it.get("answer"), opts)
|
correct = _idx_letters(it.get("answer"), opts)
|
||||||
if correct:
|
if correct:
|
||||||
is_correct = (set(picked.split("#")) - {""}) == \
|
is_correct = (set(picked.split("#")) - {""}) == (set(correct.split("#")) - {""})
|
||||||
(set(correct.split("#")) - {""})
|
|
||||||
else:
|
else:
|
||||||
correct = _answer_text(it.get("answer"))
|
correct = _answer_text(it.get("answer"))
|
||||||
try:
|
try:
|
||||||
@@ -184,7 +236,9 @@ def study_parse_review(data, show_answers=True):
|
|||||||
ascore = float(it.get("answerScore") or 0)
|
ascore = float(it.get("answerScore") or 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
ascore = None
|
ascore = None
|
||||||
out.append({"no": str(seq),
|
out.append(
|
||||||
|
{
|
||||||
|
"no": str(seq),
|
||||||
"type": qtype,
|
"type": qtype,
|
||||||
"title": _plain(it.get("stem"))[:600],
|
"title": _plain(it.get("stem"))[:600],
|
||||||
"options": opts,
|
"options": opts,
|
||||||
@@ -193,7 +247,9 @@ def study_parse_review(data, show_answers=True):
|
|||||||
"correct": correct,
|
"correct": correct,
|
||||||
"is_correct": is_correct,
|
"is_correct": is_correct,
|
||||||
"answer_score": ascore,
|
"answer_score": ascore,
|
||||||
"analysis": (_plain(it.get("analysis"))[:600] if show_answers else "")})
|
"analysis": (_plain(it.get("analysis"))[:600] if show_answers else ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -209,8 +265,12 @@ def parse_quiz_html(html):
|
|||||||
if key:
|
if key:
|
||||||
opts.append({"key": key.upper(), "text": txt})
|
opts.append({"key": key.upper(), "text": txt})
|
||||||
stem = ""
|
stem = ""
|
||||||
node = (it.select_one(".job-item-t") or it.select_one(".job-item-title")
|
node = (
|
||||||
or it.select_one("p.title") or it.select_one("div.title"))
|
it.select_one(".job-item-t")
|
||||||
|
or it.select_one(".job-item-title")
|
||||||
|
or it.select_one("p.title")
|
||||||
|
or it.select_one("div.title")
|
||||||
|
)
|
||||||
if node:
|
if node:
|
||||||
stem = re.sub(r"\s+", " ", node.get_text(" ", strip=True))
|
stem = re.sub(r"\s+", " ", node.get_text(" ", strip=True))
|
||||||
else:
|
else:
|
||||||
@@ -224,12 +284,16 @@ def parse_quiz_html(html):
|
|||||||
qtype = (it.get("questype") or "01").strip()
|
qtype = (it.get("questype") or "01").strip()
|
||||||
if not opts and qtype == "03":
|
if not opts and qtype == "03":
|
||||||
opts = [dict(o) for o in JUDGE_OPTIONS]
|
opts = [dict(o) for o in JUDGE_OPTIONS]
|
||||||
out.append({"no": (it.get("quesno") or "").strip(),
|
out.append(
|
||||||
|
{
|
||||||
|
"no": (it.get("quesno") or "").strip(),
|
||||||
"type": qtype,
|
"type": qtype,
|
||||||
"title": stem[:600],
|
"title": stem[:600],
|
||||||
"options": opts,
|
"options": opts,
|
||||||
"traineeid": it.get("traineeid"),
|
"traineeid": it.get("traineeid"),
|
||||||
"ques_id": it.get("qumid")})
|
"ques_id": it.get("qumid"),
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -250,6 +314,8 @@ def api_quiz(b: QuizIn):
|
|||||||
pf = registry.get(b.platform)
|
pf = registry.get(b.platform)
|
||||||
if pf is None or pf.discover is None:
|
if pf is None or pf.discover is None:
|
||||||
return {"ok": False, "error": "平台「%s」还没有实现课程发现" % b.platform}
|
return {"ok": False, "error": "平台「%s」还没有实现课程发现" % b.platform}
|
||||||
|
if pf.quiz is None:
|
||||||
|
return {"ok": False, "error": "平台「%s」没有测验能力" % b.platform}
|
||||||
s = (pf.session or new_session)(b.cookies)
|
s = (pf.session or new_session)(b.cookies)
|
||||||
try:
|
try:
|
||||||
d = pf.discover(s, b.plan_id, b.project_id, b.name)
|
d = pf.discover(s, b.plan_id, b.project_id, b.name)
|
||||||
@@ -257,7 +323,11 @@ def api_quiz(b: QuizIn):
|
|||||||
return {"ok": False, "code": "session_expired", "error": str(e)[:300]}
|
return {"ok": False, "code": "session_expired", "error": str(e)[:300]}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e)[:300]}
|
return {"ok": False, "error": str(e)[:300]}
|
||||||
|
return pf.quiz(s, d, b)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_quiz(s, d, b):
|
||||||
|
"""莞易学的取题(平台 quiz 能力的实现):新版走接口,旧版解析 showTestQums 页面。"""
|
||||||
tests = d.get("test") or []
|
tests = d.get("test") or []
|
||||||
if not tests:
|
if not tests:
|
||||||
return {"ok": False, "error": "这门课没有测验"}
|
return {"ok": False, "error": "这门课没有测验"}
|
||||||
@@ -278,10 +348,16 @@ def api_quiz(b: QuizIn):
|
|||||||
qs = parse_quiz_html(html)
|
qs = parse_quiz_html(html)
|
||||||
if not qs:
|
if not qs:
|
||||||
return {"ok": False, "error": "没解析到题目(页面 %d 字节),站点可能改版" % len(html)}
|
return {"ok": False, "error": "没解析到题目(页面 %d 字节),站点可能改版" % len(html)}
|
||||||
return {"ok": True, "platform": "guanyixue", "generation": "legacy",
|
return {
|
||||||
"plan_id": plan, "object_id": obj,
|
"ok": True,
|
||||||
"title": t.get("title") or "测验", "done": bool(t.get("done")),
|
"platform": "guanyixue",
|
||||||
"questions": qs}
|
"generation": "legacy",
|
||||||
|
"plan_id": plan,
|
||||||
|
"object_id": obj,
|
||||||
|
"title": t.get("title") or "测验",
|
||||||
|
"done": bool(t.get("done")),
|
||||||
|
"questions": qs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _quiz_study(s, d, tests, retake=False, period_id=""):
|
def _quiz_study(s, d, tests, retake=False, period_id=""):
|
||||||
@@ -297,15 +373,18 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
|
|||||||
|
|
||||||
这些和页面上「开始考试 / 查看试卷」发的是同一条请求,所以纯 API 也能完成。
|
这些和页面上「开始考试 / 查看试卷」发的是同一条请求,所以纯 API 也能完成。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def pick_test():
|
def pick_test():
|
||||||
if period_id:
|
if period_id:
|
||||||
for x in tests:
|
for x in tests:
|
||||||
if str(x.get("period_id") or "") == str(period_id):
|
if str(x.get("period_id") or "") == str(period_id):
|
||||||
return x
|
return x
|
||||||
return (next((x for x in tests if not x.get("done") and x.get("testpaper_result_id")), None)
|
return (
|
||||||
|
next((x for x in tests if not x.get("done") and x.get("testpaper_result_id")), None)
|
||||||
or next((x for x in tests if not x.get("done")), None)
|
or next((x for x in tests if not x.get("done")), None)
|
||||||
or next((x for x in tests if x.get("testpaper_result_id")), None)
|
or next((x for x in tests if x.get("testpaper_result_id")), None)
|
||||||
or (tests[0] if tests else None))
|
or (tests[0] if tests else None)
|
||||||
|
)
|
||||||
|
|
||||||
t = pick_test()
|
t = pick_test()
|
||||||
if not t:
|
if not t:
|
||||||
@@ -319,14 +398,15 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
|
|||||||
|
|
||||||
def start_paper(pid, err_prefix):
|
def start_paper(pid, err_prefix):
|
||||||
try:
|
try:
|
||||||
j0 = client.call("PUT", "/questions/front/testpaper/start/do",
|
j0 = client.call(
|
||||||
json_body={"periodId": int(pid),
|
"PUT",
|
||||||
"trainCourseId": d.get("train_id") or ""})
|
"/questions/front/testpaper/start/do",
|
||||||
|
json_body={"periodId": int(pid), "trainCourseId": d.get("train_id") or ""},
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return None, "%s:%s" % (err_prefix, str(e)[:200])
|
return None, "%s:%s" % (err_prefix, str(e)[:200])
|
||||||
if str(j0.get("code")) != "200":
|
if str(j0.get("code")) != "200":
|
||||||
return None, "%s:%s" % (err_prefix,
|
return None, "%s:%s" % (err_prefix, str(j0.get("msg") or j0.get("message") or j0)[:200])
|
||||||
str(j0.get("msg") or j0.get("message") or j0)[:200])
|
|
||||||
data0 = j0.get("data") or {}
|
data0 = j0.get("data") or {}
|
||||||
new_rid = str(data0.get("testpaperResultId") or data0.get("resultId") or "")
|
new_rid = str(data0.get("testpaperResultId") or data0.get("resultId") or "")
|
||||||
if not new_rid:
|
if not new_rid:
|
||||||
@@ -336,12 +416,14 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
|
|||||||
# ---- 重考:已交卷且还有剩余次数,start/do 会生成一份新答卷 ----
|
# ---- 重考:已交卷且还有剩余次数,start/do 会生成一份新答卷 ----
|
||||||
if retake:
|
if retake:
|
||||||
if not t.get("done") and t.get("testpaper_result_id"):
|
if not t.get("done") and t.get("testpaper_result_id"):
|
||||||
return {"ok": False, "code": "doing",
|
return {"ok": False, "code": "doing", "error": "还有一份未交卷的答卷,先交卷再重考"}
|
||||||
"error": "还有一份未交卷的答卷,先交卷再重考"}
|
|
||||||
if not (isinstance(left, int) and left > 0):
|
if not (isinstance(left, int) and left > 0):
|
||||||
used = (allow - left) if isinstance(allow, int) and isinstance(left, int) else "?"
|
used = (allow - left) if isinstance(allow, int) and isinstance(left, int) else "?"
|
||||||
return {"ok": False, "code": "no_attempts",
|
return {
|
||||||
"error": "考试次数已用完(已用 %s/%s 次),不能再考" % (used, allow)}
|
"ok": False,
|
||||||
|
"code": "no_attempts",
|
||||||
|
"error": "考试次数已用完(已用 %s/%s 次),不能再考" % (used, allow),
|
||||||
|
}
|
||||||
pid = str(t.get("period_id") or "").strip()
|
pid = str(t.get("period_id") or "").strip()
|
||||||
if not pid:
|
if not pid:
|
||||||
return {"ok": False, "code": "no_period", "error": "这个测验没有课时 ID,无法重考"}
|
return {"ok": False, "code": "no_period", "error": "这个测验没有课时 ID,无法重考"}
|
||||||
@@ -350,8 +432,7 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
|
|||||||
return {"ok": False, "error": err}
|
return {"ok": False, "error": err}
|
||||||
old_rid = str(t.get("testpaper_result_id") or "")
|
old_rid = str(t.get("testpaper_result_id") or "")
|
||||||
if new_rid == old_rid:
|
if new_rid == old_rid:
|
||||||
return {"ok": False, "code": "no_attempts",
|
return {"ok": False, "code": "no_attempts", "error": "平台没有生成新答卷(可能次数已用完或需要老师重置)"}
|
||||||
"error": "平台没有生成新答卷(可能次数已用完或需要老师重置)"}
|
|
||||||
started = True
|
started = True
|
||||||
t = dict(t)
|
t = dict(t)
|
||||||
t.update({"testpaper_result_id": new_rid, "done": False, "result_status": "DOING"})
|
t.update({"testpaper_result_id": new_rid, "done": False, "result_status": "DOING"})
|
||||||
@@ -364,12 +445,10 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
|
|||||||
# ---- 第一次取题:还没有答卷就自动创建 ----
|
# ---- 第一次取题:还没有答卷就自动创建 ----
|
||||||
if not rid:
|
if not rid:
|
||||||
if t.get("done"):
|
if t.get("done"):
|
||||||
return {"ok": False, "code": "no_result",
|
return {"ok": False, "code": "no_result", "error": "这个测验已完成,但平台没有返回答卷 ID,无法再看题目"}
|
||||||
"error": "这个测验已完成,但平台没有返回答卷 ID,无法再看题目"}
|
|
||||||
pid = str(t.get("period_id") or "").strip()
|
pid = str(t.get("period_id") or "").strip()
|
||||||
if not pid:
|
if not pid:
|
||||||
return {"ok": False, "code": "no_period",
|
return {"ok": False, "code": "no_period", "error": "这个测验没有课时 ID,无法自动开始答卷"}
|
||||||
"error": "这个测验没有课时 ID,无法自动开始答卷"}
|
|
||||||
rid, err = start_paper(pid, "自动开始答卷失败")
|
rid, err = start_paper(pid, "自动开始答卷失败")
|
||||||
if err:
|
if err:
|
||||||
return {"ok": False, "error": err}
|
return {"ok": False, "error": err}
|
||||||
@@ -378,8 +457,7 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
|
|||||||
# ---- 取当前答卷;已交卷的再调 review 拿标准答案 ----
|
# ---- 取当前答卷;已交卷的再调 review 拿标准答案 ----
|
||||||
paper = None
|
paper = None
|
||||||
try:
|
try:
|
||||||
j = client.call("GET", "/questions/front/testpaper/getById",
|
j = client.call("GET", "/questions/front/testpaper/getById", params={"testpaperResultId": rid})
|
||||||
params={"testpaperResultId": rid})
|
|
||||||
if str(j.get("code")) == "200":
|
if str(j.get("code")) == "200":
|
||||||
paper = j.get("data") or {}
|
paper = j.get("data") or {}
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -391,8 +469,7 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
|
|||||||
qs, review = [], False
|
qs, review = [], False
|
||||||
if done:
|
if done:
|
||||||
try:
|
try:
|
||||||
jr = client.call("GET", "/questions/front/testpaper/teacher/review",
|
jr = client.call("GET", "/questions/front/testpaper/teacher/review", params={"testpaperResultId": rid})
|
||||||
params={"testpaperResultId": rid})
|
|
||||||
if str(jr.get("code")) == "200":
|
if str(jr.get("code")) == "200":
|
||||||
rdata = jr.get("data") or {}
|
rdata = jr.get("data") or {}
|
||||||
qs = study_parse_review(rdata, show_answers=show_answers)
|
qs = study_parse_review(rdata, show_answers=show_answers)
|
||||||
@@ -413,7 +490,11 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
|
|||||||
passed_score = (paper or {}).get("passedScore")
|
passed_score = (paper or {}).get("passedScore")
|
||||||
if passed_score is None:
|
if passed_score is None:
|
||||||
passed_score = t.get("passed_score")
|
passed_score = t.get("passed_score")
|
||||||
return {"ok": True, "platform": "guanyixue", "generation": "spa", "result_id": rid,
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"platform": "guanyixue",
|
||||||
|
"generation": "spa",
|
||||||
|
"result_id": rid,
|
||||||
"period_id": t.get("period_id"),
|
"period_id": t.get("period_id"),
|
||||||
"train_id": d.get("train_id") or "",
|
"train_id": d.get("train_id") or "",
|
||||||
"started": started, # True = 本次请求创建了新答卷
|
"started": started, # True = 本次请求创建了新答卷
|
||||||
@@ -430,7 +511,8 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
|
|||||||
"score": score,
|
"score": score,
|
||||||
"passed_score": passed_score,
|
"passed_score": passed_score,
|
||||||
"passed": str(res.get("passedStatus") or "") == "1",
|
"passed": str(res.get("passedStatus") or "") == "1",
|
||||||
"submit_time": res.get("submitTime")}
|
"submit_time": res.get("submitTime"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _quiz_study_submit(b):
|
def _quiz_study_submit(b):
|
||||||
@@ -446,8 +528,7 @@ def _quiz_study_submit(b):
|
|||||||
s = new_session(b.cookies)
|
s = new_session(b.cookies)
|
||||||
client = StudyClient(s, STUDY + "/training/curriculum")
|
client = StudyClient(s, STUDY + "/training/curriculum")
|
||||||
try:
|
try:
|
||||||
j = client.call("GET", "/questions/front/testpaper/getById",
|
j = client.call("GET", "/questions/front/testpaper/getById", params={"testpaperResultId": b.result_id})
|
||||||
params={"testpaperResultId": b.result_id})
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": "交卷前取题失败:%s" % str(e)[:200]}
|
return {"ok": False, "error": "交卷前取题失败:%s" % str(e)[:200]}
|
||||||
if str(j.get("code")) != "200":
|
if str(j.get("code")) != "200":
|
||||||
@@ -459,9 +540,12 @@ def _quiz_study_submit(b):
|
|||||||
st0 = str(res0.get("status") or "").upper()
|
st0 = str(res0.get("status") or "").upper()
|
||||||
if st0 in ("FINISHED", "REVIEWED", "REVIEWING"):
|
if st0 in ("FINISHED", "REVIEWED", "REVIEWING"):
|
||||||
sc = res0.get("answerSumScore")
|
sc = res0.get("answerSumScore")
|
||||||
return {"ok": False, "code": "already_finished",
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"code": "already_finished",
|
||||||
"error": "这份答卷已经交过卷%s;点「再考一次」可以生成新答卷再提交"
|
"error": "这份答卷已经交过卷%s;点「再考一次」可以生成新答卷再提交"
|
||||||
% ("(得分 %s)" % sc if sc is not None else "")}
|
% ("(得分 %s)" % sc if sc is not None else ""),
|
||||||
|
}
|
||||||
|
|
||||||
items, answered = [], 0
|
items, answered = [], 0
|
||||||
for q in qs:
|
for q in qs:
|
||||||
@@ -482,24 +566,35 @@ def _quiz_study_submit(b):
|
|||||||
|
|
||||||
score = None
|
score = None
|
||||||
try:
|
try:
|
||||||
j2 = client.call("GET", "/questions/front/testpaper/getById",
|
j2 = client.call("GET", "/questions/front/testpaper/getById", params={"testpaperResultId": b.result_id})
|
||||||
params={"testpaperResultId": b.result_id})
|
res = (j2.get("data") or {}).get("result") or {}
|
||||||
res = ((j2.get("data") or {}).get("result") or {})
|
|
||||||
score = res.get("answerSumScore")
|
score = res.get("answerSumScore")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {"ok": True, "answers": answered, "total": len(items), "score": score,
|
return {"ok": True, "answers": answered, "total": len(items), "score": score, "msg": "交卷成功"}
|
||||||
"msg": "交卷成功"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/quiz/submit")
|
@app.post("/api/quiz/submit")
|
||||||
def api_quiz_submit(b: QuizSubmitIn):
|
def api_quiz_submit(b: QuizSubmitIn):
|
||||||
"""按 quesNo 合并用户/模型的答案后交卷。重新取一次题,保证 traineeid 是当前会话的。"""
|
"""按平台分发交卷:每个平台自己决定怎么把答案交给站点。"""
|
||||||
if not b.cookies:
|
if not b.cookies:
|
||||||
return {"ok": False, "error": "尚未登录"}
|
return {"ok": False, "error": "尚未登录"}
|
||||||
|
pf = registry.get(b.platform)
|
||||||
|
if pf is None or pf.quiz_submit is None:
|
||||||
|
return {"ok": False, "error": "平台「%s」没有交卷能力" % b.platform}
|
||||||
|
s = (pf.session or new_session)(b.cookies)
|
||||||
|
try:
|
||||||
|
return pf.quiz_submit(s, b)
|
||||||
|
except SessionExpired as e:
|
||||||
|
return {"ok": False, "code": "session_expired", "error": str(e)[:300]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)[:200]}
|
||||||
|
|
||||||
|
|
||||||
|
def submit_quiz(s, b):
|
||||||
|
"""莞易学的交卷(平台 quiz_submit 的实现):重新取一次题,保证 traineeid 是当前会话的。"""
|
||||||
if b.generation == "spa":
|
if b.generation == "spa":
|
||||||
return _quiz_study_submit(b)
|
return _quiz_study_submit(b)
|
||||||
s = new_session(b.cookies)
|
|
||||||
try:
|
try:
|
||||||
html = fetch_quiz_page(s, b.plan_id, b.object_id)
|
html = fetch_quiz_page(s, b.plan_id, b.object_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -513,30 +608,39 @@ def api_quiz_submit(b: QuizSubmitIn):
|
|||||||
ans = str((b.answers or {}).get(it["no"]) or "").strip().upper()
|
ans = str((b.answers or {}).get(it["no"]) or "").strip().upper()
|
||||||
if not ans:
|
if not ans:
|
||||||
ans = it["options"][0]["key"] if it["options"] else "A"
|
ans = it["options"][0]["key"] if it["options"] else "A"
|
||||||
answers.append({"id": it["traineeid"], "quesId": it["ques_id"],
|
answers.append(
|
||||||
"quesType": it["type"], "quesNo": it["no"], "answer": ans})
|
{"id": it["traineeid"], "quesId": it["ques_id"], "quesType": it["type"], "quesNo": it["no"], "answer": ans}
|
||||||
payload = {"planId": b.plan_id, "objectId": b.object_id,
|
)
|
||||||
"answers": json.dumps(answers, ensure_ascii=False, separators=(",", ":"))}
|
payload = {
|
||||||
|
"planId": b.plan_id,
|
||||||
|
"objectId": b.object_id,
|
||||||
|
"answers": json.dumps(answers, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
}
|
||||||
r3 = s.post(JSPX + "/train/trainee/endTest.action", data=payload, timeout=40)
|
r3 = s.post(JSPX + "/train/trainee/endTest.action", data=payload, timeout=40)
|
||||||
try:
|
try:
|
||||||
r4 = s.get("%s/train/trainee/doTest.action?planId=%s&objectId=%s"
|
r4 = s.get("%s/train/trainee/doTest.action?planId=%s&objectId=%s" % (JSPX, b.plan_id, b.object_id), timeout=40)
|
||||||
% (JSPX, b.plan_id, b.object_id), timeout=40)
|
|
||||||
text = re.sub(r"\s+", " ", BeautifulSoup(r4.text, "lxml").get_text(" ", strip=True))
|
text = re.sub(r"\s+", " ", BeautifulSoup(r4.text, "lxml").get_text(" ", strip=True))
|
||||||
m = re.search(r"得分:\s*(\d+)\s*分", text)
|
m = re.search(r"得分:\s*(\d+)\s*分", text)
|
||||||
score = m.group(1) if m else None
|
score = m.group(1) if m else None
|
||||||
except Exception:
|
except Exception:
|
||||||
score = None
|
score = None
|
||||||
return {"ok": True, "answers": len(answers), "score": score,
|
return {
|
||||||
"msg": "提交成功" if "MSG" not in (r3.text or "") else (r3.text or "")[:80]}
|
"ok": True,
|
||||||
|
"answers": len(answers),
|
||||||
|
"score": score,
|
||||||
|
"msg": "提交成功" if "MSG" not in (r3.text or "") else (r3.text or "")[:80],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_ANS_PAIR = re.compile(
|
_ANS_PAIR = re.compile(
|
||||||
r"""(\d+)\s*(?:题)?\s*["']?\s*[::=、..]\s*["']?\s*([A-F](?:\s*[#、,/,\s]\s*[A-F])*|正确|错误|对|错|√|×)""",
|
r"""(\d+)\s*(?:题)?\s*["']?\s*[::=、..]\s*["']?\s*([A-F](?:\s*[#、,/,\s]\s*[A-F])*|正确|错误|对|错|√|×)""",
|
||||||
re.I)
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_llm_answers(content, questions):
|
def parse_llm_answers(content, questions):
|
||||||
"""优先按 JSON 解析;模型没给 JSON(或被截断)时从「1.A / 第3题:B」里抠。"""
|
"""优先按 JSON 解析;模型没给 JSON(或被截断)时从「1.A / 第3题:B」里抠。"""
|
||||||
|
|
||||||
def _pick(raw):
|
def _pick(raw):
|
||||||
out = {}
|
out = {}
|
||||||
for q in questions:
|
for q in questions:
|
||||||
@@ -550,7 +654,7 @@ def parse_llm_answers(content, questions):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
txt = re.sub(r"```(?:json)?|```", "", (content or "").strip()).strip()
|
txt = re.sub(r"```(?:json)?|```", "", (content or "").strip()).strip()
|
||||||
m = re.search(r"\{.*\}", txt, re.S)
|
m = re.search(r"\{.*\}", txt, re.DOTALL)
|
||||||
if m:
|
if m:
|
||||||
try:
|
try:
|
||||||
data = json.loads(m.group(0))
|
data = json.loads(m.group(0))
|
||||||
@@ -567,16 +671,17 @@ def parse_llm_answers(content, questions):
|
|||||||
|
|
||||||
QUIZ_BATCH = 5
|
QUIZ_BATCH = 5
|
||||||
# 这些错误重试也没用(鉴权/路径/参数问题),其它(超时、5xx、空内容)值得再问一次
|
# 这些错误重试也没用(鉴权/路径/参数问题),其它(超时、5xx、空内容)值得再问一次
|
||||||
_RETRY_HINT = re.compile(r"timed out|timeout|超时|429|5\d\d|空内容|不是 JSON", re.I)
|
_RETRY_HINT = re.compile(r"timed out|timeout|超时|429|5\d\d|空内容|不是 JSON", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _quiz_lines(part):
|
def _quiz_lines(part):
|
||||||
lines = []
|
lines = []
|
||||||
for q in part:
|
for q in part:
|
||||||
opts = ";".join("%s. %s" % (o.get("key"), o.get("text")) for o in (q.get("options") or []))
|
opts = ";".join("%s. %s" % (o.get("key"), o.get("text")) for o in (q.get("options") or []))
|
||||||
lines.append("第%s题(%s)%s\n选项:%s" % (
|
lines.append(
|
||||||
q.get("no"), TYPE_LABEL.get(str(q.get("type")), "单选"),
|
"第%s题(%s)%s\n选项:%s"
|
||||||
q.get("title") or "", opts or "无"))
|
% (q.get("no"), TYPE_LABEL.get(str(q.get("type")), "单选"), q.get("title") or "", opts or "无")
|
||||||
|
)
|
||||||
return "\n\n".join(lines)
|
return "\n\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -591,10 +696,13 @@ def _quiz_ask(system, part, cfg, strict=False):
|
|||||||
strict:连 response_format 一起关掉,改成口头要求(有的网关不认这个字段)。
|
strict:连 response_format 一起关掉,改成口头要求(有的网关不认这个字段)。
|
||||||
"""
|
"""
|
||||||
if strict:
|
if strict:
|
||||||
system += ('\n只输出一个 JSON 对象,形如 {"answers":{"1":"A"}};不要解释,不要 markdown。')
|
system += '\n只输出一个 JSON 对象,形如 {"answers":{"1":"A"}};不要解释,不要 markdown。'
|
||||||
content = llm_chat([{"role": "system", "content": system},
|
content = llm_chat(
|
||||||
{"role": "user", "content": _quiz_lines(part)}],
|
[{"role": "system", "content": system}, {"role": "user", "content": _quiz_lines(part)}],
|
||||||
cfg, max_tokens=1600, json_mode=not strict)
|
cfg,
|
||||||
|
max_tokens=1600,
|
||||||
|
json_mode=not strict,
|
||||||
|
)
|
||||||
return parse_llm_answers(content, part), content
|
return parse_llm_answers(content, part), content
|
||||||
|
|
||||||
|
|
||||||
@@ -605,16 +713,28 @@ def _sse(obj):
|
|||||||
def _quiz_stream(system, questions, cfg, key=""):
|
def _quiz_stream(system, questions, cfg, key=""):
|
||||||
total = len(questions)
|
total = len(questions)
|
||||||
batches = (total + QUIZ_BATCH - 1) // QUIZ_BATCH
|
batches = (total + QUIZ_BATCH - 1) // QUIZ_BATCH
|
||||||
_quiz_log(key, "开始:%d 题 / %d 批,模型 %s(%s),超时 %ss" % (
|
_quiz_log(
|
||||||
total, batches, cfg.get("model") or "?",
|
key,
|
||||||
urlsplit(cfg.get("base_url") or "").hostname or "?", cfg.get("timeout") or 120))
|
"开始:%d 题 / %d 批,模型 %s(%s),超时 %ss"
|
||||||
|
% (
|
||||||
|
total,
|
||||||
|
batches,
|
||||||
|
cfg.get("model") or "?",
|
||||||
|
urlsplit(cfg.get("base_url") or "").hostname or "?",
|
||||||
|
cfg.get("timeout") or 120,
|
||||||
|
),
|
||||||
|
)
|
||||||
t0, filled, failed = time.time(), 0, 0
|
t0, filled, failed = time.time(), 0, 0
|
||||||
for bi, i in enumerate(range(0, total, QUIZ_BATCH), 1):
|
for bi, i in enumerate(range(0, total, QUIZ_BATCH), 1):
|
||||||
part = questions[i : i + QUIZ_BATCH]
|
part = questions[i : i + QUIZ_BATCH]
|
||||||
answers, err = {}, ""
|
answers, err = {}, ""
|
||||||
for strict in (False, True):
|
for strict in (False, True):
|
||||||
tag = "第 %d/%d 批[%s]%s" % (bi, batches, ",".join(str(q.get("no")) for q in part),
|
tag = "第 %d/%d 批[%s]%s" % (
|
||||||
" 补问" if strict else "")
|
bi,
|
||||||
|
batches,
|
||||||
|
",".join(str(q.get("no")) for q in part),
|
||||||
|
" 补问" if strict else "",
|
||||||
|
)
|
||||||
t1 = time.time()
|
t1 = time.time()
|
||||||
try:
|
try:
|
||||||
got, content = _quiz_ask(system, part, cfg, strict)
|
got, content = _quiz_ask(system, part, cfg, strict)
|
||||||
@@ -630,22 +750,27 @@ def _quiz_stream(system, questions, cfg, key=""):
|
|||||||
_quiz_log(key, tag + " 成功 %.1fs:%d 题" % (time.time() - t1, len(answers)))
|
_quiz_log(key, tag + " 成功 %.1fs:%d 题" % (time.time() - t1, len(answers)))
|
||||||
err = ""
|
err = ""
|
||||||
break
|
break
|
||||||
_quiz_log(key, tag + " 本批只答出 %d 题 %.1fs,模型原文:%s" % (
|
_quiz_log(
|
||||||
len(answers), time.time() - t1, content[:300].replace("\n", " ")))
|
key,
|
||||||
|
tag
|
||||||
|
+ " 本批只答出 %d 题 %.1fs,模型原文:%s"
|
||||||
|
% (len(answers), time.time() - t1, content[:300].replace("\n", " ")),
|
||||||
|
)
|
||||||
err = "还有 %d 题没答出来" % len(part)
|
err = "还有 %d 题没答出来" % len(part)
|
||||||
filled += len(answers)
|
filled += len(answers)
|
||||||
if not answers:
|
if not answers:
|
||||||
failed += 1
|
failed += 1
|
||||||
yield _sse({"answers": answers, "error": "" if answers else err})
|
yield _sse({"answers": answers, "error": "" if answers else err})
|
||||||
_quiz_log(key, "结束:已填 %d/%d,失败 %d 批,耗时 %.1fs" % (
|
_quiz_log(key, "结束:已填 %d/%d,失败 %d 批,耗时 %.1fs" % (filled, total, failed, time.time() - t0))
|
||||||
filled, total, failed, time.time() - t0))
|
|
||||||
yield _sse({"done": True, "total": total, "failed": failed})
|
yield _sse({"done": True, "total": total, "failed": failed})
|
||||||
|
|
||||||
|
|
||||||
def _quiz_err(msg, total=0):
|
def _quiz_err(msg, total=0):
|
||||||
return StreamingResponse(iter([_sse({"done": True, "total": total, "failed": 0, "error": msg})]),
|
return StreamingResponse(
|
||||||
|
iter([_sse({"done": True, "total": total, "failed": 0, "error": msg})]),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/quiz/answer")
|
@app.post("/api/quiz/answer")
|
||||||
@@ -664,7 +789,9 @@ def api_quiz_answer(b: QuizAnswerIn):
|
|||||||
if not b.questions:
|
if not b.questions:
|
||||||
_quiz_log(key, "没有题目")
|
_quiz_log(key, "没有题目")
|
||||||
return _quiz_err("没有题目")
|
return _quiz_err("没有题目")
|
||||||
system = (b.prompt or cfg.get("prompt") or DEFAULT_ANSWER_PROMPT)
|
system = b.prompt or cfg.get("prompt") or DEFAULT_ANSWER_PROMPT
|
||||||
return StreamingResponse(_quiz_stream(system, b.questions, cfg, key),
|
return StreamingResponse(
|
||||||
|
_quiz_stream(system, b.questions, cfg, key),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
@@ -16,27 +16,29 @@ study.dgjy.net(莞易学 / 东莞教师培训)视频课时 —— 自动完
|
|||||||
GET /train/course/front/period/list/tree/studyLog?courseId=<MOOC课程ID>&isFront=1
|
GET /train/course/front/period/list/tree/studyLog?courseId=<MOOC课程ID>&isFront=1
|
||||||
每个课时节点里的 studyLog.completeTime 非空 = 平台真的记为完成。
|
每个课时节点里的 studyLog.completeTime 非空 = 平台真的记为完成。
|
||||||
|
|
||||||
用法
|
用法(在仓库根目录执行)
|
||||||
python study_video.py verify <trainCourseId> # 只核验,不动数据
|
python src/study_tool/plugins/guanyixue/script/study_video.py verify <trainCourseId> # 只核验,不动数据
|
||||||
python study_video.py run <trainCourseId> # 完成所有未完成的视频课时
|
python src/study_tool/plugins/guanyixue/script/study_video.py run <trainCourseId> # 完成所有未完成的视频课时
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
# 代码在 src/study_tool 包里,命令行脚本自己把 src 加进 sys.path
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
|
|
||||||
|
|
||||||
from study_tool import store # noqa: E402
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
# 脚本在 src/study_tool/plugins/guanyixue/script/,往上 4 层就是 src;命令行运行时自己把它加进 sys.path
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(HERE, "..", "..", "..", "..")))
|
||||||
|
|
||||||
|
from study_tool import database as store
|
||||||
|
|
||||||
BASE = "https://study.dgjy.net"
|
BASE = "https://study.dgjy.net"
|
||||||
KV_SESSION = "study_session" # 会话存在 SQLite 的 kv 表(和网页端同一份库)
|
KV_SESSION = "study_session" # 会话存在 SQLite 的 kv 表(和网页端同一份库)
|
||||||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
UA = (
|
||||||
"(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0")
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0"
|
||||||
|
)
|
||||||
CDP = "http://127.0.0.1:9222"
|
CDP = "http://127.0.0.1:9222"
|
||||||
|
|
||||||
|
|
||||||
@@ -46,12 +48,13 @@ def load_session():
|
|||||||
return sess if isinstance(sess, dict) and sess.get("token") else None
|
return sess if isinstance(sess, dict) and sess.get("token") else None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ 会话 / 认证
|
# ------------------------------------------------------------------ 会话 / 认证
|
||||||
|
|
||||||
|
|
||||||
def capture_session():
|
def capture_session():
|
||||||
"""从已登录的浏览器里取 Middle-Token 与 cookie。"""
|
"""从已登录的浏览器里取 Middle-Token 与 cookie。"""
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
b = p.chromium.connect_over_cdp(CDP)
|
b = p.chromium.connect_over_cdp(CDP)
|
||||||
cookies = b.contexts[0].cookies()
|
cookies = b.contexts[0].cookies()
|
||||||
@@ -61,9 +64,12 @@ def capture_session():
|
|||||||
token = c["value"]
|
token = c["value"]
|
||||||
if not token:
|
if not token:
|
||||||
raise SystemExit("没找到 Middle-Token —— 请先在浏览器里登录 study.dgjy.net")
|
raise SystemExit("没找到 Middle-Token —— 请先在浏览器里登录 study.dgjy.net")
|
||||||
sess = {"token": token,
|
sess = {
|
||||||
"cookies": [{"name": c["name"], "value": c["value"],
|
"token": token,
|
||||||
"domain": c["domain"], "path": c["path"]} for c in cookies]}
|
"cookies": [
|
||||||
|
{"name": c["name"], "value": c["value"], "domain": c["domain"], "path": c["path"]} for c in cookies
|
||||||
|
],
|
||||||
|
}
|
||||||
store.kv_set(KV_SESSION, sess)
|
store.kv_set(KV_SESSION, sess)
|
||||||
return sess
|
return sess
|
||||||
|
|
||||||
@@ -72,14 +78,20 @@ def api(sess=None):
|
|||||||
if sess is None:
|
if sess is None:
|
||||||
sess = load_session() or capture_session()
|
sess = load_session() or capture_session()
|
||||||
s = requests.Session()
|
s = requests.Session()
|
||||||
s.headers.update({"User-Agent": UA, "Accept": "application/json, text/plain, */*",
|
s.headers.update(
|
||||||
|
{
|
||||||
|
"User-Agent": UA,
|
||||||
|
"Accept": "application/json, text/plain, */*",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": "Bearer " + sess["token"],
|
"Authorization": "Bearer " + sess["token"],
|
||||||
"Referer": BASE + "/training/courseVideo"})
|
"Referer": BASE + "/training/courseVideo",
|
||||||
|
}
|
||||||
|
)
|
||||||
for c in sess["cookies"]:
|
for c in sess["cookies"]:
|
||||||
if "dgjy.net" in (c.get("domain") or ""):
|
if "dgjy.net" in (c.get("domain") or ""):
|
||||||
s.cookies.set(c["name"], c["value"] or "",
|
s.cookies.set(
|
||||||
domain=(c.get("domain") or "").lstrip("."), path=c.get("path") or "/")
|
c["name"], c["value"] or "", domain=(c.get("domain") or "").lstrip("."), path=c.get("path") or "/"
|
||||||
|
)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
@@ -93,14 +105,16 @@ def check_auth(s):
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ 课程结构
|
# ------------------------------------------------------------------ 课程结构
|
||||||
|
|
||||||
|
|
||||||
def course_info(s, train_id):
|
def course_info(s, train_id):
|
||||||
return s.get(BASE + "/train/course/front/trainCourse/getById?id=%s" % train_id,
|
return s.get(BASE + "/train/course/front/trainCourse/getById?id=%s" % train_id, timeout=30).json().get("data") or {}
|
||||||
timeout=30).json().get("data") or {}
|
|
||||||
|
|
||||||
|
|
||||||
def period_tree(s, course_id, is_front="1"):
|
def period_tree(s, course_id, is_front="1"):
|
||||||
r = s.get(BASE + "/train/course/front/period/list/tree/studyLog?courseId=%s&isFront=%s"
|
r = s.get(
|
||||||
% (course_id, is_front), timeout=30)
|
BASE + "/train/course/front/period/list/tree/studyLog?courseId=%s&isFront=%s" % (course_id, is_front),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
return r.json().get("data") or []
|
return r.json().get("data") or []
|
||||||
|
|
||||||
|
|
||||||
@@ -108,7 +122,8 @@ def flatten(nodes, out=None, parent=""):
|
|||||||
out = [] if out is None else out
|
out = [] if out is None else out
|
||||||
for n in nodes or []:
|
for n in nodes or []:
|
||||||
sl = n.get("studyLog") or {}
|
sl = n.get("studyLog") or {}
|
||||||
out.append({
|
out.append(
|
||||||
|
{
|
||||||
"periodId": n.get("id"),
|
"periodId": n.get("id"),
|
||||||
"name": n.get("name") or "",
|
"name": n.get("name") or "",
|
||||||
"parent": parent,
|
"parent": parent,
|
||||||
@@ -121,7 +136,8 @@ def flatten(nodes, out=None, parent=""):
|
|||||||
"completeTime": sl.get("completeTime"),
|
"completeTime": sl.get("completeTime"),
|
||||||
"logPeriodScore": sl.get("logPeriodScore"),
|
"logPeriodScore": sl.get("logPeriodScore"),
|
||||||
"lastProgressTime": sl.get("lastProgressTime"),
|
"lastProgressTime": sl.get("lastProgressTime"),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
flatten(n.get("children"), out, n.get("name") or parent)
|
flatten(n.get("children"), out, n.get("name") or parent)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -143,22 +159,32 @@ def video_periods(s, train_id):
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ 核验
|
# ------------------------------------------------------------------ 核验
|
||||||
|
|
||||||
|
|
||||||
def report(info, vids):
|
def report(info, vids):
|
||||||
print()
|
print()
|
||||||
print("课程:%s" % info.get("courseName", "?"))
|
print("课程:%s" % info.get("courseName", "?"))
|
||||||
st = info.get("study") or {}
|
st = info.get("study") or {}
|
||||||
print("进度:%s/%s 课时 规则 completeRule=%s" %
|
print(
|
||||||
(st.get("periodStudy"), st.get("periodTotal"), info.get("completeRule")))
|
"进度:%s/%s 课时 规则 completeRule=%s"
|
||||||
|
% (st.get("periodStudy"), st.get("periodTotal"), info.get("completeRule"))
|
||||||
|
)
|
||||||
print()
|
print()
|
||||||
print("%-8s %-40s %-8s %-6s %-20s %s" %
|
print("%-8s %-40s %-8s %-6s %-20s %s" % ("periodId", "课时名", "时长(s)", "拖拽", "completeTime", "判定"))
|
||||||
("periodId", "课时名", "时长(s)", "拖拽", "completeTime", "判定"))
|
|
||||||
done = 0
|
done = 0
|
||||||
for v in vids:
|
for v in vids:
|
||||||
ok = bool(v["completeTime"])
|
ok = bool(v["completeTime"])
|
||||||
done += ok
|
done += ok
|
||||||
print("%-8s %-40s %-8s %-6s %-20s %s" %
|
print(
|
||||||
(v["periodId"], v["name"][:40], v["duration"], v["allowVideoDrag"],
|
"%-8s %-40s %-8s %-6s %-20s %s"
|
||||||
v["completeTime"] or "-", "✅ 已完成" if ok else "❌ 未完成"))
|
% (
|
||||||
|
v["periodId"],
|
||||||
|
v["name"][:40],
|
||||||
|
v["duration"],
|
||||||
|
v["allowVideoDrag"],
|
||||||
|
v["completeTime"] or "-",
|
||||||
|
"✅ 已完成" if ok else "❌ 未完成",
|
||||||
|
)
|
||||||
|
)
|
||||||
print()
|
print()
|
||||||
print("视频课时:%d/%d 已完成" % (done, len(vids)))
|
print("视频课时:%d/%d 已完成" % (done, len(vids)))
|
||||||
return done, len(vids)
|
return done, len(vids)
|
||||||
@@ -166,10 +192,15 @@ def report(info, vids):
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ 自动完成
|
# ------------------------------------------------------------------ 自动完成
|
||||||
|
|
||||||
|
|
||||||
def complete_one(pg, train_id, train_course, period_id, timeout=180):
|
def complete_one(pg, train_id, train_course, period_id, timeout=180):
|
||||||
"""打开视频页 -> 拖到结尾 -> 播放 -> 等 isComplete=1 上报。返回 True/False。"""
|
"""打开视频页 -> 拖到结尾 -> 播放 -> 等 isComplete=1 上报。返回 True/False。"""
|
||||||
url = ("%s/training/courseVideo?classhour=%d&isJoin=1&needJoin=1&courseId=%s&isFront=1"
|
url = "%s/training/courseVideo?classhour=%d&isJoin=1&needJoin=1&courseId=%s&isFront=1&trainCourseId=%s" % (
|
||||||
"&trainCourseId=%s" % (BASE, period_id, train_course, train_id))
|
BASE,
|
||||||
|
period_id,
|
||||||
|
train_course,
|
||||||
|
train_id,
|
||||||
|
)
|
||||||
posted = {"done": False}
|
posted = {"done": False}
|
||||||
|
|
||||||
def on_req(r):
|
def on_req(r):
|
||||||
@@ -260,6 +291,7 @@ def run(train_id, only_missing=True):
|
|||||||
print(" %s %s (%ss)" % (v["periodId"], v["name"][:40], v["duration"]))
|
print(" %s %s (%ss)" % (v["periodId"], v["name"][:40], v["duration"]))
|
||||||
|
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
b = p.chromium.connect_over_cdp(CDP)
|
b = p.chromium.connect_over_cdp(CDP)
|
||||||
ctx = b.contexts[0]
|
ctx = b.contexts[0]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""莞易学工具模块:http / login / course / feedback / learn / spa。"""
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""旧版课程页:课程列表、结构解析与页面代际分发。"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from urllib.parse import unquote, urljoin, urlsplit
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from study_tool.platforms import registry
|
||||||
|
from study_tool.platforms.errors import SessionExpired
|
||||||
|
from study_tool.plugins.guanyixue.detect import detect_generation, has_study_marker
|
||||||
|
|
||||||
|
from ..config import COURSE_SECTIONS, JSPX, PLAN_STATUS, STUDY, STUDYABLE_STATUS
|
||||||
|
from .http import whoami
|
||||||
|
from .login import _jspx_call, ensure_jspx_session, fetch_admin
|
||||||
|
from .spa import (StudyClient, discover_study, extract_study_token,
|
||||||
|
extract_train_id, save_snapshot, study_resolve_train_id)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ids(text):
|
||||||
|
plan = project = ""
|
||||||
|
m = re.search(r"planId=([0-9A-Fa-f]{16,})", text or "")
|
||||||
|
if m:
|
||||||
|
plan = m.group(1)
|
||||||
|
m = re.search(r"projectId=([0-9A-Fa-f]{16,})", text or "")
|
||||||
|
if m:
|
||||||
|
project = m.group(1)
|
||||||
|
return plan, project
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_course_rows(html, section=""):
|
||||||
|
"""从「我的课程」列表片段里抽出课程行。
|
||||||
|
|
||||||
|
行结构(各板块一致):
|
||||||
|
<table class="table-results"><tr><th>在学课程</th></tr>
|
||||||
|
<tr><td><p class="course-tt"><a href="courseStudyMain.action?planId=..&projectId=..">名字</a></p>
|
||||||
|
<p class="course-dd"><span>班期:..</span><span>分数:82</span><span>成绩:合格</span></p>
|
||||||
|
</td><td><a class="abtn-orange-big">查看</a></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
注意 href 是**不带前导斜杠的相对路径**(`courseStudyMain.action?...`),
|
||||||
|
相对 `/train/trainee/` 解析,不能直接 `JSPX + href`。
|
||||||
|
"""
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
out = []
|
||||||
|
for table in soup.select("table.table-results"):
|
||||||
|
th = table.select_one("th")
|
||||||
|
sec = section or (re.sub(r"\s+", " ", th.get_text(" ", strip=True)) if th else "")
|
||||||
|
# 只按「有没有课程行」判断,不能用 "暂无" in table.get_text():
|
||||||
|
# 表格里任何一个单元格出现「暂无」都会把整张表(含全部课程)丢掉。
|
||||||
|
rows = [tr for tr in table.select("tr") if tr.select_one("p.course-tt a")]
|
||||||
|
for tr in rows:
|
||||||
|
a = tr.select_one("p.course-tt a")
|
||||||
|
href = unquote(a.get("href") or "")
|
||||||
|
plan, project = parse_ids(href)
|
||||||
|
if not plan or not project:
|
||||||
|
continue
|
||||||
|
dd = tr.select_one("p.course-dd")
|
||||||
|
out.append({
|
||||||
|
"section": sec,
|
||||||
|
"name": a.get_text(" ", strip=True),
|
||||||
|
"info": re.sub(r"\s+", " ", dd.get_text(" ", strip=True)) if dd else "",
|
||||||
|
"plan_id": plan,
|
||||||
|
"project_id": project,
|
||||||
|
"url": urljoin(JSPX + "/train/trainee/", href),
|
||||||
|
"progress": "",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_courses(s, with_progress=True):
|
||||||
|
"""把「我的课程」下三个板块 × 三种状态**全部**拉出来,含已学完的。
|
||||||
|
|
||||||
|
站点把列表拆成了 AJAX 接口,每个组合一次请求:
|
||||||
|
|
||||||
|
/train/trainee/studyingCourse.action?courseType=studying&planStatus=TAKING_PLAN
|
||||||
|
/train/assist/assistanceCourse.action?courseType=assistance&planStatus=FINISHED_PLAN
|
||||||
|
/train/inspect/inspectingCourse.action?courseType=inspecting&planStatus=NO_BEGAIN_PLAN
|
||||||
|
...
|
||||||
|
|
||||||
|
只看 /admin/main.action 是看不到「已结束」课程的——那个首页只渲染在学/助学/督学
|
||||||
|
三张「进行中」的概览表,课一学完就从首页消失了。
|
||||||
|
|
||||||
|
返回空列表只代表「三个板块九种状态都没课」;
|
||||||
|
会话失效或页面改版一律抛异常,让调用方能给出准确提示。
|
||||||
|
"""
|
||||||
|
# 先用首页校验会话,避免九个接口各自失败还看不出来
|
||||||
|
_jspx_call(s, lambda: fetch_admin(s))
|
||||||
|
|
||||||
|
out, seen = [], set()
|
||||||
|
for section, path, ctype in COURSE_SECTIONS:
|
||||||
|
for label, status in PLAN_STATUS:
|
||||||
|
url = "%s%s?courseType=%s&planStatus=%s" % (JSPX, path, ctype, status)
|
||||||
|
try:
|
||||||
|
html = s.get(url, timeout=40).text
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for c in _parse_course_rows(html, section):
|
||||||
|
key = (c["plan_id"], c["project_id"])
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
c["course_type"] = ctype
|
||||||
|
c["status"] = status
|
||||||
|
c["status_label"] = label
|
||||||
|
c["in_study"] = status in STUDYABLE_STATUS
|
||||||
|
out.append(c)
|
||||||
|
|
||||||
|
# 进度只在「进行中/未开始」的课上查,已结束的动辄几十门,逐个请求太慢
|
||||||
|
if with_progress:
|
||||||
|
for c in out:
|
||||||
|
if not c["in_study"]:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rr = s.get(c["url"], timeout=30)
|
||||||
|
p = BeautifulSoup(rr.text, "lxml").select_one("p.loading font")
|
||||||
|
c["progress"] = p.get_text(strip=True) if p else ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_jspx_course(html):
|
||||||
|
"""老版 jspx 课程页(tier-tree)解析。"""
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
course = soup.select_one("h1")
|
||||||
|
prog = soup.select_one("p.loading font")
|
||||||
|
info = soup.select_one("div.results-info p.info")
|
||||||
|
|
||||||
|
cw, fb, ts = [], [], []
|
||||||
|
for one in soup.select("div.tier-tree-one"):
|
||||||
|
ns = one.select_one("p.tier1.has-children span.name")
|
||||||
|
chapter = re.sub(r"\s+", " ", ns.get_text(" ", strip=True)) if ns else "?"
|
||||||
|
for p in one.select("p.tier3"):
|
||||||
|
a = p.select_one("a")
|
||||||
|
if not a:
|
||||||
|
continue
|
||||||
|
href = a.get("href") or ""
|
||||||
|
txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))
|
||||||
|
name = p.select_one("span.name")
|
||||||
|
title = re.sub(r"\s+", " ", name.get_text(" ", strip=True)) if name else txt[:40]
|
||||||
|
m = re.search(r"(\d+)分钟", txt)
|
||||||
|
done = "已完成" in txt
|
||||||
|
if "courseware.action" in href:
|
||||||
|
cw.append({"chapter": chapter, "title": title, "href": href, "done": done,
|
||||||
|
"duration": int(m.group(1)) if m else None})
|
||||||
|
elif "doFeedback.action" in href:
|
||||||
|
fb.append({"title": title, "href": href, "done": done})
|
||||||
|
elif "doTest.action" in href:
|
||||||
|
ts.append({"title": title, "href": href, "done": done})
|
||||||
|
return {
|
||||||
|
"user": whoami(html),
|
||||||
|
"course": course.get_text(" ", strip=True) if course else "?",
|
||||||
|
"progress": prog.get_text(strip=True) if prog else "?",
|
||||||
|
"info": re.sub(r"\s+", " ", info.get_text(" ", strip=True)) if info else "?",
|
||||||
|
"courseware": cw, "feedback": fb, "test": ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def discover(s, plan, project, name=""):
|
||||||
|
"""打开课程页并解析;会话失效时先用 CASTGC 补一次 jspx 授权再重试。"""
|
||||||
|
return _jspx_call(s, lambda: _discover_once(s, plan, project, name))
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_once(s, plan, project, name=""):
|
||||||
|
url = "%s/train/trainee/courseStudyMain.action?planId=%s&projectId=%s&trainRole=0" % (JSPX, plan, project)
|
||||||
|
r = s.get(url, timeout=40)
|
||||||
|
html = r.text or ""
|
||||||
|
if "/admin/login.action" in html and len(html) < 2000:
|
||||||
|
# 课程页也走同一个判断:CASTGC 有效就无密码续期
|
||||||
|
if ensure_jspx_session(s):
|
||||||
|
r = s.get(url, timeout=40)
|
||||||
|
html = r.text or ""
|
||||||
|
if "没有权限" in html:
|
||||||
|
raise RuntimeError("该账号对此课程没有权限")
|
||||||
|
if "/admin/login.action" in html and len(html) < 2000:
|
||||||
|
raise SessionExpired("登录已失效:课程页把请求跳回了 /admin/login.action(请重新登录)")
|
||||||
|
|
||||||
|
# 课程页 302 到 study 时,token 在跳转 URL 里;哪怕 SPA 外壳返回空也要当成学过的新平台
|
||||||
|
study_url = "study.dgjy.net" in (r.url or "")
|
||||||
|
tok = extract_study_token(r.url, r.history)
|
||||||
|
if tok:
|
||||||
|
host = urlsplit(r.url).hostname or urlsplit(STUDY).hostname
|
||||||
|
try:
|
||||||
|
s.cookies.set("Middle-Token", tok, domain=host, path="/")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(html) < 3000 and not (study_url or tok or has_study_marker(html, r.url)):
|
||||||
|
raise RuntimeError("课程页返回异常(页面长度仅 %d,登录可能已失效)" % len(html))
|
||||||
|
|
||||||
|
generation = detect_generation(html, r.url)
|
||||||
|
# 按登录 / 学习入口识别平台;以后新增平台时这里不用改,靠 Platform.entries 命中
|
||||||
|
pf = registry.detect(r.url, html)
|
||||||
|
platform_id = pf.id if pf else "guanyixue"
|
||||||
|
if generation == "legacy":
|
||||||
|
d = parse_jspx_course(html)
|
||||||
|
if d["courseware"] or d["feedback"] or d["test"]:
|
||||||
|
d["platform"] = platform_id
|
||||||
|
d["generation"] = "legacy"
|
||||||
|
return d
|
||||||
|
# 老结构但一个活动都没有:多半是「慕课」外链页(页面还在 jspx,课时在 study 平台)
|
||||||
|
if not has_study_marker(html, r.url):
|
||||||
|
snap = save_snapshot(plan, project, r.url, html)
|
||||||
|
raise RuntimeError("课程页能打开,但没有任何课件/反馈表/测验(页面已存 %s)。"
|
||||||
|
"若是慕课课程,请确认新平台登录态是否有效" % snap)
|
||||||
|
elif generation != "spa":
|
||||||
|
snap = save_snapshot(plan, project, r.url, html)
|
||||||
|
raise RuntimeError("无法识别课程页结构(站点可能又改版):页面里既没有 jspx 的 "
|
||||||
|
"tier-tree,也没有新版 SPA 标记。页面已存 %s,请把它发我" % snap)
|
||||||
|
|
||||||
|
# ---- 新版平台(Vue SPA + /train/course/front/* JSON 接口)----
|
||||||
|
tid = extract_train_id(r.url, html)
|
||||||
|
user = whoami(html)
|
||||||
|
client = StudyClient(s, r.url)
|
||||||
|
|
||||||
|
def _load():
|
||||||
|
nonlocal tid
|
||||||
|
if not tid:
|
||||||
|
tid = study_resolve_train_id(client, name)
|
||||||
|
if not tid:
|
||||||
|
snap = save_snapshot(plan, project, r.url, html)
|
||||||
|
raise RuntimeError("新版课程页未带 trainCourseId,且按课程名也匹配不到(课程名:%s)。"
|
||||||
|
"页面已存 %s" % (name or "?", snap))
|
||||||
|
return discover_study(client, tid, name, user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
d = _load()
|
||||||
|
except SessionExpired:
|
||||||
|
client.bootstrap()
|
||||||
|
tid = extract_train_id(r.url, html)
|
||||||
|
d = _load()
|
||||||
|
d["platform"] = platform_id
|
||||||
|
d["generation"] = "spa"
|
||||||
|
# URL 里可能带 token(凭据),日志/状态里一律打码;鉴权已经写进 cookie
|
||||||
|
d["page_url"] = re.sub(r"([?&])(token|access_token|securityKey|sign)=[^&]*", r"\1\2=***", r.url or "")
|
||||||
|
d["plan_id"] = plan
|
||||||
|
d["project_id"] = project
|
||||||
|
return d
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""旧版反馈表与测验提交。"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from study_tool.runtime import log
|
||||||
|
|
||||||
|
from ..config import EP_STUDY, JSPX
|
||||||
|
from .course import parse_ids
|
||||||
|
from .http import new_session
|
||||||
|
|
||||||
|
|
||||||
|
class CwItem:
|
||||||
|
def __init__(self, d, cookies):
|
||||||
|
self.title = d["title"]
|
||||||
|
href = d["href"]
|
||||||
|
self.url = href if href.startswith("http") else JSPX + href
|
||||||
|
self.s = new_session(cookies)
|
||||||
|
self.rec = None
|
||||||
|
self.status = "done" if d.get("done") else "pending"
|
||||||
|
self.last = None
|
||||||
|
|
||||||
|
def resolve(self):
|
||||||
|
try:
|
||||||
|
r = self.s.get(self.url, timeout=40)
|
||||||
|
except Exception as e:
|
||||||
|
return "exc:%s" % e
|
||||||
|
m = re.search(r"'id':'([0-9A-F]{32})'", r.text)
|
||||||
|
if not m:
|
||||||
|
return "no-id"
|
||||||
|
self.rec = m.group(1)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
try:
|
||||||
|
r = self.s.post(EP_STUDY, data={"id": self.rec, "order": "server_start",
|
||||||
|
"updatePassPerc": "true"}, timeout=40)
|
||||||
|
return r.text.strip()[:40]
|
||||||
|
except Exception as e:
|
||||||
|
return "EXC:%s" % e
|
||||||
|
|
||||||
|
def beat(self):
|
||||||
|
try:
|
||||||
|
r = self.s.post(EP_STUDY, data={"id": self.rec, "order": "server_time",
|
||||||
|
"updatePassPerc": "true",
|
||||||
|
"videoSchedule": 30}, timeout=40)
|
||||||
|
return r.text.strip()[:60]
|
||||||
|
except Exception as e:
|
||||||
|
return "EXC:%s" % e
|
||||||
|
|
||||||
|
def submit_feedback(s, href, key):
|
||||||
|
url = href if href.startswith("http") else JSPX + href
|
||||||
|
r = s.get(url, timeout=40)
|
||||||
|
soup = BeautifulSoup(r.text, "lxml")
|
||||||
|
fields = {i.get("name"): i.get("value") for i in soup.select("form input[type=hidden]") if i.get("name")}
|
||||||
|
if fields.get("allowStudy") == "false":
|
||||||
|
log(key, "反馈表:allowStudy=false,跳过")
|
||||||
|
return
|
||||||
|
r2 = s.post(JSPX + "/train/trainee/doFeedbackList.action", data=fields, timeout=40)
|
||||||
|
qs = BeautifulSoup(r2.text, "lxml")
|
||||||
|
answers = []
|
||||||
|
for it in qs.select("div.job-item"):
|
||||||
|
qtype = it.get("questype")
|
||||||
|
opts = [sp.get("data-name") for sp in it.select(".job-item-a span")]
|
||||||
|
ans = ("课程内容充实,讲师讲解清晰,对我的教学实践很有帮助,希望今后能增加更多一线教学案例。"
|
||||||
|
if qtype == "04" else (opts[0] if opts else "A"))
|
||||||
|
answers.append({"id": it.get("traineeid"), "quesId": it.get("qumid"),
|
||||||
|
"quesType": qtype, "quesNo": it.get("quesno"), "answer": ans})
|
||||||
|
payload = {"planId": fields.get("planId"), "objectId": fields.get("objectId"),
|
||||||
|
"answers": json.dumps(answers, ensure_ascii=False, separators=(",", ":")),
|
||||||
|
"submitStatus": "finish"}
|
||||||
|
rr = s.post(JSPX + "/train/trainee/saveFeedback.action", data=payload, timeout=40)
|
||||||
|
log(key, "反馈表:%d 题,%s" % (len(answers), "提交成功" if "MSG" not in rr.text else "返回 " + rr.text[:80]))
|
||||||
|
|
||||||
|
def submit_quiz(s, href, key, answer_key=None):
|
||||||
|
url = href if href.startswith("http") else JSPX + href
|
||||||
|
plan, _ = parse_ids(url)
|
||||||
|
m = re.search(r"objectId=([0-9A-Fa-f]+)", url)
|
||||||
|
obj = m.group(1) if m else ""
|
||||||
|
rr = s.get("%s/train/trainee/showTestQums.action?planId=%s&objectId=%s" % (JSPX, plan, obj), timeout=40)
|
||||||
|
qs = BeautifulSoup(rr.text, "lxml")
|
||||||
|
singles = (answer_key or {}).get("singles", {})
|
||||||
|
multis = (answer_key or {}).get("multis", {})
|
||||||
|
answers = []
|
||||||
|
for it in qs.select("div.job-item"):
|
||||||
|
t, qno = it.get("questype"), it.get("quesno")
|
||||||
|
opts = [sp.get("data-name") for sp in it.select(".job-item-a span")]
|
||||||
|
if t == "02":
|
||||||
|
ans = (multis.get(qno) or ("#".join(opts) if opts else "A"))
|
||||||
|
else:
|
||||||
|
ans = singles.get(qno) or (opts[0] if opts else "A")
|
||||||
|
answers.append({"id": it.get("traineeid"), "quesId": it.get("qumid"),
|
||||||
|
"quesType": t, "quesNo": qno, "answer": ans})
|
||||||
|
if not answers:
|
||||||
|
log(key, "测验:未取到题目,跳过")
|
||||||
|
return
|
||||||
|
payload = {"planId": plan, "objectId": obj,
|
||||||
|
"answers": json.dumps(answers, ensure_ascii=False, separators=(",", ":"))}
|
||||||
|
r3 = s.post(JSPX + "/train/trainee/endTest.action", data=payload, timeout=40)
|
||||||
|
r4 = s.get("%s/train/trainee/doTest.action?planId=%s&objectId=%s" % (JSPX, plan, obj), timeout=40)
|
||||||
|
sc = re.search(r"得分:\s*(\d+)\s*分",
|
||||||
|
re.sub(r"\s+", " ", BeautifulSoup(r4.text, "lxml").get_text(" ", strip=True)))
|
||||||
|
log(key, "测验:%d 题,%s,得分 %s" %
|
||||||
|
(len(answers), "提交成功" if "MSG" not in r3.text else r3.text[:60],
|
||||||
|
sc.group(1) if sc else "?"))
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""莞易学请求底座:会话、cookie、身份与新版平台 token。"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from ..config import UA
|
||||||
|
|
||||||
|
|
||||||
|
def new_session(cookies=None):
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
|
||||||
|
for c in (cookies or []):
|
||||||
|
if "dgjy" not in (c.get("domain") or ""):
|
||||||
|
continue
|
||||||
|
s.cookies.set(c["name"], c.get("value") or "",
|
||||||
|
domain=(c.get("domain") or "").lstrip("."),
|
||||||
|
path=c.get("path") or "/")
|
||||||
|
return s
|
||||||
|
|
||||||
|
def dump_cookies(s):
|
||||||
|
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path}
|
||||||
|
for c in s.cookies]
|
||||||
|
|
||||||
|
def whoami(html):
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
hdr = soup.select_one("#header")
|
||||||
|
if hdr:
|
||||||
|
m = re.search(r"你好[,,]?\s*(\S+)\s*老师", re.sub(r"\s+", " ", hdr.get_text(" ", strip=True)))
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def study_token(s):
|
||||||
|
for c in s.cookies:
|
||||||
|
if c.name == "Middle-Token" and "dgjy" in (c.domain or ""):
|
||||||
|
return c.value
|
||||||
|
return ""
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""旧版学习流程:反馈表、测验与课件心跳。"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from study_tool.runtime import RUNS, RUNS_LOCK, log, set_state
|
||||||
|
|
||||||
|
from .course import discover
|
||||||
|
from .feedback import CwItem, submit_feedback, submit_quiz
|
||||||
|
from .http import dump_cookies
|
||||||
|
|
||||||
|
|
||||||
|
def _study_builtin(key, s, d, workers=1, cap=30, enable_quiz=False, enable_feedback=False,
|
||||||
|
mode="fast", parallel=1, quiz_key="", cookies=None, plan="", project="",
|
||||||
|
name=""):
|
||||||
|
"""旧版 jspx 的内置流程:反馈表 + 测验 + 刷课件。"""
|
||||||
|
if enable_feedback:
|
||||||
|
for fb in d["feedback"]:
|
||||||
|
if fb["done"]:
|
||||||
|
log(key, "反馈表已完成,跳过")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
submit_feedback(s, fb["href"], key)
|
||||||
|
except Exception as e:
|
||||||
|
log(key, "反馈表异常:%s" % e)
|
||||||
|
else:
|
||||||
|
for fb in d["feedback"]:
|
||||||
|
log(key, "反馈表:已按设置跳过(%s)" % ("已完成" if fb["done"] else "未完成"))
|
||||||
|
|
||||||
|
if enable_quiz:
|
||||||
|
for t in d["test"]:
|
||||||
|
if t["done"]:
|
||||||
|
log(key, "测验已完成,跳过")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
submit_quiz(s, t["href"], key, quiz_key)
|
||||||
|
except Exception as e:
|
||||||
|
log(key, "测验异常:%s" % e)
|
||||||
|
else:
|
||||||
|
for t in d["test"]:
|
||||||
|
if t["done"]:
|
||||||
|
log(key, "测验:已完成,跳过")
|
||||||
|
else:
|
||||||
|
log(key, "测验:⚠ 已按设置跳过答题(避免因正确率不足卡住「完成」),"
|
||||||
|
"需要时请在页面上手动作答,或打开「自动交测验」开关")
|
||||||
|
|
||||||
|
objs = [CwItem(c, cookies) for c in d["courseware"]]
|
||||||
|
todo = [o for o in objs if o.status != "done"]
|
||||||
|
done0 = len(objs) - len(todo)
|
||||||
|
set_state(key, status="studying", total=len(objs), done=done0)
|
||||||
|
if todo:
|
||||||
|
log(key, "解析 %d 个学习记录…" % len(todo))
|
||||||
|
with ThreadPoolExecutor(max_workers=min(workers, len(todo))) as ex:
|
||||||
|
list(ex.map(lambda x: x.resolve(), todo))
|
||||||
|
live = [o for o in todo if o.rec]
|
||||||
|
log(key, "已解析 %d/%d,并发启动会话" % (len(live), len(todo)))
|
||||||
|
with ThreadPoolExecutor(max_workers=min(workers, len(live))) as ex:
|
||||||
|
list(ex.map(lambda x: x.start(), live))
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < cap * 60:
|
||||||
|
with RUNS_LOCK:
|
||||||
|
if (RUNS.get(key) or {}).get("status") == "stopped":
|
||||||
|
log(key, "已按要求停止")
|
||||||
|
return
|
||||||
|
pending = [o for o in live if o.status != "done"]
|
||||||
|
if not pending:
|
||||||
|
break
|
||||||
|
with ThreadPoolExecutor(max_workers=min(workers, len(pending))) as ex:
|
||||||
|
resps = list(ex.map(lambda x: x.beat(), pending))
|
||||||
|
newly = []
|
||||||
|
for o, resp in zip(pending, resps):
|
||||||
|
o.last = resp
|
||||||
|
if resp == "study_over":
|
||||||
|
o.status = "done"
|
||||||
|
newly.append(o)
|
||||||
|
el = int(time.time() - t0)
|
||||||
|
dc = len([o for o in live if o.status == "done"]) + done0
|
||||||
|
set_state(key, done=dc, elapsed=el)
|
||||||
|
log(key, "第 %d 轮 %dm%02ds 完成 %d/%d" % (el // 60 + 1, el // 60, el % 60, dc, len(objs)))
|
||||||
|
for o in newly:
|
||||||
|
log(key, " ✓ %s" % o.title[:40])
|
||||||
|
time.sleep(60)
|
||||||
|
else:
|
||||||
|
log(key, "所有课件均已完成")
|
||||||
|
|
||||||
|
try:
|
||||||
|
d2 = discover(s, plan, project, name)
|
||||||
|
set_state(key, progress=d2["progress"], info=d2["info"], cookies=dump_cookies(s))
|
||||||
|
log(key, "课程结束:%s | %s" % (d2["progress"], d2["info"]))
|
||||||
|
except Exception as e:
|
||||||
|
log(key, "复查失败:%s" % e)
|
||||||
|
|
||||||
|
def _study(key, s, d, **opts):
|
||||||
|
"""旧版页面:反馈表 + 测验 + 刷课件。"""
|
||||||
|
return _study_builtin(key, s, d, **opts)
|
||||||
+9
-272
@@ -1,21 +1,17 @@
|
|||||||
"""旧版平台(jspx.dgjy.net):会话 / cookie / 登录 / 课程抓取。
|
"""登录与 SSO:账号密码登录、CASTGC 续期、新版平台 token 换取。"""
|
||||||
|
|
||||||
对外:new_session / login / scrape_courses / discover。
|
|
||||||
"""
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from html import unescape as html_unescape
|
from html import unescape as html_unescape
|
||||||
from urllib.parse import unquote, urljoin, urlsplit
|
from urllib.parse import urljoin, urlsplit
|
||||||
|
|
||||||
import requests
|
from study_tool.platforms.errors import SessionExpired
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
from study_tool.config import *
|
from ..config import (AUTHORIZE, CODE_RE, DESENC, JSPX, LOGIN_HEADERS, NODE, STUDY, TPASS,
|
||||||
from study_tool.platforms import registry
|
TRAIN_AUTHORIZE_PATH)
|
||||||
from study_tool.platforms.detect import detect_generation, has_study_marker
|
from .http import new_session, study_token, whoami
|
||||||
|
|
||||||
# ------------------------------------------------------------------------ crypto
|
|
||||||
|
|
||||||
def str_enc(data: str) -> str:
|
def str_enc(data: str) -> str:
|
||||||
r = subprocess.run([NODE, DESENC, data, "1", "2", "3"],
|
r = subprocess.run([NODE, DESENC, data, "1", "2", "3"],
|
||||||
@@ -25,46 +21,6 @@ def str_enc(data: str) -> str:
|
|||||||
return r.stdout.strip()
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- http client
|
|
||||||
|
|
||||||
def new_session(cookies=None):
|
|
||||||
s = requests.Session()
|
|
||||||
s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
|
|
||||||
for c in (cookies or []):
|
|
||||||
if "dgjy" not in (c.get("domain") or ""):
|
|
||||||
continue
|
|
||||||
s.cookies.set(c["name"], c.get("value") or "",
|
|
||||||
domain=(c.get("domain") or "").lstrip("."),
|
|
||||||
path=c.get("path") or "/")
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def dump_cookies(s):
|
|
||||||
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path}
|
|
||||||
for c in s.cookies]
|
|
||||||
|
|
||||||
|
|
||||||
def whoami(html):
|
|
||||||
soup = BeautifulSoup(html, "lxml")
|
|
||||||
hdr = soup.select_one("#header")
|
|
||||||
if hdr:
|
|
||||||
m = re.search(r"你好[,,]?\s*(\S+)\s*老师", re.sub(r"\s+", " ", hdr.get_text(" ", strip=True)))
|
|
||||||
if m:
|
|
||||||
return m.group(1)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def parse_ids(text):
|
|
||||||
plan = project = ""
|
|
||||||
m = re.search(r"planId=([0-9A-Fa-f]{16,})", text or "")
|
|
||||||
if m:
|
|
||||||
plan = m.group(1)
|
|
||||||
m = re.search(r"projectId=([0-9A-Fa-f]{16,})", text or "")
|
|
||||||
if m:
|
|
||||||
project = m.group(1)
|
|
||||||
return plan, project
|
|
||||||
|
|
||||||
|
|
||||||
def _follow_js_login(s, page):
|
def _follow_js_login(s, page):
|
||||||
"""补上浏览器才会执行的两跳,否则只拿得到 CASTGC、拿不到 jspx 的会话。 `oauth2.0/authorize` 链的最后一跳**不是 302**,而是 dj.dgjy.net 上一个
|
"""补上浏览器才会执行的两跳,否则只拿得到 CASTGC、拿不到 jspx 的会话。 `oauth2.0/authorize` 链的最后一跳**不是 302**,而是 dj.dgjy.net 上一个
|
||||||
1486 字节的 JS 中转页,里面动态插 <script>:
|
1486 字节的 JS 中转页,里面动态插 <script>:
|
||||||
@@ -305,7 +261,7 @@ def ensure_study_session(s, force=False):
|
|||||||
s.cookies.clear(c.domain, c.path, c.name)
|
s.cookies.clear(c.domain, c.path, c.name)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if _study_token(s):
|
if study_token(s):
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
r = s.get(TPASS + TRAIN_AUTHORIZE_PATH, timeout=40, allow_redirects=True)
|
r = s.get(TPASS + TRAIN_AUTHORIZE_PATH, timeout=40, allow_redirects=True)
|
||||||
@@ -332,7 +288,7 @@ def ensure_study_session(s, force=False):
|
|||||||
except Exception:
|
except Exception:
|
||||||
break
|
break
|
||||||
cur_url, body = r.url or "", r.text or ""
|
cur_url, body = r.url or "", r.text or ""
|
||||||
return bool(_study_token(s))
|
return bool(study_token(s))
|
||||||
|
|
||||||
|
|
||||||
def _fetch_train_token(s):
|
def _fetch_train_token(s):
|
||||||
@@ -372,7 +328,7 @@ def _exchange_code(s, code):
|
|||||||
headers={"Accept": "application/json, text/plain, */*"})
|
headers={"Accept": "application/json, text/plain, */*"})
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if _study_token(s):
|
if study_token(s):
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
j = rr.json()
|
j = rr.json()
|
||||||
@@ -414,222 +370,3 @@ def fetch_admin(s):
|
|||||||
raise RuntimeError("课程页结构异常:未找到 table-results 表格(站点可能已改版,页面长度 %d)"
|
raise RuntimeError("课程页结构异常:未找到 table-results 表格(站点可能已改版,页面长度 %d)"
|
||||||
% len(html))
|
% len(html))
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
def _parse_course_rows(html, section=""):
|
|
||||||
"""从「我的课程」列表片段里抽出课程行。
|
|
||||||
|
|
||||||
行结构(各板块一致):
|
|
||||||
<table class="table-results"><tr><th>在学课程</th></tr>
|
|
||||||
<tr><td><p class="course-tt"><a href="courseStudyMain.action?planId=..&projectId=..">名字</a></p>
|
|
||||||
<p class="course-dd"><span>班期:..</span><span>分数:82</span><span>成绩:合格</span></p>
|
|
||||||
</td><td><a class="abtn-orange-big">查看</a></td></tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
注意 href 是**不带前导斜杠的相对路径**(`courseStudyMain.action?...`),
|
|
||||||
相对 `/train/trainee/` 解析,不能直接 `JSPX + href`。
|
|
||||||
"""
|
|
||||||
soup = BeautifulSoup(html, "lxml")
|
|
||||||
out = []
|
|
||||||
for table in soup.select("table.table-results"):
|
|
||||||
th = table.select_one("th")
|
|
||||||
sec = section or (re.sub(r"\s+", " ", th.get_text(" ", strip=True)) if th else "")
|
|
||||||
# 只按「有没有课程行」判断,不能用 "暂无" in table.get_text():
|
|
||||||
# 表格里任何一个单元格出现「暂无」都会把整张表(含全部课程)丢掉。
|
|
||||||
rows = [tr for tr in table.select("tr") if tr.select_one("p.course-tt a")]
|
|
||||||
for tr in rows:
|
|
||||||
a = tr.select_one("p.course-tt a")
|
|
||||||
href = unquote(a.get("href") or "")
|
|
||||||
plan, project = parse_ids(href)
|
|
||||||
if not plan or not project:
|
|
||||||
continue
|
|
||||||
dd = tr.select_one("p.course-dd")
|
|
||||||
out.append({
|
|
||||||
"section": sec,
|
|
||||||
"name": a.get_text(" ", strip=True),
|
|
||||||
"info": re.sub(r"\s+", " ", dd.get_text(" ", strip=True)) if dd else "",
|
|
||||||
"plan_id": plan,
|
|
||||||
"project_id": project,
|
|
||||||
"url": urljoin(JSPX + "/train/trainee/", href),
|
|
||||||
"progress": "",
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def scrape_courses(s, with_progress=True):
|
|
||||||
"""把「我的课程」下三个板块 × 三种状态**全部**拉出来,含已学完的。
|
|
||||||
|
|
||||||
站点把列表拆成了 AJAX 接口,每个组合一次请求:
|
|
||||||
|
|
||||||
/train/trainee/studyingCourse.action?courseType=studying&planStatus=TAKING_PLAN
|
|
||||||
/train/assist/assistanceCourse.action?courseType=assistance&planStatus=FINISHED_PLAN
|
|
||||||
/train/inspect/inspectingCourse.action?courseType=inspecting&planStatus=NO_BEGAIN_PLAN
|
|
||||||
...
|
|
||||||
|
|
||||||
只看 /admin/main.action 是看不到「已结束」课程的——那个首页只渲染在学/助学/督学
|
|
||||||
三张「进行中」的概览表,课一学完就从首页消失了。
|
|
||||||
|
|
||||||
返回空列表只代表「三个板块九种状态都没课」;
|
|
||||||
会话失效或页面改版一律抛异常,让调用方能给出准确提示。
|
|
||||||
"""
|
|
||||||
# 先用首页校验会话,避免九个接口各自失败还看不出来
|
|
||||||
_jspx_call(s, lambda: fetch_admin(s))
|
|
||||||
|
|
||||||
out, seen = [], set()
|
|
||||||
for section, path, ctype in COURSE_SECTIONS:
|
|
||||||
for label, status in PLAN_STATUS:
|
|
||||||
url = "%s%s?courseType=%s&planStatus=%s" % (JSPX, path, ctype, status)
|
|
||||||
try:
|
|
||||||
html = s.get(url, timeout=40).text
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
for c in _parse_course_rows(html, section):
|
|
||||||
key = (c["plan_id"], c["project_id"])
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
c["course_type"] = ctype
|
|
||||||
c["status"] = status
|
|
||||||
c["status_label"] = label
|
|
||||||
c["in_study"] = status in STUDYABLE_STATUS
|
|
||||||
out.append(c)
|
|
||||||
|
|
||||||
# 进度只在「进行中/未开始」的课上查,已结束的动辄几十门,逐个请求太慢
|
|
||||||
if with_progress:
|
|
||||||
for c in out:
|
|
||||||
if not c["in_study"]:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
rr = s.get(c["url"], timeout=30)
|
|
||||||
p = BeautifulSoup(rr.text, "lxml").select_one("p.loading font")
|
|
||||||
c["progress"] = p.get_text(strip=True) if p else ""
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def parse_jspx_course(html):
|
|
||||||
"""老版 jspx 课程页(tier-tree)解析。"""
|
|
||||||
soup = BeautifulSoup(html, "lxml")
|
|
||||||
course = soup.select_one("h1")
|
|
||||||
prog = soup.select_one("p.loading font")
|
|
||||||
info = soup.select_one("div.results-info p.info")
|
|
||||||
|
|
||||||
cw, fb, ts = [], [], []
|
|
||||||
for one in soup.select("div.tier-tree-one"):
|
|
||||||
ns = one.select_one("p.tier1.has-children span.name")
|
|
||||||
chapter = re.sub(r"\s+", " ", ns.get_text(" ", strip=True)) if ns else "?"
|
|
||||||
for p in one.select("p.tier3"):
|
|
||||||
a = p.select_one("a")
|
|
||||||
if not a:
|
|
||||||
continue
|
|
||||||
href = a.get("href") or ""
|
|
||||||
txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))
|
|
||||||
name = p.select_one("span.name")
|
|
||||||
title = re.sub(r"\s+", " ", name.get_text(" ", strip=True)) if name else txt[:40]
|
|
||||||
m = re.search(r"(\d+)分钟", txt)
|
|
||||||
done = "已完成" in txt
|
|
||||||
if "courseware.action" in href:
|
|
||||||
cw.append({"chapter": chapter, "title": title, "href": href, "done": done,
|
|
||||||
"duration": int(m.group(1)) if m else None})
|
|
||||||
elif "doFeedback.action" in href:
|
|
||||||
fb.append({"title": title, "href": href, "done": done})
|
|
||||||
elif "doTest.action" in href:
|
|
||||||
ts.append({"title": title, "href": href, "done": done})
|
|
||||||
return {
|
|
||||||
"user": whoami(html),
|
|
||||||
"course": course.get_text(" ", strip=True) if course else "?",
|
|
||||||
"progress": prog.get_text(strip=True) if prog else "?",
|
|
||||||
"info": re.sub(r"\s+", " ", info.get_text(" ", strip=True)) if info else "?",
|
|
||||||
"courseware": cw, "feedback": fb, "test": ts,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def discover(s, plan, project, name=""):
|
|
||||||
"""打开课程页并解析;会话失效时先用 CASTGC 补一次 jspx 授权再重试。"""
|
|
||||||
return _jspx_call(s, lambda: _discover_once(s, plan, project, name))
|
|
||||||
|
|
||||||
|
|
||||||
def _discover_once(s, plan, project, name=""):
|
|
||||||
url = "%s/train/trainee/courseStudyMain.action?planId=%s&projectId=%s&trainRole=0" % (JSPX, plan, project)
|
|
||||||
r = s.get(url, timeout=40)
|
|
||||||
html = r.text or ""
|
|
||||||
if "/admin/login.action" in html and len(html) < 2000:
|
|
||||||
# 课程页也走同一个判断:CASTGC 有效就无密码续期
|
|
||||||
if ensure_jspx_session(s):
|
|
||||||
r = s.get(url, timeout=40)
|
|
||||||
html = r.text or ""
|
|
||||||
if "没有权限" in html:
|
|
||||||
raise RuntimeError("该账号对此课程没有权限")
|
|
||||||
if "/admin/login.action" in html and len(html) < 2000:
|
|
||||||
raise SessionExpired("登录已失效:课程页把请求跳回了 /admin/login.action(请重新登录)")
|
|
||||||
|
|
||||||
# 课程页 302 到 study 时,token 在跳转 URL 里;哪怕 SPA 外壳返回空也要当成学过的新平台
|
|
||||||
study_url = "study.dgjy.net" in (r.url or "")
|
|
||||||
tok = extract_study_token(r.url, r.history)
|
|
||||||
if tok:
|
|
||||||
host = urlsplit(r.url).hostname or urlsplit(STUDY).hostname
|
|
||||||
try:
|
|
||||||
s.cookies.set("Middle-Token", tok, domain=host, path="/")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if len(html) < 3000 and not (study_url or tok or has_study_marker(html, r.url)):
|
|
||||||
raise RuntimeError("课程页返回异常(页面长度仅 %d,登录可能已失效)" % len(html))
|
|
||||||
|
|
||||||
generation = detect_generation(html, r.url)
|
|
||||||
# 按登录 / 学习入口识别平台;以后新增平台时这里不用改,靠 Platform.entries 命中
|
|
||||||
pf = registry.detect(r.url, html)
|
|
||||||
platform_id = pf.id if pf else "guanyixue"
|
|
||||||
if generation == "legacy":
|
|
||||||
d = parse_jspx_course(html)
|
|
||||||
if d["courseware"] or d["feedback"] or d["test"]:
|
|
||||||
d["platform"] = platform_id
|
|
||||||
d["generation"] = "legacy"
|
|
||||||
return d
|
|
||||||
# 老结构但一个活动都没有:多半是「慕课」外链页(页面还在 jspx,课时在 study 平台)
|
|
||||||
if not has_study_marker(html, r.url):
|
|
||||||
snap = save_snapshot(plan, project, r.url, html)
|
|
||||||
raise RuntimeError("课程页能打开,但没有任何课件/反馈表/测验(页面已存 %s)。"
|
|
||||||
"若是慕课课程,请确认新平台登录态是否有效" % snap)
|
|
||||||
elif generation != "spa":
|
|
||||||
snap = save_snapshot(plan, project, r.url, html)
|
|
||||||
raise RuntimeError("无法识别课程页结构(站点可能又改版):页面里既没有 jspx 的 "
|
|
||||||
"tier-tree,也没有新版 SPA 标记。页面已存 %s,请把它发我" % snap)
|
|
||||||
|
|
||||||
# ---- 新版平台(Vue SPA + /train/course/front/* JSON 接口)----
|
|
||||||
tid = extract_train_id(r.url, html)
|
|
||||||
user = whoami(html)
|
|
||||||
client = StudyClient(s, r.url)
|
|
||||||
|
|
||||||
def _load():
|
|
||||||
nonlocal tid
|
|
||||||
if not tid:
|
|
||||||
tid = study_resolve_train_id(client, name)
|
|
||||||
if not tid:
|
|
||||||
snap = save_snapshot(plan, project, r.url, html)
|
|
||||||
raise RuntimeError("新版课程页未带 trainCourseId,且按课程名也匹配不到(课程名:%s)。"
|
|
||||||
"页面已存 %s" % (name or "?", snap))
|
|
||||||
return discover_study(client, tid, name, user)
|
|
||||||
|
|
||||||
try:
|
|
||||||
d = _load()
|
|
||||||
except SessionExpired:
|
|
||||||
client.bootstrap()
|
|
||||||
tid = extract_train_id(r.url, html)
|
|
||||||
d = _load()
|
|
||||||
d["platform"] = platform_id
|
|
||||||
d["generation"] = "spa"
|
|
||||||
# URL 里可能带 token(凭据),日志/状态里一律打码;鉴权已经写进 cookie
|
|
||||||
d["page_url"] = re.sub(r"([?&])(token|access_token|securityKey|sign)=[^&]*", r"\1\2=***", r.url or "")
|
|
||||||
d["plan_id"] = plan
|
|
||||||
d["project_id"] = project
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 平台注册
|
|
||||||
|
|
||||||
def _study(key, s, d, **opts):
|
|
||||||
"""旧版页面的学习流程仍在 engine._study_builtin(反馈表 / 测验 / 刷课件)。"""
|
|
||||||
from study_tool.engine import _study_builtin
|
|
||||||
return _study_builtin(key, s, d, **opts)
|
|
||||||
+452
-26
@@ -1,16 +1,20 @@
|
|||||||
"""新版莞易学课程页(study.dgjy.net):StudyClient、课程结构、学习动作。"""
|
"""新版莞易学(study.dgjy.net):StudyClient、课程结构、学习动作与浏览器播放。"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from study_tool import store
|
from study_tool import database, notify
|
||||||
from study_tool.config import *
|
from study_tool.platforms.errors import SessionExpired
|
||||||
from study_tool.platforms.jspx import *
|
from study_tool.runtime import RUNS, log, set_state
|
||||||
from study_tool.runtime import log
|
|
||||||
|
|
||||||
# ------------------------------------------------------- 新版平台:页面识别/提取
|
from ..config import DEBUG_DIR, HEX32, JSPX, STUDY
|
||||||
|
from .http import dump_cookies, new_session, study_token
|
||||||
|
from .login import ensure_study_session
|
||||||
|
|
||||||
|
|
||||||
def extract_train_id(page_url, html):
|
def extract_train_id(page_url, html):
|
||||||
@@ -81,7 +85,7 @@ def save_snapshot(plan, project, url, html):
|
|||||||
"""
|
"""
|
||||||
safe_url = re.sub(r"([?&])(token|access_token|securityKey|sign)=[^&]*", r"\1\2=***", url or "")
|
safe_url = re.sub(r"([?&])(token|access_token|securityKey|sign)=[^&]*", r"\1\2=***", url or "")
|
||||||
try:
|
try:
|
||||||
sid = store.snapshot_add(plan, project, safe_url, html or "")
|
sid = database.snapshot_add(plan, project, safe_url, html or "")
|
||||||
return "/api/snapshot/%d" % sid
|
return "/api/snapshot/%d" % sid
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -98,15 +102,6 @@ def save_snapshot(plan, project, url, html):
|
|||||||
return "(保存失败)"
|
return "(保存失败)"
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------- 新版平台:HTTP 客户端
|
|
||||||
|
|
||||||
def _study_token(s):
|
|
||||||
for c in s.cookies:
|
|
||||||
if c.name == "Middle-Token" and "dgjy" in (c.domain or ""):
|
|
||||||
return c.value
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
class StudyClient:
|
class StudyClient:
|
||||||
"""新版平台接口客户端。
|
"""新版平台接口客户端。
|
||||||
|
|
||||||
@@ -117,7 +112,7 @@ class StudyClient:
|
|||||||
|
|
||||||
def __init__(self, session, page_url=""):
|
def __init__(self, session, page_url=""):
|
||||||
self.s = session
|
self.s = session
|
||||||
self.token = _study_token(session)
|
self.token = study_token(session)
|
||||||
self.bases = []
|
self.bases = []
|
||||||
origin = ""
|
origin = ""
|
||||||
if page_url:
|
if page_url:
|
||||||
@@ -178,11 +173,9 @@ class StudyClient:
|
|||||||
ensure_study_session(self.s, force=True)
|
ensure_study_session(self.s, force=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.token = _study_token(self.s)
|
self.token = study_token(self.s)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------- 新版平台:课程结构
|
|
||||||
|
|
||||||
def _flatten_periods(nodes, chapter="", out=None):
|
def _flatten_periods(nodes, chapter="", out=None):
|
||||||
out = [] if out is None else out
|
out = [] if out is None else out
|
||||||
for n in nodes or []:
|
for n in nodes or []:
|
||||||
@@ -228,9 +221,12 @@ def _int_or_none(v):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# 视频文件类型 -> 播放器通道(来自课程播放页 chunk-3d0ebfea 的 judgeVideoType)
|
|
||||||
CLOUD_TYPES = {"cloud_video", "middle"} # polyv 云视频:/period/middle/play
|
CLOUD_TYPES = {"cloud_video", "middle"} # polyv 云视频:/period/middle/play
|
||||||
|
|
||||||
|
|
||||||
MOOC_TYPES = {"mooc_video"} # 网易云课堂:走签名/moc 播放
|
MOOC_TYPES = {"mooc_video"} # 网易云课堂:走签名/moc 播放
|
||||||
|
|
||||||
|
|
||||||
OTHER_TYPES = {"other"} # 普通 mp4:原生 <video>,ended 即完成
|
OTHER_TYPES = {"other"} # 普通 mp4:原生 <video>,ended 即完成
|
||||||
|
|
||||||
|
|
||||||
@@ -429,8 +425,6 @@ def discover_study(client, train_id, name="", user=""):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------- 新版平台:动作
|
|
||||||
|
|
||||||
def study_period_log(client, period_id, done, seconds=0):
|
def study_period_log(client, period_id, done, seconds=0):
|
||||||
"""播放器两个包:播放中 {isComplete:0, lastProgressTime},播完 {isComplete:1}(不带时长)。"""
|
"""播放器两个包:播放中 {isComplete:0, lastProgressTime},播完 {isComplete:1}(不带时长)。"""
|
||||||
body = {"periodId": int(period_id), "isComplete": 1 if done else 0}
|
body = {"periodId": int(period_id), "isComplete": 1 if done else 0}
|
||||||
@@ -620,7 +614,7 @@ def study_join_course(client, train_id):
|
|||||||
return str(j.get("code")) == "200", str(j.get("msg") or j.get("message") or "")
|
return str(j.get("code")) == "200", str(j.get("msg") or j.get("message") or "")
|
||||||
|
|
||||||
|
|
||||||
def study_complete_period(cookies, page_url, item):
|
def study_complete_period(cookies, page_url, item, key=""):
|
||||||
"""把一个视频课时报到 complete=1,并以服务端 completeTime 为准。"""
|
"""把一个视频课时报到 complete=1,并以服务端 completeTime 为准。"""
|
||||||
s = new_session(cookies)
|
s = new_session(cookies)
|
||||||
c = StudyClient(s, page_url)
|
c = StudyClient(s, page_url)
|
||||||
@@ -648,6 +642,11 @@ def study_complete_period(cookies, page_url, item):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
last = str(e)[:100]
|
last = str(e)[:100]
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
|
notify.retry(
|
||||||
|
key,
|
||||||
|
"单课时上报",
|
||||||
|
"课时「%s」第 %d 次上报后仍未生效:%s" % (item.get("title") or pid, attempt, last),
|
||||||
|
)
|
||||||
return False, last
|
return False, last
|
||||||
|
|
||||||
|
|
||||||
@@ -679,10 +678,437 @@ def study_submit_feedback(client, fb, key, train_id):
|
|||||||
log(key, "反馈表:提交失败 code=%s %s" % (r.get("code"), str(r.get("msg") or "")[:80]))
|
log(key, "反馈表:提交失败 code=%s %s" % (r.get("code"), str(r.get("msg") or "")[:80]))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 学习实现
|
def run_study_course(key, s, d, workers, cap, enable_quiz, enable_feedback,
|
||||||
|
mode="fast", parallel=2):
|
||||||
|
"""新版平台(study.dgjy.net / Vue SPA)课程:视频走 study/log 接口,
|
||||||
|
反馈表走 evaluation/result,测验保持手动作答。
|
||||||
|
|
||||||
|
mode=fast 只调接口上报(直链 mp4 仍退回真播),速度最快
|
||||||
|
mode=normal 开无头浏览器真播视频,parallel 控制同时播几个
|
||||||
|
"""
|
||||||
|
cookies = dump_cookies(s)
|
||||||
|
page_url = d.get("page_url") or ""
|
||||||
|
train_id = d.get("train_id") or ""
|
||||||
|
client = StudyClient(s, page_url)
|
||||||
|
|
||||||
|
# ---- 先确认已加入培训 ----
|
||||||
|
# 没加入时 study/log 仍然回 200,但服务端不记账:逐个课时重试只会刷一屏「上报未生效」。
|
||||||
|
if d.get("need_join"):
|
||||||
|
joined, why = ensure_study_join(s, client, d)
|
||||||
|
if joined:
|
||||||
|
log(key, "加入培训:%s" % why)
|
||||||
|
d["is_join"] = True
|
||||||
|
else:
|
||||||
|
log(key, "加入培训被拒:%s" % (why or "未知原因"))
|
||||||
|
probe = next((c for c in d["courseware"] if not c["done"]), None)
|
||||||
|
if probe:
|
||||||
|
good, last = study_complete_period(cookies, page_url, probe, key)
|
||||||
|
log(key, "试报 1 个课时:%s" % ("生效" if good else "未生效(%s)" % last))
|
||||||
|
if not good:
|
||||||
|
log(key, "该账号对这门课没有写入权限,本次跳过。"
|
||||||
|
"需要课程管理员把这门课分配到账号下(jspx 在学 ≠ 新版平台已加入)")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---- 反馈表 ----
|
||||||
|
if enable_feedback:
|
||||||
|
for fb in d["feedback"]:
|
||||||
|
if fb["done"]:
|
||||||
|
log(key, "反馈表已完成,跳过")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
study_submit_feedback(client, fb, key, train_id)
|
||||||
|
except Exception as e:
|
||||||
|
log(key, "反馈表异常:%s" % e)
|
||||||
|
else:
|
||||||
|
for fb in d["feedback"]:
|
||||||
|
log(key, "反馈表:已按设置跳过(%s)" % ("已完成" if fb["done"] else "未完成"))
|
||||||
|
|
||||||
|
# ---- 测验 ----
|
||||||
|
for t in d["test"]:
|
||||||
|
score, passed = t.get("score"), t.get("passed_score")
|
||||||
|
if t["done"]:
|
||||||
|
if score is not None and passed is not None:
|
||||||
|
try:
|
||||||
|
if float(score) < float(passed):
|
||||||
|
log(key, "测验:已作答但不及格(%s/%s),需要在页面上重考" % (score, passed))
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log(key, "测验:已完成,跳过")
|
||||||
|
elif enable_quiz:
|
||||||
|
log(key, "测验:新版平台不代答(%s 需在页面上手动作答)" % t["title"][:30])
|
||||||
|
else:
|
||||||
|
log(key, "测验:⚠ 已按设置跳过(%s),需要时请在页面上手动作答" % t["title"][:30])
|
||||||
|
|
||||||
|
# ---- 视频课时 ----
|
||||||
|
objs = d["courseware"]
|
||||||
|
todo = [c for c in objs if not c["done"]]
|
||||||
|
done0 = len(objs) - len(todo)
|
||||||
|
set_state(key, status="studying", total=len(objs), done=done0)
|
||||||
|
if not objs:
|
||||||
|
log(key, "没有视频课时")
|
||||||
|
return
|
||||||
|
|
||||||
|
kinds = {}
|
||||||
|
for c in objs:
|
||||||
|
kinds[c.get("kind") or "?"] = kinds.get(c.get("kind") or "?", 0) + 1
|
||||||
|
if kinds:
|
||||||
|
log(key, "视频类型:%s" % "、".join("%s %d 个" % (k, v) for k, v in kinds.items()))
|
||||||
|
|
||||||
|
if not todo:
|
||||||
|
log(key, "所有课件均已完成")
|
||||||
|
all_done = True
|
||||||
|
elif mode == "normal":
|
||||||
|
got = study_videos_normal(key, cookies, page_url, train_id, todo, parallel, cap, done0)
|
||||||
|
all_done = got >= len(objs)
|
||||||
|
set_state(key, done=got)
|
||||||
|
log(key, "视频课时:%d/%d 已完成" % (got, len(objs)))
|
||||||
|
else:
|
||||||
|
got = study_videos_fast(key, cookies, page_url, train_id, todo, workers, cap, done0)
|
||||||
|
all_done = got >= len(objs)
|
||||||
|
set_state(key, done=got)
|
||||||
|
log(key, "视频课时:%d/%d 已完成" % (got, len(objs)))
|
||||||
|
|
||||||
|
# ---- 课程容器(periodType 10/13):子课时学完不会自动完成 ----
|
||||||
|
if all_done and d.get("containers"):
|
||||||
|
for ct in d["containers"]:
|
||||||
|
if ct.get("done"):
|
||||||
|
continue
|
||||||
|
try: # 先重算子课程计数:并发上报会丢增量,计数不到容器就不完成
|
||||||
|
study_cal_final_score(client, ct["kcdm"])
|
||||||
|
log(key, " 已重算子课程完成计数(kcdm=%s)" % ct["kcdm"][:8])
|
||||||
|
except Exception as e:
|
||||||
|
log(key, " 重算子课程完成计数失败:%s" % str(e)[:80])
|
||||||
|
ok, msg = study_complete_container(client, ct)
|
||||||
|
log(key, " %s 课程节点「%s」:%s" % ("✓" if ok else "×", ct["name"][:24], msg))
|
||||||
|
try:
|
||||||
|
study_cal_final_score(client, train_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
_reread_study(key, s, d)
|
||||||
|
except Exception as e:
|
||||||
|
log(key, "复查失败:%s" % e)
|
||||||
|
|
||||||
|
|
||||||
|
VIS_KEEP_JS = """
|
||||||
|
(() => {
|
||||||
|
const d = document;
|
||||||
|
try { Object.defineProperty(d, 'hidden', {get: () => false, configurable: true}); } catch (e) {}
|
||||||
|
try { Object.defineProperty(d, 'webkitHidden', {get: () => false, configurable: true}); } catch (e) {}
|
||||||
|
try { Object.defineProperty(d, 'visibilityState', {get: () => 'visible', configurable: true}); } catch (e) {}
|
||||||
|
d.addEventListener('visibilitychange', e => e.stopImmediatePropagation(), true);
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _classhour_of(url):
|
||||||
|
m = re.search(r"[?&]classhour=([0-9]+)", url or "")
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_video(pg, want_id, secs=60):
|
||||||
|
"""等播放页就绪:("ok", (frame, duration)) / ("redirect", 被切到的课时) / ("timeout", "")。"""
|
||||||
|
end = time.time() + secs
|
||||||
|
while time.time() < end:
|
||||||
|
cur = _classhour_of(pg.url)
|
||||||
|
if cur and want_id and cur != str(want_id):
|
||||||
|
return "redirect", cur
|
||||||
|
for fr in pg.frames:
|
||||||
|
try:
|
||||||
|
st = fr.evaluate("""() => {
|
||||||
|
const v = Array.from(document.querySelectorAll('video')).filter(x => x.duration > 30)[0];
|
||||||
|
return v ? {d: v.duration} : null; }""")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if st and st.get("d"):
|
||||||
|
return "ok", (fr, float(st["d"]))
|
||||||
|
pg.wait_for_timeout(1000)
|
||||||
|
return "timeout", ""
|
||||||
|
|
||||||
|
|
||||||
|
def _kick_play(frame, seek):
|
||||||
|
"""保活:静音、1 倍速、必要时 seek 到结尾前 3 秒(seek=False 就是老老实实从头播)。"""
|
||||||
|
try:
|
||||||
|
frame.evaluate("""(seek) => {
|
||||||
|
const v = Array.from(document.querySelectorAll('video')).filter(x => x.duration > 30)[0];
|
||||||
|
if (!v) return;
|
||||||
|
v.muted = true;
|
||||||
|
try { v.playbackRate = 1; } catch (e) {}
|
||||||
|
if (seek && v.currentTime < v.duration - 5) {
|
||||||
|
try { v.currentTime = Math.max(0, v.duration - 3); } catch (e) {}
|
||||||
|
}
|
||||||
|
if (v.paused) v.play().catch(() => {});
|
||||||
|
}""", seek)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _dialog_text(pg):
|
||||||
|
try:
|
||||||
|
el = pg.query_selector(".el-message-box__message")
|
||||||
|
return (el.inner_text() or "").strip() if el else ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def play_one_period(key, p, cookies, train_id, item, timeout=900, seek=False, attach_cdp=False):
|
||||||
|
"""真播一个课时到结尾。返回 (ok, 说明, 平台要求先学的课时 id)。
|
||||||
|
|
||||||
|
平台在进入播放页时会先查「是不是轮到这一节」,不按顺序会被弹回并跳到它指定的课时,
|
||||||
|
这时返回的第三个值就是那一节,调用方据此重排队列。
|
||||||
|
"""
|
||||||
|
pid = str(item["period_id"])
|
||||||
|
url = ("%s/training/courseVideo?classhour=%s&isJoin=1&needJoin=1&courseId=%s"
|
||||||
|
"&isFront=1&trainCourseId=%s" % (STUDY, pid, train_id, train_id))
|
||||||
|
posted = {"done": False}
|
||||||
|
browser, own = None, False
|
||||||
|
try:
|
||||||
|
if attach_cdp:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.connect_over_cdp("http://127.0.0.1:9222")
|
||||||
|
except Exception:
|
||||||
|
browser = None
|
||||||
|
if browser is None:
|
||||||
|
browser = p.chromium.launch(headless=True, args=[
|
||||||
|
"--autoplay-policy=no-user-gesture-required", "--mute-audio",
|
||||||
|
"--disable-background-timer-throttling", "--disable-renderer-backgrounding"])
|
||||||
|
own = True
|
||||||
|
ctx = browser.new_context()
|
||||||
|
else:
|
||||||
|
ctx = browser.contexts[0] if browser.contexts else browser.new_context()
|
||||||
|
for c in cookies:
|
||||||
|
if "dgjy" in (c.get("domain") or ""):
|
||||||
|
try:
|
||||||
|
ctx.add_cookies([{"name": c["name"], "value": c.get("value") or "",
|
||||||
|
"domain": (c.get("domain") or "").lstrip("."),
|
||||||
|
"path": c.get("path") or "/"}])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
pg = ctx.new_page()
|
||||||
|
pg.add_init_script(VIS_KEEP_JS)
|
||||||
|
|
||||||
|
def on_req(r):
|
||||||
|
try:
|
||||||
|
if "period/study/log" in r.url and '"isComplete":1' in (r.post_data or "").replace(" ", ""):
|
||||||
|
posted["done"] = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pg.on("request", on_req)
|
||||||
|
pg.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||||
|
|
||||||
|
status, val = _wait_video(pg, pid, 60)
|
||||||
|
if status == "redirect":
|
||||||
|
return False, "平台按顺序放行,页面被切到课时 %s" % val, str(val)
|
||||||
|
if status != "ok":
|
||||||
|
return False, "没等到 <video>(可能需要专用播放器的课时)", ""
|
||||||
|
frame = val[0]
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
if posted["done"]:
|
||||||
|
return True, "", ""
|
||||||
|
msg = _dialog_text(pg)
|
||||||
|
if "顺序" in msg or "未完成课时" in msg:
|
||||||
|
return False, "平台按顺序放行:%s" % msg[:30], _classhour_of(pg.url)
|
||||||
|
_kick_play(frame, seek)
|
||||||
|
pg.wait_for_timeout(2000)
|
||||||
|
return False, "超时未等到播放结束上报", ""
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)[:120], ""
|
||||||
|
finally:
|
||||||
|
if own and browser is not None:
|
||||||
|
try:
|
||||||
|
browser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def study_videos_fast(key, cookies, page_url, train_id, todo, workers, cap, done0=0):
|
||||||
|
"""快速学习:直接调 study/log 上报;直链 mp4 接口不认,退回真播。返回完成总数。"""
|
||||||
|
deadline = time.time() + cap * 60
|
||||||
|
hard = [c for c in todo if c.get("kind") != "direct"]
|
||||||
|
soft = [c for c in todo if c.get("kind") == "direct"]
|
||||||
|
ok = done0
|
||||||
|
if hard:
|
||||||
|
log(key, "并发提交 %d 个视频课时(workers=%d)…" % (len(hard), min(workers, len(hard))))
|
||||||
|
ex = ThreadPoolExecutor(max_workers=min(workers, len(hard)))
|
||||||
|
futs = {ex.submit(study_complete_period, cookies, page_url, it, key): it for it in hard}
|
||||||
|
try:
|
||||||
|
for fut in as_completed(futs, timeout=max(1, deadline - time.time())):
|
||||||
|
it = futs[fut]
|
||||||
|
try:
|
||||||
|
good, msg = fut.result()
|
||||||
|
except Exception as e:
|
||||||
|
good, msg = False, str(e)[:80]
|
||||||
|
if good:
|
||||||
|
ok += 1
|
||||||
|
log(key, " ✓ %s(%ss)" % (it["title"][:40], it.get("duration") or 0))
|
||||||
|
else:
|
||||||
|
log(key, " × %s 上报未生效:%s" % (it["title"][:40], msg))
|
||||||
|
set_state(key, done=ok)
|
||||||
|
except TimeoutError:
|
||||||
|
log(key, "已到时间上限(%d 分钟),剩余课时留待下次" % cap)
|
||||||
|
except Exception as e:
|
||||||
|
log(key, "批量提交中断:%s" % e)
|
||||||
|
finally:
|
||||||
|
ex.shutdown(wait=False, cancel_futures=True)
|
||||||
|
if soft:
|
||||||
|
log(key, "另有 %d 个直链视频课时需要真播放到结尾(%s)"
|
||||||
|
% (len(soft), "、".join(c["title"][:16] for c in soft[:4])))
|
||||||
|
for it in soft:
|
||||||
|
if (RUNS.get(key) or {}).get("status") == "stopped":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
good = play_period_to_end(key, cookies, page_url,
|
||||||
|
train_id, it["period_id"], it["title"])
|
||||||
|
except Exception as e:
|
||||||
|
log(key, " × %s 播放失败:%s" % (it["title"][:30], str(e)[:100]))
|
||||||
|
continue
|
||||||
|
if good:
|
||||||
|
ok += 1
|
||||||
|
log(key, " ✓ %s(播放到结尾)" % it["title"][:40])
|
||||||
|
else:
|
||||||
|
log(key, " × %s 未确认完成" % it["title"][:40])
|
||||||
|
set_state(key, done=ok)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def _play_wave(key, cookies, train_id, wave, deadline, seek):
|
||||||
|
"""一波并发:每路一个独立 playwright + 独立浏览器,互不遮挡。"""
|
||||||
|
def run(it):
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
with sync_playwright() as p:
|
||||||
|
return play_one_period(key, p, cookies, train_id, it,
|
||||||
|
timeout=max(60, deadline - time.time()), seek=seek)
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)[:100], ""
|
||||||
|
out = []
|
||||||
|
ex = ThreadPoolExecutor(max_workers=len(wave))
|
||||||
|
try:
|
||||||
|
futs = {ex.submit(run, it): it for it in wave}
|
||||||
|
for fut in as_completed(futs):
|
||||||
|
try:
|
||||||
|
ok, why, wanted = fut.result()
|
||||||
|
except Exception as e:
|
||||||
|
ok, why, wanted = False, str(e)[:100], ""
|
||||||
|
out.append((futs[fut], ok, why, wanted))
|
||||||
|
finally:
|
||||||
|
ex.shutdown(wait=False)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def study_videos_normal(key, cookies, page_url, train_id, todo, parallel, cap, done0=0):
|
||||||
|
"""常规学习:开无头浏览器真播视频,parallel 路并发。
|
||||||
|
|
||||||
|
平台可能按课节顺序放行(不在顺序上会被弹回),所以先按并发跑一波;
|
||||||
|
一旦被弹回就把并发降到 1,并把平台指定的那一节挪到队首,按顺序补回去。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright # noqa: F401
|
||||||
|
except Exception as e:
|
||||||
|
log(key, "缺少 playwright,常规学习不可用:%s" % e)
|
||||||
|
return done0
|
||||||
|
|
||||||
|
parallel = max(1, min(int(parallel or 1), 5, len(todo)))
|
||||||
|
log(key, "常规学习:%d 个课时真播,%d 路并发" % (len(todo), parallel))
|
||||||
|
deadline = time.time() + cap * 60
|
||||||
|
ok, pending, order_only, bounced = done0, list(todo), False, {}
|
||||||
|
while pending and time.time() < deadline:
|
||||||
|
if (RUNS.get(key) or {}).get("status") == "stopped":
|
||||||
|
log(key, "已按要求停止")
|
||||||
|
break
|
||||||
|
wave, pending = pending[:parallel], pending[parallel:]
|
||||||
|
log(key, "本轮 %d 路:%s" % (len(wave), "、".join(x["title"][:14] for x in wave)))
|
||||||
|
for it, good, why, wanted in _play_wave(key, cookies, train_id, wave, deadline, seek=False):
|
||||||
|
if good:
|
||||||
|
ok += 1
|
||||||
|
log(key, " ✓ %s(真播到结尾)" % it["title"][:40])
|
||||||
|
else:
|
||||||
|
log(key, " × %s:%s" % (it["title"][:30], why))
|
||||||
|
if wanted:
|
||||||
|
order_only = True
|
||||||
|
n = bounced[it["period_id"]] = bounced.get(it["period_id"], 0) + 1
|
||||||
|
if n >= 3: # 一直被顺序校验弹回,别再空转
|
||||||
|
log(key, " × %s:连续被顺序校验弹回,跳过" % it["title"][:30])
|
||||||
|
else:
|
||||||
|
i = next((i for i, x in enumerate(pending)
|
||||||
|
if str(x["period_id"]) == str(wanted)), None)
|
||||||
|
if i is None: # 平台要的那一节正在播或不在这门课里,先排到队尾
|
||||||
|
pending.append(it)
|
||||||
|
else: # 先播平台要的那一节,再回来播这一个
|
||||||
|
pending.insert(0, pending.pop(i))
|
||||||
|
set_state(key, done=ok)
|
||||||
|
if order_only and parallel > 1:
|
||||||
|
parallel = 1
|
||||||
|
log(key, "检测到平台按顺序放行,并发降为 1(剩下的逐个播)")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def play_period_to_end(key, cookies, page_url, train_id, period_id, title, timeout=900):
|
||||||
|
"""直链视频(fileType=other):平台只认 onPlayEnd 上报的 isComplete=1。
|
||||||
|
|
||||||
|
做法:Playwright 打开课程播放页(带登录 cookie)→ 秒跳到 duration-3 秒 → 播放 →
|
||||||
|
等接口出现 isComplete=1 → 回读 completeTime 复核。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except Exception as e:
|
||||||
|
log(key, " 缺少 playwright,直链视频无法自动播完:%s" % e)
|
||||||
|
return False
|
||||||
|
item = {"period_id": period_id, "title": title}
|
||||||
|
try:
|
||||||
|
with sync_playwright() as p:
|
||||||
|
ok, why, _ = play_one_period(key, p, cookies, train_id, item,
|
||||||
|
timeout=timeout, seek=True, attach_cdp=True)
|
||||||
|
except Exception as e:
|
||||||
|
log(key, " 播放异常:%s" % str(e)[:120])
|
||||||
|
return False
|
||||||
|
if not ok:
|
||||||
|
log(key, " 未播完:%s" % why)
|
||||||
|
return False
|
||||||
|
time.sleep(2)
|
||||||
|
try:
|
||||||
|
c = StudyClient(new_session(cookies), page_url)
|
||||||
|
return study_is_done(c, period_id)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def study_sync_gyx(client, train_id):
|
||||||
|
"""培训里的「课程」是 periodType 10/13 的容器节点,不跟着里面的课时自动完成。
|
||||||
|
|
||||||
|
这条同步接口要课程管理员权限:学生账号会回「无权操作该课程」,调一次没坏处,
|
||||||
|
管理员账号跑的时候能直接把容器刷成完成。
|
||||||
|
"""
|
||||||
|
j = client.call("GET", "/course/front/study/sync/gyxCourseData/%s" % train_id)
|
||||||
|
if j.get("code") == 200:
|
||||||
|
return "已同步"
|
||||||
|
return "跳过(%s)" % str(j.get("msg") or ("code=%s" % j.get("code")))[:40]
|
||||||
|
|
||||||
|
|
||||||
|
def _reread_study(key, s, d):
|
||||||
|
"""课程跑完后按 trainCourseId 直接复查(不重新解析 jspx 页面)。"""
|
||||||
|
client = StudyClient(s, d.get("page_url") or "")
|
||||||
|
try:
|
||||||
|
log(key, "同步莞易学课程完成状态:%s" % study_sync_gyx(client, d["train_id"]))
|
||||||
|
except Exception as e:
|
||||||
|
log(key, "同步莞易学课程完成状态失败:%s" % str(e)[:100])
|
||||||
|
try:
|
||||||
|
d2 = discover_study(client, d["train_id"], d["course"])
|
||||||
|
except SessionExpired:
|
||||||
|
client.bootstrap()
|
||||||
|
d2 = discover_study(client, d["train_id"], d["course"])
|
||||||
|
set_state(key, progress=d2["progress"], info=d2["info"], cookies=dump_cookies(s))
|
||||||
|
log(key, "课程结束:%s | %s" % (d2["progress"], d2["info"]))
|
||||||
|
|
||||||
|
|
||||||
def _study(key, s, d, **opts):
|
def _study(key, s, d, **opts):
|
||||||
from study_tool.engine import run_study_course
|
|
||||||
return run_study_course(key, s, d, opts.get("workers", 1), opts.get("cap", 30),
|
return run_study_course(key, s, d, opts.get("workers", 1), opts.get("cap", 30),
|
||||||
opts.get("enable_quiz", False), opts.get("enable_feedback", False),
|
opts.get("enable_quiz", False), opts.get("enable_feedback", False),
|
||||||
opts.get("mode", "fast"), opts.get("parallel", 2))
|
opts.get("mode", "fast"), opts.get("parallel", 2))
|
||||||
@@ -1 +1,12 @@
|
|||||||
"""HTTP 路由包:按业务域拆分,导入即注册到 study_tool.webapp.app。"""
|
"""HTTP 路由包:按业务域拆分,导入即注册到 study_tool.webapp.app。
|
||||||
|
|
||||||
|
新增路由文件不用改任何地方:load_routes() 会扫描本目录并导入全部模块。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
|
||||||
|
def load_routes():
|
||||||
|
for m in pkgutil.iter_modules(__path__):
|
||||||
|
importlib.import_module(f"{__name__}.{m.name}")
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
"""账号 / 登录 / 登录记录接口。"""
|
"""账号 / 登录 / 登录记录接口。"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from study_tool import database, notify
|
||||||
|
from study_tool.models import AccountIn, LoginIn
|
||||||
|
from study_tool.platforms import registry
|
||||||
|
from study_tool.runtime import RUNS, RUNS_LOCK, log
|
||||||
from study_tool.webapp import app
|
from study_tool.webapp import app
|
||||||
|
|
||||||
from study_tool import store
|
|
||||||
from study_tool.config import *
|
|
||||||
from study_tool.models import *
|
|
||||||
from study_tool.platforms import registry
|
|
||||||
from study_tool.platforms.jspx import *
|
|
||||||
from study_tool.runtime import RUNS, RUNS_LOCK, log
|
|
||||||
|
|
||||||
def _log_login(username, ok, code, error, elapsed, stage=""):
|
def _log_login(username, ok, code, error, elapsed, stage=""):
|
||||||
"""每次登录尝试:统一日志 + login_log 表(登录记录页用)。"""
|
"""每次登录尝试:统一日志 + login_log 表(登录记录页用)。"""
|
||||||
log(username or "-", "登录%s code=%s%s %s %.1fs" % (
|
log(
|
||||||
"成功" if ok else "失败", code, (" " + stage) if stage else "",
|
username or "-",
|
||||||
(error or "")[:120], elapsed or 0))
|
"登录%s code=%s%s %s %.1fs"
|
||||||
|
% ("成功" if ok else "失败", code, (" " + stage) if stage else "", (error or "")[:120], elapsed or 0),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
store.login_log_add(username, ok, code, elapsed, stage, error)
|
database.login_log_add(username, ok, code, elapsed, stage, error)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
_RUN_FIELDS = ("status", "error", "progress", "done", "total", "course",
|
_RUN_FIELDS = ("status", "error", "progress", "done", "total", "course", "started", "finished_at", "cookies")
|
||||||
"started", "finished_at")
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_done_run(account_id):
|
def _clear_done_run(account_id):
|
||||||
@@ -46,14 +46,14 @@ def _clear_done_run(account_id):
|
|||||||
for field in _RUN_FIELDS:
|
for field in _RUN_FIELDS:
|
||||||
st.pop(field, None)
|
st.pop(field, None)
|
||||||
st["updated"] = time.time()
|
st["updated"] = time.time()
|
||||||
store.run_state_delete(key)
|
database.run_state_delete(key)
|
||||||
|
|
||||||
|
|
||||||
def _login_account(b: LoginIn):
|
def _login_account(b: LoginIn):
|
||||||
"""解析出一组可登录的凭据:显式传入优先,否则按账号 id 从库里取。"""
|
"""解析出一组可登录的凭据:显式传入优先,否则按账号 id 从库里取。"""
|
||||||
acc = store.account_get(b.account_id) if b.account_id else None
|
acc = database.account_get(b.account_id) if b.account_id else None
|
||||||
username = (b.username or "").strip() or ((acc or {}).get("username") or "")
|
username = (b.username or "").strip() or ((acc or {}).get("username") or "")
|
||||||
password = b.password or (store.account_password(b.account_id) if b.account_id else "")
|
password = b.password or (database.account_password(b.account_id) if b.account_id else "")
|
||||||
return acc, username, password
|
return acc, username, password
|
||||||
|
|
||||||
|
|
||||||
@@ -63,13 +63,13 @@ def api_login(b: LoginIn):
|
|||||||
acc, username, password = _login_account(b)
|
acc, username, password = _login_account(b)
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
err = "没有可用的账号密码:新账号请在「添加账号」里填密码,服务端会把它存进 SQLite"
|
err = "没有可用的账号密码:新账号请在「添加账号」里填密码,服务端会把它存进 SQLite"
|
||||||
_log_login(username or b.account_id, False, "no_credential", err, time.time() - t0,
|
_log_login(username or b.account_id, False, "no_credential", err, time.time() - t0, "LoginIn")
|
||||||
"LoginIn")
|
|
||||||
return JSONResponse({"ok": False, "code": "no_credential", "error": err}, status_code=200)
|
return JSONResponse({"ok": False, "code": "no_credential", "error": err}, status_code=200)
|
||||||
pf = registry.get((acc or {}).get("platform") or b.platform)
|
pf = registry.get((acc or {}).get("platform") or b.platform)
|
||||||
if pf is None or pf.login is None:
|
if pf is None or pf.login is None:
|
||||||
err = "平台「%s」还没有实现登录,请换一个平台或先在 platforms/ 里补上实现" % (
|
err = "平台「%s」还没有实现登录,请换一个平台或先在 plugins/ 里补上实现" % (
|
||||||
(acc or {}).get("platform") or b.platform)
|
(acc or {}).get("platform") or b.platform
|
||||||
|
)
|
||||||
_log_login(username, False, "no_platform", err, time.time() - t0, "LoginIn")
|
_log_login(username, False, "no_platform", err, time.time() - t0, "LoginIn")
|
||||||
return JSONResponse({"ok": False, "code": "no_platform", "error": err}, status_code=200)
|
return JSONResponse({"ok": False, "code": "no_platform", "error": err}, status_code=200)
|
||||||
try:
|
try:
|
||||||
@@ -77,20 +77,27 @@ def api_login(b: LoginIn):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
err = str(e)[:300]
|
err = str(e)[:300]
|
||||||
_log_login(username, False, "login_failed", err, time.time() - t0, type(e).__name__)
|
_log_login(username, False, "login_failed", err, time.time() - t0, type(e).__name__)
|
||||||
|
notify.fault("login", (acc or {}).get("id") or "system", "登录失败", "账号:%s\n原因:%s" % (username, err))
|
||||||
if acc:
|
if acc:
|
||||||
store.account_upsert({"id": acc["id"], "username": acc["username"],
|
database.account_upsert(
|
||||||
"status": "login_failed", "lastError": err,
|
{
|
||||||
"lastErrorAt": time.time()})
|
"id": acc["id"],
|
||||||
|
"username": acc["username"],
|
||||||
|
"status": "login_failed",
|
||||||
|
"lastError": err,
|
||||||
|
"lastErrorAt": time.time(),
|
||||||
|
}
|
||||||
|
)
|
||||||
return JSONResponse({"ok": False, "code": "login_failed", "error": err}, status_code=200)
|
return JSONResponse({"ok": False, "code": "login_failed", "error": err}, status_code=200)
|
||||||
warn = ""
|
warn = ""
|
||||||
try:
|
try:
|
||||||
courses = pf.list_courses(s) if pf.list_courses else []
|
courses = pf.list_courses(s) if pf.list_courses else []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
courses, warn = [], str(e)[:200]
|
courses, warn = [], str(e)[:200]
|
||||||
cookies = (pf.dump_cookies or dump_cookies)(s)
|
cookies = pf.dump_cookies(s) if pf.dump_cookies else []
|
||||||
_log_login(username, True, "ok", warn, time.time() - t0, "user=%s" % user)
|
_log_login(username, True, "ok", warn, time.time() - t0, "user=%s" % user)
|
||||||
if acc:
|
if acc:
|
||||||
store.account_save_login(acc["id"], user, cookies, courses)
|
database.account_save_login(acc["id"], user, cookies, courses)
|
||||||
_clear_done_run(acc["id"]) # 上一轮已结束的运行状态不再挂在新会话上
|
_clear_done_run(acc["id"]) # 上一轮已结束的运行状态不再挂在新会话上
|
||||||
return {"ok": True, "user": user, "cookies": cookies, "courses": courses, "warn": warn}
|
return {"ok": True, "user": user, "cookies": cookies, "courses": courses, "warn": warn}
|
||||||
|
|
||||||
@@ -98,15 +105,16 @@ def api_login(b: LoginIn):
|
|||||||
@app.get("/api/loginlog")
|
@app.get("/api/loginlog")
|
||||||
def api_loginlog(tail: int = 40):
|
def api_loginlog(tail: int = 40):
|
||||||
"""登录尝试回放:前端点「登录」后,这里能看到平台到底回了什么。"""
|
"""登录尝试回放:前端点「登录」后,这里能看到平台到底回了什么。"""
|
||||||
return {"ok": True, "lines": store.login_log_tail(tail)}
|
return {"ok": True, "lines": database.login_log_tail(tail)}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ accounts
|
# ------------------------------------------------------------------ accounts
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/accounts")
|
@app.get("/api/accounts")
|
||||||
def api_accounts():
|
def api_accounts():
|
||||||
"""全部账号(含会话 cookie / 课程缓存 / 勾选),**不含密码**。"""
|
"""全部账号(含会话 cookie / 课程缓存 / 勾选),**不含密码**。"""
|
||||||
return {"ok": True, "accounts": store.account_list()}
|
return {"ok": True, "accounts": database.account_list()}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/accounts")
|
@app.post("/api/accounts")
|
||||||
@@ -118,16 +126,15 @@ def api_account_save(b: AccountIn):
|
|||||||
if not rec.get("id") and not rec.get("username"):
|
if not rec.get("id") and not rec.get("username"):
|
||||||
return JSONResponse({"ok": False, "error": "缺少 username"}, status_code=200)
|
return JSONResponse({"ok": False, "error": "缺少 username"}, status_code=200)
|
||||||
try:
|
try:
|
||||||
aid = store.account_upsert(rec)
|
aid = database.account_upsert(rec)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JSONResponse({"ok": False, "error": "写入 SQLite 失败:%s" % str(e)[:200]},
|
return JSONResponse({"ok": False, "error": "写入 SQLite 失败:%s" % str(e)[:200]}, status_code=200)
|
||||||
status_code=200)
|
return {"ok": True, "id": aid, "account": database.account_get(aid)}
|
||||||
return {"ok": True, "id": aid, "account": store.account_get(aid)}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/accounts/{account_id}")
|
@app.get("/api/accounts/{account_id}")
|
||||||
def api_account_get(account_id: str):
|
def api_account_get(account_id: str):
|
||||||
acc = store.account_get(account_id)
|
acc = database.account_get(account_id)
|
||||||
if not acc:
|
if not acc:
|
||||||
return JSONResponse({"ok": False, "error": "账号不存在"}, status_code=200)
|
return JSONResponse({"ok": False, "error": "账号不存在"}, status_code=200)
|
||||||
return {"ok": True, "account": acc}
|
return {"ok": True, "account": acc}
|
||||||
@@ -135,8 +142,8 @@ def api_account_get(account_id: str):
|
|||||||
|
|
||||||
@app.delete("/api/accounts/{account_id}")
|
@app.delete("/api/accounts/{account_id}")
|
||||||
def api_account_del(account_id: str):
|
def api_account_del(account_id: str):
|
||||||
acc = store.account_get(account_id) or {}
|
acc = database.account_get(account_id) or {}
|
||||||
store.account_delete(account_id)
|
database.account_delete(account_id)
|
||||||
with RUNS_LOCK:
|
with RUNS_LOCK:
|
||||||
RUNS.pop(account_id, None)
|
RUNS.pop(account_id, None)
|
||||||
# 登录日志挂在用户名下,一起清掉,不然删完账号日志还在
|
# 登录日志挂在用户名下,一起清掉,不然删完账号日志还在
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""大模型连通性测试接口。"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from study_tool.webapp import app
|
||||||
|
|
||||||
|
from study_tool.config.settings import load_settings
|
||||||
|
from study_tool.llm import llm_chat
|
||||||
|
from study_tool.models import LlmTestIn
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/llm/test")
|
||||||
|
def api_llm_test(b: LlmTestIn):
|
||||||
|
cfg = dict(load_settings()["llm"])
|
||||||
|
if b.base_url.strip():
|
||||||
|
cfg["base_url"] = b.base_url.strip()
|
||||||
|
if b.model.strip():
|
||||||
|
cfg["model"] = b.model.strip()
|
||||||
|
if b.api_key.strip():
|
||||||
|
cfg["api_key"] = b.api_key.strip()
|
||||||
|
cfg["timeout"] = max(10, min(120, int(b.timeout or 60)))
|
||||||
|
if not cfg.get("api_key"):
|
||||||
|
return {"ok": False, "error": "没有 api_key"}
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
reply = llm_chat([{"role": "user", "content": "只回复两个字符:ok"}], cfg,
|
||||||
|
max_tokens=8, json_mode=False)
|
||||||
|
return {"ok": True, "ms": int((time.time() - t0) * 1000), "reply": (reply or "")[:80]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)[:300]}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
"""/api/log、/api/runs:前端轮询日志与运行状态。"""
|
"""/api/log、/api/runs:前端轮询日志与运行状态。"""
|
||||||
from study_tool.webapp import app
|
|
||||||
|
|
||||||
from study_tool.runtime import RUNS, RUNS_LOCK
|
from study_tool.runtime import RUNS, RUNS_LOCK
|
||||||
|
from study_tool.webapp import app
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/log")
|
@app.get("/api/log")
|
||||||
def api_log(key: str, since: int = 0):
|
def api_log(key: str, since: int = 0):
|
||||||
st = RUNS.get(key) or {}
|
st = RUNS.get(key) or {}
|
||||||
log = st.get("log", [])
|
log = st.get("log", [])
|
||||||
out = {"lines": log[since:], "next": len(log), "status": st.get("status", ""),
|
out = {
|
||||||
"done": st.get("done", 0), "total": st.get("total", 0),
|
"lines": log[since:],
|
||||||
"course": st.get("course", ""), "progress": st.get("progress", "")}
|
"next": len(log),
|
||||||
|
"status": st.get("status", ""),
|
||||||
|
"done": st.get("done", 0),
|
||||||
|
"total": st.get("total", 0),
|
||||||
|
"course": st.get("course", ""),
|
||||||
|
"progress": st.get("progress", ""),
|
||||||
|
}
|
||||||
# discover 可能给账号补上了 study.dgjy.net 的 Middle-Token,回传给前端写回 IndexedDB
|
# discover 可能给账号补上了 study.dgjy.net 的 Middle-Token,回传给前端写回 IndexedDB
|
||||||
if st.get("cookies"):
|
if st.get("cookies"):
|
||||||
out["cookies"] = st["cookies"]
|
out["cookies"] = st["cookies"]
|
||||||
|
|||||||
+156
-55
@@ -1,43 +1,85 @@
|
|||||||
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志接口。"""
|
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志 / 插件管理接口。"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from study_tool.webapp import app
|
|
||||||
|
|
||||||
from study_tool import store
|
from study_tool import plugins, database
|
||||||
from study_tool.capture import *
|
from study_tool.config.settings import load_settings
|
||||||
from study_tool.config import *
|
from study_tool.engine import study_worker
|
||||||
from study_tool.engine import *
|
from study_tool.models import CaptureIn, CookiesIn, DiscoverIn, StopIn, StudyIn
|
||||||
from study_tool.models import *
|
|
||||||
from study_tool.platforms import registry
|
from study_tool.platforms import registry
|
||||||
from study_tool.platforms.jspx import *
|
from study_tool.platforms.errors import SessionExpired
|
||||||
from study_tool.platforms.study import *
|
from study_tool.runtime import RUNS, RUNS_LOCK, log, set_state
|
||||||
from study_tool.runtime import RUNS, RUNS_LOCK, log
|
from study_tool.webapp import app
|
||||||
from study_tool.settings_store import load_settings
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ 平台
|
# ------------------------------------------------------------------ 平台
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/platforms")
|
@app.get("/api/platforms")
|
||||||
def api_platforms():
|
def api_platforms():
|
||||||
"""已注册平台;前端用 label 展示,例如「莞易学」。"""
|
"""已注册平台;label 给界面展示,capabilities 说明该平台实现了哪些阶段。"""
|
||||||
return {"ok": True, "platforms": [{"id": p.id, "label": p.label} for p in registry.all()]}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"platforms": [{"id": p.id, "label": p.label, "capabilities": p.capabilities()} for p in registry.all()],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 插件管理
|
||||||
|
# 插件目录 plugins/<名字>/,实现见 study_tool.plugins。
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/plugins")
|
||||||
|
def api_plugins():
|
||||||
|
"""磁盘上的插件清单:是否启用、是否已加载、加载后的 label 与 capabilities。"""
|
||||||
|
return {"ok": True, "plugins": plugins.discover()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/plugins/{name}/enable")
|
||||||
|
def api_plugin_enable(name: str):
|
||||||
|
return _plugin_call(lambda: plugins.set_enabled(name, True))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/plugins/{name}/disable")
|
||||||
|
def api_plugin_disable(name: str):
|
||||||
|
return _plugin_call(lambda: plugins.set_enabled(name, False))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/plugins/reload")
|
||||||
|
def api_plugins_reload():
|
||||||
|
"""重载全部已启用插件:改完插件代码不用重启服务。"""
|
||||||
|
return _plugin_call(plugins.reload)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/plugins/{name}/reload")
|
||||||
|
def api_plugin_reload(name: str):
|
||||||
|
return _plugin_call(lambda: plugins.reload(name))
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_call(fn):
|
||||||
|
"""插件操作失败不抛 500,返回 ok=false 给前端提示。"""
|
||||||
|
try:
|
||||||
|
return {"ok": True, "plugins": fn()}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e), "plugins": plugins.discover()}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ 运行日志 / 快照
|
# ------------------------------------------------------------------ 运行日志 / 快照
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/log/history")
|
@app.get("/api/log/history")
|
||||||
def api_log_history(key: str = "", tail: int = 200):
|
def api_log_history(key: str = "", tail: int = 200):
|
||||||
"""从 SQLite 读历史日志;不带 key 时列出每个账号存了多少行。"""
|
"""从 SQLite 读历史日志;不带 key 时列出每个账号存了多少行。"""
|
||||||
if not key:
|
if not key:
|
||||||
return {"ok": True, "keys": store.run_log_keys()}
|
return {"ok": True, "keys": database.run_log_keys()}
|
||||||
return {"ok": True, "key": key, "lines": store.run_log_tail(key, tail)}
|
return {"ok": True, "key": key, "lines": database.run_log_tail(key, tail)}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/log/history")
|
@app.delete("/api/log/history")
|
||||||
def api_log_history_clear(key: str = ""):
|
def api_log_history_clear(key: str = ""):
|
||||||
"""清历史日志;带 key 时连内存里的那一段也清掉,前端不用刷新页面。"""
|
"""清历史日志;带 key 时连内存里的那一段也清掉,前端不用刷新页面。"""
|
||||||
store.run_log_clear(key)
|
database.run_log_clear(key)
|
||||||
with RUNS_LOCK:
|
with RUNS_LOCK:
|
||||||
st = RUNS.get(key)
|
st = RUNS.get(key)
|
||||||
if st is not None:
|
if st is not None:
|
||||||
@@ -48,35 +90,50 @@ def api_log_history_clear(key: str = ""):
|
|||||||
@app.get("/api/snapshots")
|
@app.get("/api/snapshots")
|
||||||
def api_snapshots(limit: int = 50):
|
def api_snapshots(limit: int = 50):
|
||||||
"""站点改版时存下的课程页原始 HTML 列表。"""
|
"""站点改版时存下的课程页原始 HTML 列表。"""
|
||||||
return {"ok": True, "snapshots": store.snapshot_list(limit)}
|
return {"ok": True, "snapshots": database.snapshot_list(limit)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/snapshot/{sid}", response_class=HTMLResponse)
|
@app.get("/api/snapshot/{sid}", response_class=HTMLResponse)
|
||||||
def api_snapshot(sid: int):
|
def api_snapshot(sid: int):
|
||||||
row = store.snapshot_get(sid)
|
row = database.snapshot_get(sid)
|
||||||
if not row:
|
if not row:
|
||||||
return HTMLResponse("<h3>快照不存在</h3>", status_code=404)
|
return HTMLResponse("<h3>快照不存在</h3>", status_code=404)
|
||||||
head = ("<!-- id=%s planId=%s projectId=%s final url: %s created=%s -->\n"
|
head = "<!-- id=%s planId=%s projectId=%s final url: %s created=%s -->\n" % (
|
||||||
% (row["id"], row["plan_id"], row["project_id"], row["url"],
|
row["id"],
|
||||||
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(row["created"] or 0))))
|
row["plan_id"],
|
||||||
|
row["project_id"],
|
||||||
|
row["url"],
|
||||||
|
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(row["created"] or 0)),
|
||||||
|
)
|
||||||
return HTMLResponse(head + (row["html"] or ""))
|
return HTMLResponse(head + (row["html"] or ""))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/courses")
|
@app.post("/api/courses")
|
||||||
def api_courses(b: CookiesIn):
|
def api_courses(b: CookiesIn):
|
||||||
if not b.cookies:
|
if not b.cookies:
|
||||||
return JSONResponse({"ok": False, "code": "no_cookie",
|
return JSONResponse(
|
||||||
"error": "本机没有该账号的会话 cookie,请先点「登录」"}, status_code=200)
|
{"ok": False, "code": "no_cookie", "error": "本机没有该账号的会话 cookie,请先点「登录」"}, status_code=200
|
||||||
|
)
|
||||||
pf = registry.get(b.platform)
|
pf = registry.get(b.platform)
|
||||||
if pf is None or pf.list_courses is None:
|
if pf is None or pf.list_courses is None:
|
||||||
return JSONResponse({"ok": False, "code": "no_platform",
|
return JSONResponse(
|
||||||
"error": "平台「%s」还没有实现刷新课程,请先在 platforms/ 里补上"
|
{
|
||||||
% b.platform}, status_code=200)
|
"ok": False,
|
||||||
s = (pf.session or new_session)(b.cookies)
|
"code": "no_platform",
|
||||||
|
"error": "平台「%s」还没有实现刷新课程,请先在 plugins/ 里补上" % b.platform,
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
|
if pf.session is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "code": "no_platform", "error": "平台「%s」还没有实现 session" % b.platform}, status_code=200
|
||||||
|
)
|
||||||
|
s = pf.session(b.cookies)
|
||||||
if not s.cookies:
|
if not s.cookies:
|
||||||
return JSONResponse({"ok": False, "code": "no_cookie",
|
return JSONResponse(
|
||||||
"error": "保存的 cookie 全部被过滤(domain 不含 dgjy),请重新登录"},
|
{"ok": False, "code": "no_cookie", "error": "保存的 cookie 全部被过滤(站点域名不匹配),请重新登录"},
|
||||||
status_code=200)
|
status_code=200,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
courses = pf.list_courses(s)
|
courses = pf.list_courses(s)
|
||||||
except SessionExpired as e:
|
except SessionExpired as e:
|
||||||
@@ -84,29 +141,43 @@ def api_courses(b: CookiesIn):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JSONResponse({"ok": False, "code": "fetch_failed", "error": str(e)[:300]}, status_code=200)
|
return JSONResponse({"ok": False, "code": "fetch_failed", "error": str(e)[:300]}, status_code=200)
|
||||||
if not courses:
|
if not courses:
|
||||||
return JSONResponse({"ok": False, "code": "no_course",
|
return JSONResponse(
|
||||||
"error": "登录态有效,但该账号名下三个板块(在学/助学/督学)"
|
{
|
||||||
"的进行中、未开始、已结束里都没有课程"}, status_code=200)
|
"ok": False,
|
||||||
|
"code": "no_course",
|
||||||
|
"error": "登录态有效,但该账号名下三个板块(在学/助学/督学)的进行中、未开始、已结束里都没有课程",
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
user = pf.profile(s) if pf.profile else ""
|
user = pf.profile(s) if pf.profile else ""
|
||||||
except Exception:
|
except Exception:
|
||||||
user = ""
|
user = ""
|
||||||
return {"ok": True, "courses": courses, "user": user,
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"courses": courses,
|
||||||
|
"user": user,
|
||||||
"in_study": len([c for c in courses if c["in_study"]]),
|
"in_study": len([c for c in courses if c["in_study"]]),
|
||||||
# 补 jspx 会话时 cookie 会换新(JSESSIONID/route),回传让前端写回
|
# 补 jspx 会话时 cookie 会换新(JSESSIONID/route),回传让前端写回
|
||||||
"cookies": (pf.dump_cookies or dump_cookies)(s)}
|
"cookies": (pf.dump_cookies(s) if pf.dump_cookies else []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/discover")
|
@app.post("/api/discover")
|
||||||
def api_discover(b: DiscoverIn):
|
def api_discover(b: DiscoverIn):
|
||||||
pf = registry.get(b.platform)
|
pf = registry.get(b.platform)
|
||||||
if pf is None or pf.discover is None:
|
if pf is None or pf.discover is None:
|
||||||
return JSONResponse({"ok": False, "code": "no_platform",
|
return JSONResponse(
|
||||||
"error": "平台「%s」还没有实现课程发现" % b.platform}, status_code=200)
|
{"ok": False, "code": "no_platform", "error": "平台「%s」还没有实现课程发现" % b.platform}, status_code=200
|
||||||
s = (pf.session or new_session)(b.cookies)
|
)
|
||||||
|
if pf.session is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "code": "no_platform", "error": "平台「%s」还没有实现 session" % b.platform}, status_code=200
|
||||||
|
)
|
||||||
|
s = pf.session(b.cookies)
|
||||||
try:
|
try:
|
||||||
d = pf.discover(s, b.plan_id, b.project_id, b.name)
|
d = pf.discover(s, b.plan_id, b.project_id, b.name)
|
||||||
return {"ok": True, **d, "cookies": (pf.dump_cookies or dump_cookies)(s)}
|
return {"ok": True, **d, "cookies": (pf.dump_cookies(s) if pf.dump_cookies else [])}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JSONResponse({"ok": False, "error": str(e)[:300]}, status_code=200)
|
return JSONResponse({"ok": False, "error": str(e)[:300]}, status_code=200)
|
||||||
|
|
||||||
@@ -125,17 +196,33 @@ def api_study(b: StudyIn):
|
|||||||
return JSONResponse({"ok": False, "error": "没有选中任何课程"}, status_code=200)
|
return JSONResponse({"ok": False, "error": "没有选中任何课程"}, status_code=200)
|
||||||
set_state(b.key, status="discovering", log=[], done=0, total=0, started=time.time())
|
set_state(b.key, status="discovering", log=[], done=0, total=0, started=time.time())
|
||||||
# 会话失效时后台要能自动重登:优先用请求里的密码,没有就按 key 从 SQLite 取
|
# 会话失效时后台要能自动重登:优先用请求里的密码,没有就按 key 从 SQLite 取
|
||||||
pw = b.password or store.account_password(b.key) or ""
|
pw = b.password or database.account_password(b.key) or ""
|
||||||
st = load_settings()["study"]
|
st = load_settings()["study"]
|
||||||
threading.Thread(target=study_worker,
|
threading.Thread(
|
||||||
args=(b.key, b.username, b.cookies, courses,
|
target=study_worker,
|
||||||
max(1, min(b.workers, 95)), b.cap_minutes, b.quiz_key,
|
args=(
|
||||||
b.enable_quiz, b.enable_feedback, pw,
|
b.key,
|
||||||
st.get("mode") or "fast", int(st.get("parallel") or 2)),
|
b.username,
|
||||||
daemon=True).start()
|
b.cookies,
|
||||||
return {"ok": True, "courses": len(courses),
|
courses,
|
||||||
"enable_quiz": b.enable_quiz, "enable_feedback": b.enable_feedback,
|
max(1, min(b.workers, 95)),
|
||||||
"study": st}
|
b.cap_minutes,
|
||||||
|
b.quiz_key,
|
||||||
|
b.enable_quiz,
|
||||||
|
b.enable_feedback,
|
||||||
|
pw,
|
||||||
|
st.get("mode") or "fast",
|
||||||
|
int(st.get("parallel") or 2),
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"courses": len(courses),
|
||||||
|
"enable_quiz": b.enable_quiz,
|
||||||
|
"enable_feedback": b.enable_feedback,
|
||||||
|
"study": st,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/stop")
|
@app.post("/api/stop")
|
||||||
@@ -145,6 +232,12 @@ def api_stop(b: StopIn):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_platform(key):
|
||||||
|
"""抓包是平台自己的能力:按账号所属平台取插件。"""
|
||||||
|
acc = database.account_get(key) or {}
|
||||||
|
return registry.get(acc.get("platform"))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/capture")
|
@app.post("/api/capture")
|
||||||
def api_capture(b: CaptureIn):
|
def api_capture(b: CaptureIn):
|
||||||
"""开一个可见的浏览器让用户手动走一遍,记录所有 /train/** 请求与响应。"""
|
"""开一个可见的浏览器让用户手动走一遍,记录所有 /train/** 请求与响应。"""
|
||||||
@@ -152,12 +245,15 @@ def api_capture(b: CaptureIn):
|
|||||||
return JSONResponse({"ok": False, "error": "正在抓包"}, status_code=200)
|
return JSONResponse({"ok": False, "error": "正在抓包"}, status_code=200)
|
||||||
if not b.cookies:
|
if not b.cookies:
|
||||||
return JSONResponse({"ok": False, "error": "尚未登录"}, status_code=200)
|
return JSONResponse({"ok": False, "error": "尚未登录"}, status_code=200)
|
||||||
CAPTURES[b.key] = []
|
pf = _capture_platform(b.key)
|
||||||
|
if pf is None or pf.capture is None:
|
||||||
|
return JSONResponse({"ok": False, "error": "该平台还不支持抓包"}, status_code=200)
|
||||||
|
pf.capture_reset(b.key)
|
||||||
set_state(b.key, capturing=True, capture_stop=False, capture_file="")
|
set_state(b.key, capturing=True, capture_stop=False, capture_file="")
|
||||||
log(b.key, "正在打开浏览器…")
|
log(b.key, "正在打开浏览器…")
|
||||||
threading.Thread(target=capture_worker,
|
threading.Thread(
|
||||||
args=(b.key, b.cookies, max(1, min(int(b.minutes or 10), 30))),
|
target=pf.capture, args=(b.key, b.cookies, max(1, min(int(b.minutes or 10), 30))), daemon=True
|
||||||
daemon=True).start()
|
).start()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -171,6 +267,11 @@ def api_capture_stop(b: StopIn):
|
|||||||
@app.get("/api/capture")
|
@app.get("/api/capture")
|
||||||
def api_capture_get(key: str = ""):
|
def api_capture_get(key: str = ""):
|
||||||
st = RUNS.get(key) or {}
|
st = RUNS.get(key) or {}
|
||||||
return {"ok": True, "capturing": bool(st.get("capturing")),
|
pf = _capture_platform(key)
|
||||||
|
records = pf.captures(key) if pf is not None and pf.capture is not None else []
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"capturing": bool(st.get("capturing")),
|
||||||
"file": st.get("capture_file") or "",
|
"file": st.get("capture_file") or "",
|
||||||
"records": CAPTURES.get(key) or []}
|
"records": records,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""公开查询接口:不需要 token,按用户名返回课程列表与学习进度(不含密码 / cookie / 账号 id)。"""
|
||||||
|
|
||||||
|
from study_tool import database
|
||||||
|
from study_tool.platforms import registry
|
||||||
|
from study_tool.runtime import RUNS, RUNS_LOCK
|
||||||
|
from study_tool.webapp import app
|
||||||
|
|
||||||
|
_COURSE_FIELDS = ("name", "section", "status_label", "info", "progress")
|
||||||
|
_RUN_FIELDS = ("status", "done", "total", "course", "progress", "error")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/query")
|
||||||
|
def api_query(username: str = ""):
|
||||||
|
name = username.strip()
|
||||||
|
if not name:
|
||||||
|
return {"ok": False, "error": "请输入用户名"}
|
||||||
|
items = []
|
||||||
|
for a in database.account_list():
|
||||||
|
if a["username"] != name:
|
||||||
|
continue
|
||||||
|
with RUNS_LOCK:
|
||||||
|
run = dict(RUNS.get(a["id"]) or {})
|
||||||
|
pf = registry.get(a["platform"])
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"username": a["username"],
|
||||||
|
"user": a["user"],
|
||||||
|
"platform": pf.label if pf else a["platform"],
|
||||||
|
"status": a["status"],
|
||||||
|
"loginAt": a["loginAt"],
|
||||||
|
"courses": [{k: c.get(k) for k in _COURSE_FIELDS} for c in a["courses"]],
|
||||||
|
"run": {k: run[k] for k in _RUN_FIELDS if k in run},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"ok": True, "accounts": items}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""系统设置接口(大模型 / 学习方式 / 故障推送)。"""
|
||||||
|
from study_tool import notify
|
||||||
|
from study_tool.webapp import app
|
||||||
|
|
||||||
|
from study_tool.config.settings import load_settings, save_settings
|
||||||
|
from study_tool.models import LlmCfgIn
|
||||||
|
from study_tool.runtime import log
|
||||||
|
|
||||||
|
|
||||||
|
def _mask(value):
|
||||||
|
"""密钥只回显前后几位,前端不回显明文。"""
|
||||||
|
value = value or ""
|
||||||
|
if len(value) > 10:
|
||||||
|
return value[:4] + "…" + value[-4:]
|
||||||
|
return "已设置" if value else ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/settings")
|
||||||
|
def api_get_settings():
|
||||||
|
cfg = load_settings()
|
||||||
|
llm = cfg["llm"]
|
||||||
|
key = llm.get("api_key") or ""
|
||||||
|
out = dict(llm)
|
||||||
|
out["api_key"] = ""
|
||||||
|
out["has_key"] = bool(key)
|
||||||
|
out["key_hint"] = (key[:4] + "…" + key[-4:]) if len(key) > 10 else ("已设置" if key else "")
|
||||||
|
nt = dict(cfg["notify"])
|
||||||
|
secret = nt.get("app_secret") or ""
|
||||||
|
nt["app_secret"] = ""
|
||||||
|
nt["has_secret"] = bool(secret)
|
||||||
|
nt["secret_hint"] = _mask(secret)
|
||||||
|
return {"ok": True, "llm": out, "study": cfg["study"], "notify": nt}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/settings")
|
||||||
|
def api_set_settings(b: LlmCfgIn):
|
||||||
|
cur = load_settings()
|
||||||
|
llm = cur["llm"]
|
||||||
|
if b.base_url.strip():
|
||||||
|
llm["base_url"] = b.base_url.strip()
|
||||||
|
if b.clear_api_key: # 显式清空(前端「清空」按钮),留空仍然表示保留
|
||||||
|
llm["api_key"] = ""
|
||||||
|
elif b.api_key.strip(): # 留空 = 保留原 key,前端不回显明文
|
||||||
|
llm["api_key"] = b.api_key.strip()
|
||||||
|
if b.model.strip():
|
||||||
|
llm["model"] = b.model.strip()
|
||||||
|
if b.temperature > 0: # 0 = 不改,避免前端留空把温度清成 0
|
||||||
|
llm["temperature"] = max(0.0, min(2.0, float(b.temperature)))
|
||||||
|
llm["timeout"] = max(10, min(600, int(b.timeout or 120)))
|
||||||
|
llm["prompt"] = b.prompt or ""
|
||||||
|
if b.study_mode in ("fast", "normal"): # 空串 = 不改
|
||||||
|
cur["study"]["mode"] = b.study_mode
|
||||||
|
if b.study_parallel: # 0 = 不改
|
||||||
|
cur["study"]["parallel"] = max(1, min(int(b.study_parallel), 5))
|
||||||
|
if b.notify is not None:
|
||||||
|
nt = cur["notify"]
|
||||||
|
nb = b.notify
|
||||||
|
if nb.base_url.strip():
|
||||||
|
nt["base_url"] = nb.base_url.strip()
|
||||||
|
if nb.app_key.strip():
|
||||||
|
nt["app_key"] = nb.app_key.strip()
|
||||||
|
if nb.clear_app_secret: # 显式清空(前端「清空」按钮)
|
||||||
|
nt["app_secret"] = ""
|
||||||
|
elif nb.app_secret.strip(): # 留空 = 保留原密钥
|
||||||
|
nt["app_secret"] = nb.app_secret.strip()
|
||||||
|
nt["channel_name"] = nb.channel_name.strip()
|
||||||
|
for flag in ("enabled", "on_error", "on_login", "on_progress", "on_retry"):
|
||||||
|
value = getattr(nb, flag)
|
||||||
|
if value is not None:
|
||||||
|
nt[flag] = bool(value)
|
||||||
|
if nb.retry_threshold: # 0 = 不改
|
||||||
|
nt["retry_threshold"] = max(2, min(int(nb.retry_threshold), 10))
|
||||||
|
if nb.cooldown_min:
|
||||||
|
nt["cooldown_min"] = max(1, min(int(nb.cooldown_min), 1440))
|
||||||
|
save_settings(cur)
|
||||||
|
log("system", "设置已更新:模型 %s(%s),学习方式 %s/%s 路,故障推送 %s" % (
|
||||||
|
cur["llm"]["model"], cur["llm"]["base_url"],
|
||||||
|
cur["study"]["mode"], cur["study"]["parallel"],
|
||||||
|
"开" if cur["notify"]["enabled"] else "关"))
|
||||||
|
return {"ok": True, "study": cur["study"]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/notify/test")
|
||||||
|
def api_notify_test():
|
||||||
|
"""测试推送:走当前启用的渠道插件,直接发一条。"""
|
||||||
|
ok, message = notify.send(
|
||||||
|
"【测试】学习控制台推送",
|
||||||
|
"这是一条测试推送。\n收到说明 base_url / app_key / app_secret / channel_name 都配好了。",
|
||||||
|
{"kind": "test", "status": "success"},
|
||||||
|
)
|
||||||
|
log("system", "测试推送%s:%s" % ("成功" if ok else "失败", message), level="info" if ok else "warn")
|
||||||
|
return {"ok": ok, "message": message}
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
日志只有一个出口 log():
|
日志只有一个出口 log():
|
||||||
* 控制台:带上时间/级别/key,docker compose logs 也能看
|
* 控制台:带上时间/级别/key,docker compose logs 也能看
|
||||||
* 内存:RUNS[key]["log"],/api/log 供前端轮询
|
* 内存:RUNS[key]["log"],/api/log 供前端轮询
|
||||||
* SQLite:store.run_log_add,/api/log/history 重启后还能查
|
* SQLite:database.run_log_add,/api/log/history 重启后还能查
|
||||||
新代码不要再自己 print(),也不要直接写 run_log 表。
|
新代码不要再自己 print(),也不要直接写 run_log 表。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from study_tool import store
|
from study_tool import database
|
||||||
|
|
||||||
RUNS = {}
|
RUNS = {}
|
||||||
RUNS_LOCK = threading.Lock()
|
RUNS_LOCK = threading.Lock()
|
||||||
@@ -34,7 +35,7 @@ def log(key, msg, level="info", persist=True):
|
|||||||
st["updated"] = time.time()
|
st["updated"] = time.time()
|
||||||
if persist:
|
if persist:
|
||||||
try:
|
try:
|
||||||
store.run_log_add(key, line)
|
database.run_log_add(key, line)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return line
|
return line
|
||||||
@@ -53,6 +54,4 @@ def set_state(key, **kw):
|
|||||||
st.pop(stale, None)
|
st.pop(stale, None)
|
||||||
st.update(kw)
|
st.update(kw)
|
||||||
st["updated"] = time.time()
|
st["updated"] = time.time()
|
||||||
store.run_state_set(key, RUNS.get(key) or {})
|
database.run_state_set(key, RUNS.get(key) or {})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
"""系统设置(大模型 + 学习方式)读写。"""
|
|
||||||
from study_tool import store
|
|
||||||
from study_tool.webapp import app
|
|
||||||
|
|
||||||
from study_tool.config import *
|
|
||||||
from study_tool.models import *
|
|
||||||
from study_tool.runtime import log
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ settings
|
|
||||||
|
|
||||||
SETTINGS_NAME = "llm"
|
|
||||||
DEFAULT_LLM = {
|
|
||||||
"base_url": "https://api.openai.com/v1",
|
|
||||||
"api_key": "",
|
|
||||||
"model": "gpt-4o-mini",
|
|
||||||
"temperature": 0.2,
|
|
||||||
"timeout": 120,
|
|
||||||
"prompt": "",
|
|
||||||
}
|
|
||||||
# 学习方式:fast = 只调 study/log 接口,不开浏览器;normal = 真播视频(可多路并发)
|
|
||||||
DEFAULT_STUDY = {
|
|
||||||
"mode": "fast",
|
|
||||||
"parallel": 2,
|
|
||||||
}
|
|
||||||
DEFAULT_ANSWER_PROMPT = (
|
|
||||||
"你是考试答题助手。根据题干和选项判断正确答案。\n"
|
|
||||||
"单选题返回一个选项字母;多选题返回多个字母,用 # 连接(如 A#C);判断题只返回 A 或 B(A=正确,B=错误)。\n"
|
|
||||||
"只输出 JSON:{\"answers\":{\"题号\":\"答案\"}},不要输出解释或多余文字。"
|
|
||||||
)
|
|
||||||
TYPE_LABEL = {"01": "单选", "02": "多选", "03": "判断", "04": "问答"}
|
|
||||||
|
|
||||||
|
|
||||||
def load_settings():
|
|
||||||
data = store.get_setting(SETTINGS_NAME) or {}
|
|
||||||
llm = dict(DEFAULT_LLM)
|
|
||||||
study = dict(DEFAULT_STUDY)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
raw = data.get("llm")
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
llm.update({k: v for k, v in raw.items() if k in DEFAULT_LLM and v is not None})
|
|
||||||
st = data.get("study")
|
|
||||||
if isinstance(st, dict):
|
|
||||||
study.update({k: v for k, v in st.items() if v is not None})
|
|
||||||
study["mode"] = study.get("mode") if study.get("mode") in ("fast", "normal") else "fast"
|
|
||||||
try:
|
|
||||||
study["parallel"] = max(1, min(int(study.get("parallel") or 2), 5))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
study["parallel"] = 2
|
|
||||||
return {"llm": llm, "study": study}
|
|
||||||
|
|
||||||
|
|
||||||
def save_settings(data):
|
|
||||||
store.set_setting(SETTINGS_NAME, {"llm": data.get("llm") or {},
|
|
||||||
"study": data.get("study") or {}})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/settings")
|
|
||||||
def api_get_settings():
|
|
||||||
cfg = load_settings()
|
|
||||||
llm = cfg["llm"]
|
|
||||||
key = llm.get("api_key") or ""
|
|
||||||
out = dict(llm)
|
|
||||||
out["api_key"] = ""
|
|
||||||
out["has_key"] = bool(key)
|
|
||||||
out["key_hint"] = (key[:4] + "…" + key[-4:]) if len(key) > 10 else ("已设置" if key else "")
|
|
||||||
return {"ok": True, "llm": out, "study": cfg["study"]}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/settings")
|
|
||||||
def api_set_settings(b: LlmCfgIn):
|
|
||||||
cur = load_settings()
|
|
||||||
llm = cur["llm"]
|
|
||||||
if b.base_url.strip():
|
|
||||||
llm["base_url"] = b.base_url.strip()
|
|
||||||
if b.api_key.strip(): # 留空 = 保留原 key,前端不回显明文
|
|
||||||
llm["api_key"] = b.api_key.strip()
|
|
||||||
if b.model.strip():
|
|
||||||
llm["model"] = b.model.strip()
|
|
||||||
llm["temperature"] = max(0.0, min(2.0, float(b.temperature or 0)))
|
|
||||||
llm["timeout"] = max(10, min(600, int(b.timeout or 120)))
|
|
||||||
llm["prompt"] = b.prompt or ""
|
|
||||||
if b.study_mode in ("fast", "normal"): # 空串 = 不改
|
|
||||||
cur["study"]["mode"] = b.study_mode
|
|
||||||
if b.study_parallel: # 0 = 不改
|
|
||||||
cur["study"]["parallel"] = max(1, min(int(b.study_parallel), 5))
|
|
||||||
save_settings(cur)
|
|
||||||
log("system", "设置已更新:模型 %s(%s),学习方式 %s/%s 路" % (
|
|
||||||
cur["llm"]["model"], cur["llm"]["base_url"],
|
|
||||||
cur["study"]["mode"], cur["study"]["parallel"]))
|
|
||||||
return {"ok": True, "study": cur["study"]}
|
|
||||||
@@ -1,514 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""SQLite3 存储层 —— 账号 / 设置 / 登录记录 / 运行日志 / 运行状态 / 缓存 / 页面快照
|
|
||||||
|
|
||||||
为什么用 SQLite 而不是散落的文件
|
|
||||||
* 远程管理:Docker 里只挂一个 data 卷,所有状态都在这一个文件里,备份 = 拷一个文件
|
|
||||||
* 并发:running 时有几十个线程写日志,文件追加要自己加锁,SQLite WAL 直接解决
|
|
||||||
* 查询:/api/log/history 能按账号取最近 N 行,不需要自己维护文件轮转
|
|
||||||
|
|
||||||
表
|
|
||||||
settings 系统配置(name -> JSON),目前只有 llm
|
|
||||||
kv 通用缓存(任意 key -> JSON),例如 StudyClient 试出来的接口前缀
|
|
||||||
accounts 账号 / 密码 / 会话 cookie / 课程缓存 / 勾选 / 开关
|
|
||||||
login_log 每次登录尝试(平台原话)
|
|
||||||
run_log 每个账号(key)的运行日志
|
|
||||||
run_state 运行状态快照,进程重启后 /api/runs 还能看到上次跑到哪
|
|
||||||
snapshots 课程页原始 HTML(站点改版时定位选择器用)
|
|
||||||
|
|
||||||
并发模型
|
|
||||||
* WAL + busy_timeout=15s:读写互不阻塞,写锁竞争自动重试
|
|
||||||
* 每个线程一条连接(threading.local):ThreadPoolExecutor 里多线程写日志不会串
|
|
||||||
* 本进程内的写操作再用一把 RLock 串起来,避免多线程 commit 抖动(读不加锁)
|
|
||||||
* 首次调用自动建库建表,并把旧的 data/settings.json、data/logs/*.log 导入后改名
|
|
||||||
(原文件保留为 *.imported,回滚旧版本时改名回去即可)
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
# 包在 src/study_tool 下,数据目录仍是仓库根目录的 data/
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
|
||||||
DATA_DIR = os.environ.get("DATA_DIR") or os.path.join(ROOT, "data")
|
|
||||||
DB_PATH = os.environ.get("DB_PATH") or os.path.join(DATA_DIR, "study.db")
|
|
||||||
|
|
||||||
# 旧的文件式存储位置(只在首次启动时导入一次)
|
|
||||||
LEGACY_SETTINGS = os.path.join(DATA_DIR, "settings.json")
|
|
||||||
LEGACY_LOGDIR = os.path.join(DATA_DIR, "logs")
|
|
||||||
|
|
||||||
# 每个账号保留的日志行数,超出就丢最旧的(远程长期挂机,不能无限涨)
|
|
||||||
RUN_LOG_KEEP = int(os.environ.get("RUN_LOG_KEEP", "2000") or 2000)
|
|
||||||
# 页面快照保留条数
|
|
||||||
SNAPSHOT_KEEP = int(os.environ.get("SNAPSHOT_KEEP", "200") or 200)
|
|
||||||
|
|
||||||
SCHEMA = """
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
|
||||||
name TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated REAL NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS kv (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT,
|
|
||||||
updated REAL NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS accounts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
username TEXT,
|
|
||||||
platform TEXT DEFAULT 'guanyixue',
|
|
||||||
password TEXT,
|
|
||||||
note TEXT,
|
|
||||||
user TEXT,
|
|
||||||
cookies TEXT,
|
|
||||||
courses TEXT,
|
|
||||||
selected TEXT,
|
|
||||||
status TEXT,
|
|
||||||
last_error TEXT,
|
|
||||||
last_error_at REAL,
|
|
||||||
opt_quiz INTEGER DEFAULT 0,
|
|
||||||
opt_feedback INTEGER DEFAULT 1,
|
|
||||||
login_at REAL,
|
|
||||||
created REAL,
|
|
||||||
updated REAL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS login_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
ts TEXT,
|
|
||||||
username TEXT,
|
|
||||||
ok INTEGER,
|
|
||||||
code TEXT,
|
|
||||||
elapsed REAL,
|
|
||||||
stage TEXT,
|
|
||||||
error TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS run_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
key TEXT NOT NULL,
|
|
||||||
ts REAL,
|
|
||||||
line TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_run_log_key ON run_log(key, id);
|
|
||||||
CREATE TABLE IF NOT EXISTS run_state (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
state TEXT,
|
|
||||||
updated REAL
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS snapshots (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
plan_id TEXT,
|
|
||||||
project_id TEXT,
|
|
||||||
url TEXT,
|
|
||||||
html TEXT,
|
|
||||||
created REAL
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
|
|
||||||
_local = threading.local()
|
|
||||||
_lock = threading.RLock() # 只保护“写”和一次性初始化
|
|
||||||
_ready = False
|
|
||||||
_write_count = {}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ 基础
|
|
||||||
|
|
||||||
def dumps(o):
|
|
||||||
return json.dumps(o, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
||||||
|
|
||||||
|
|
||||||
def loads(s, default=None):
|
|
||||||
if not s:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return json.loads(s)
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def conn():
|
|
||||||
"""取当前线程的连接(没有就建一条),顺带保证表结构已建好。"""
|
|
||||||
global _ready
|
|
||||||
c = getattr(_local, "conn", None)
|
|
||||||
if c is None:
|
|
||||||
os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True)
|
|
||||||
# check_same_thread=False:连接跟着线程走,线程退出后由 GC 关闭,
|
|
||||||
# 关闭动作可能发生在别的线程里,默认的 same-thread 检查会直接报错。
|
|
||||||
c = sqlite3.connect(DB_PATH, timeout=15, check_same_thread=False)
|
|
||||||
c.row_factory = sqlite3.Row
|
|
||||||
c.execute("PRAGMA journal_mode=WAL")
|
|
||||||
c.execute("PRAGMA synchronous=NORMAL")
|
|
||||||
c.execute("PRAGMA busy_timeout=15000")
|
|
||||||
_local.conn = c
|
|
||||||
if not _ready:
|
|
||||||
with _lock:
|
|
||||||
if not _ready:
|
|
||||||
c.executescript(SCHEMA)
|
|
||||||
_ensure_columns(c)
|
|
||||||
c.commit()
|
|
||||||
_migrate(c)
|
|
||||||
_ready = True
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
def query(sql, params=()):
|
|
||||||
return conn().execute(sql, params).fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
def query_one(sql, params=()):
|
|
||||||
rows = query(sql, params)
|
|
||||||
return rows[0] if rows else None
|
|
||||||
|
|
||||||
|
|
||||||
def write(sql, params=()):
|
|
||||||
with _lock:
|
|
||||||
c = conn()
|
|
||||||
cur = c.execute(sql, params)
|
|
||||||
c.commit()
|
|
||||||
return cur
|
|
||||||
|
|
||||||
|
|
||||||
def init():
|
|
||||||
"""显式初始化(启动时调用一次,确保库文件已建好)。"""
|
|
||||||
conn()
|
|
||||||
|
|
||||||
|
|
||||||
def db_info():
|
|
||||||
try:
|
|
||||||
size = os.path.getsize(DB_PATH) if os.path.exists(DB_PATH) else 0
|
|
||||||
except Exception:
|
|
||||||
size = 0
|
|
||||||
counts = {}
|
|
||||||
for t in ("accounts", "run_log", "login_log", "snapshots", "run_state", "kv", "settings"):
|
|
||||||
try:
|
|
||||||
counts[t] = (query_one("SELECT COUNT(*) AS n FROM %s" % t) or {"n": 0})["n"]
|
|
||||||
except Exception:
|
|
||||||
counts[t] = -1
|
|
||||||
return {"path": DB_PATH, "size": size, "counts": counts}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ 迁移
|
|
||||||
|
|
||||||
def _ensure_columns(c):
|
|
||||||
"""SQLite 没有 ADD COLUMN IF NOT EXISTS;老库缺字段时补上。"""
|
|
||||||
cols = {r["name"] for r in c.execute("PRAGMA table_info(accounts)")}
|
|
||||||
if "platform" not in cols:
|
|
||||||
c.execute("ALTER TABLE accounts ADD COLUMN platform TEXT DEFAULT 'guanyixue'")
|
|
||||||
|
|
||||||
|
|
||||||
def _migrate(c):
|
|
||||||
"""把旧的文件式存储导入 SQLite;成功后把原文件改名成 *.imported。"""
|
|
||||||
# 1) data/settings.json -> settings
|
|
||||||
if os.path.exists(LEGACY_SETTINGS):
|
|
||||||
try:
|
|
||||||
with open(LEGACY_SETTINGS, encoding="utf-8") as f:
|
|
||||||
data = json.load(f) or {}
|
|
||||||
if isinstance(data.get("llm"), dict) and get_setting("llm") is None:
|
|
||||||
set_setting("llm", data["llm"])
|
|
||||||
os.replace(LEGACY_SETTINGS, LEGACY_SETTINGS + ".imported")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 2) data/logs/login.log -> login_log
|
|
||||||
if os.path.isdir(LEGACY_LOGDIR):
|
|
||||||
for name in sorted(os.listdir(LEGACY_LOGDIR)):
|
|
||||||
if not name.endswith(".log"):
|
|
||||||
continue
|
|
||||||
path = os.path.join(LEGACY_LOGDIR, name)
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8", errors="replace") as f:
|
|
||||||
lines = [x.rstrip("\n") for x in f if x.strip()]
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if name == "login.log":
|
|
||||||
for ln in lines:
|
|
||||||
p = ln.split("\t")
|
|
||||||
if len(p) < 4:
|
|
||||||
continue
|
|
||||||
el = re.sub(r"[^0-9.]", "", p[4]) if len(p) > 4 else "0"
|
|
||||||
login_log_add(p[1], p[2] == "OK", p[3],
|
|
||||||
float(el or 0), p[5] if len(p) > 5 else "",
|
|
||||||
p[6] if len(p) > 6 else "", ts=p[0])
|
|
||||||
else:
|
|
||||||
# data/logs/<key>.log -> run_log(key 是文件名去掉 .log)
|
|
||||||
key = name[:-4]
|
|
||||||
for ln in lines:
|
|
||||||
run_log_add(key, ln, ts=None)
|
|
||||||
try:
|
|
||||||
os.replace(path, path + ".imported")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 3) data/study_session.json(study_video.py 的会话)-> kv
|
|
||||||
legacy_session = os.path.join(DATA_DIR, "study_session.json")
|
|
||||||
if os.path.exists(legacy_session) and kv_get("study_session") is None:
|
|
||||||
try:
|
|
||||||
with open(legacy_session, encoding="utf-8") as f:
|
|
||||||
kv_set("study_session", json.load(f))
|
|
||||||
os.replace(legacy_session, legacy_session + ".imported")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ settings
|
|
||||||
|
|
||||||
def get_setting(name, default=None):
|
|
||||||
r = query_one("SELECT value FROM settings WHERE name=?", (name,))
|
|
||||||
return loads(r["value"], default) if r else default
|
|
||||||
|
|
||||||
|
|
||||||
def set_setting(name, value):
|
|
||||||
write("INSERT INTO settings(name, value, updated) VALUES(?,?,?) "
|
|
||||||
"ON CONFLICT(name) DO UPDATE SET value=excluded.value, updated=excluded.updated",
|
|
||||||
(name, dumps(value), time.time()))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ kv 缓存
|
|
||||||
|
|
||||||
def kv_get(key, default=None):
|
|
||||||
r = query_one("SELECT value FROM kv WHERE key=?", (key,))
|
|
||||||
return loads(r["value"], default) if r else default
|
|
||||||
|
|
||||||
|
|
||||||
def kv_set(key, value):
|
|
||||||
write("INSERT INTO kv(key, value, updated) VALUES(?,?,?) "
|
|
||||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated=excluded.updated",
|
|
||||||
(key, dumps(value), time.time()))
|
|
||||||
|
|
||||||
|
|
||||||
def kv_del(key):
|
|
||||||
write("DELETE FROM kv WHERE key=?", (key,))
|
|
||||||
|
|
||||||
|
|
||||||
def kv_all():
|
|
||||||
return {r["key"]: loads(r["value"]) for r in query("SELECT key, value FROM kv")}
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ accounts
|
|
||||||
|
|
||||||
def _acc_row(r):
|
|
||||||
"""DB 行 -> 前端用的记录(不含密码)。"""
|
|
||||||
return {
|
|
||||||
"id": r["id"],
|
|
||||||
"username": r["username"] or "",
|
|
||||||
"platform": r["platform"] or "guanyixue",
|
|
||||||
"note": r["note"] or "",
|
|
||||||
"user": r["user"] or "",
|
|
||||||
"cookies": loads(r["cookies"], []) or [],
|
|
||||||
"courses": loads(r["courses"], []) or [],
|
|
||||||
"selected": loads(r["selected"], []) or [],
|
|
||||||
"status": r["status"] or "idle",
|
|
||||||
"lastError": r["last_error"] or "",
|
|
||||||
"lastErrorAt": r["last_error_at"] or 0,
|
|
||||||
"optQuiz": bool(r["opt_quiz"]),
|
|
||||||
"optFeedback": bool(r["opt_feedback"]),
|
|
||||||
"loginAt": r["login_at"] or 0,
|
|
||||||
"created": r["created"] or 0,
|
|
||||||
"updated": r["updated"] or 0,
|
|
||||||
"hasPassword": bool(r["password"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def account_list():
|
|
||||||
return [_acc_row(r) for r in query("SELECT * FROM accounts ORDER BY created, id")]
|
|
||||||
|
|
||||||
|
|
||||||
def account_get(account_id):
|
|
||||||
if not account_id:
|
|
||||||
return None
|
|
||||||
r = query_one("SELECT * FROM accounts WHERE id=?", (str(account_id),))
|
|
||||||
return _acc_row(r) if r else None
|
|
||||||
|
|
||||||
|
|
||||||
def account_password(account_id):
|
|
||||||
if not account_id:
|
|
||||||
return ""
|
|
||||||
r = query_one("SELECT password FROM accounts WHERE id=?", (str(account_id),))
|
|
||||||
return (r["password"] or "") if r else ""
|
|
||||||
|
|
||||||
|
|
||||||
def account_exists(account_id):
|
|
||||||
return bool(account_id) and query_one("SELECT 1 FROM accounts WHERE id=?",
|
|
||||||
(str(account_id),)) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def account_upsert(rec):
|
|
||||||
"""新增/更新账号。rec['password'] 为空(None)时保留库里原有密码。
|
|
||||||
|
|
||||||
明文密码要留在库里:会话失效时后台要能自己重登,不能等用户回来输密码。
|
|
||||||
"""
|
|
||||||
now = time.time()
|
|
||||||
aid = str(rec.get("id") or "").strip()
|
|
||||||
if not aid:
|
|
||||||
aid = "a%s%04d" % (int(now * 1000), int(time.time() * 10000) % 10000)
|
|
||||||
pwd = rec.get("password")
|
|
||||||
if pwd is not None:
|
|
||||||
pwd = str(pwd)
|
|
||||||
platform = (rec.get("platform") or "").strip() or None
|
|
||||||
selected = loads(dumps(rec.get("selected") or []), []) or []
|
|
||||||
write(
|
|
||||||
"""INSERT INTO accounts
|
|
||||||
(id, username, platform, password, note, user, cookies, courses, selected, status,
|
|
||||||
last_error, last_error_at, opt_quiz, opt_feedback, login_at, created, updated)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
username = excluded.username,
|
|
||||||
platform = COALESCE(excluded.platform, accounts.platform),
|
|
||||||
password = COALESCE(excluded.password, accounts.password),
|
|
||||||
note = excluded.note,
|
|
||||||
user = excluded.user,
|
|
||||||
cookies = excluded.cookies,
|
|
||||||
courses = excluded.courses,
|
|
||||||
selected = excluded.selected,
|
|
||||||
status = excluded.status,
|
|
||||||
last_error = excluded.last_error,
|
|
||||||
last_error_at = excluded.last_error_at,
|
|
||||||
opt_quiz = excluded.opt_quiz,
|
|
||||||
opt_feedback = excluded.opt_feedback,
|
|
||||||
login_at = excluded.login_at,
|
|
||||||
updated = excluded.updated""",
|
|
||||||
(aid, rec.get("username") or "", platform, pwd, rec.get("note") or "", rec.get("user") or "",
|
|
||||||
dumps(rec.get("cookies") or []), dumps(rec.get("courses") or []), dumps(selected),
|
|
||||||
rec.get("status") or "idle", rec.get("lastError") or "",
|
|
||||||
float(rec.get("lastErrorAt") or 0),
|
|
||||||
1 if rec.get("optQuiz") is True else 0,
|
|
||||||
0 if rec.get("optFeedback") is False else 1,
|
|
||||||
float(rec.get("loginAt") or 0),
|
|
||||||
float(rec.get("created") or now), now))
|
|
||||||
return aid
|
|
||||||
|
|
||||||
|
|
||||||
def account_save_login(account_id, user, cookies, courses):
|
|
||||||
"""登录成功后把新的会话写回账号(服务端自己记,不依赖前端回传)。"""
|
|
||||||
rec = account_get(account_id)
|
|
||||||
if not rec:
|
|
||||||
return
|
|
||||||
courses = courses or []
|
|
||||||
plans = {c.get("plan_id") for c in courses}
|
|
||||||
rec.update({
|
|
||||||
"user": user or rec.get("user") or "",
|
|
||||||
"cookies": cookies or [],
|
|
||||||
"courses": courses,
|
|
||||||
"selected": [p for p in (rec.get("selected") or []) if p in plans],
|
|
||||||
"status": "logged_in",
|
|
||||||
"lastError": "",
|
|
||||||
"lastErrorAt": 0,
|
|
||||||
"loginAt": time.time(),
|
|
||||||
})
|
|
||||||
rec.pop("hasPassword", None)
|
|
||||||
account_upsert(rec)
|
|
||||||
|
|
||||||
|
|
||||||
def account_delete(account_id):
|
|
||||||
"""删账号,连同它的运行状态、课程日志、登录记录一起删。
|
|
||||||
|
|
||||||
登录日志的 key 和 login_log.username 记的是用户名(手机号),不是账号 id,
|
|
||||||
两边都要清,否则删完账号旧日志还留在页面上。
|
|
||||||
"""
|
|
||||||
aid = str(account_id or "")
|
|
||||||
acc = account_get(aid) or {}
|
|
||||||
uname = str(acc.get("username") or "")
|
|
||||||
write("DELETE FROM accounts WHERE id=?", (aid,))
|
|
||||||
write("DELETE FROM run_state WHERE key=?", (aid,))
|
|
||||||
for key in (aid, uname):
|
|
||||||
if key:
|
|
||||||
write("DELETE FROM run_log WHERE key=?", (key,))
|
|
||||||
write("DELETE FROM login_log WHERE username=?", (key,))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ 登录记录
|
|
||||||
|
|
||||||
def login_log_add(username, ok, code, elapsed, stage="", error="", ts=None):
|
|
||||||
write("INSERT INTO login_log(ts, username, ok, code, elapsed, stage, error) VALUES(?,?,?,?,?,?,?)",
|
|
||||||
(ts or time.strftime("%Y-%m-%d %H:%M:%S"), username, 1 if ok else 0,
|
|
||||||
str(code or ""), float(elapsed or 0), str(stage or ""),
|
|
||||||
str(error or "").replace("\n", " ")[:500]))
|
|
||||||
|
|
||||||
|
|
||||||
def login_log_tail(n=40):
|
|
||||||
"""返回旧版文本行格式(ts\\t用户名\\tOK\\tcode\\t耗时\\tstage\\t错误),前端不用改。"""
|
|
||||||
n = max(1, min(int(n or 40), 500))
|
|
||||||
rows = query("SELECT * FROM login_log ORDER BY id DESC LIMIT ?", (n,))
|
|
||||||
out = []
|
|
||||||
for r in reversed(rows):
|
|
||||||
out.append("%s\t%s\t%s\t%s\t%.1fs\t%s\t%s" % (
|
|
||||||
r["ts"], r["username"], "OK" if r["ok"] else "FAIL", r["code"],
|
|
||||||
r["elapsed"] or 0, r["stage"] or "", r["error"] or ""))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ 运行日志
|
|
||||||
|
|
||||||
def run_log_add(key, line, ts=None):
|
|
||||||
key = str(key or "")
|
|
||||||
write("INSERT INTO run_log(key, ts, line) VALUES(?,?,?)", (key, ts or time.time(), line))
|
|
||||||
n = _write_count.get("run_log", 0) + 1
|
|
||||||
_write_count["run_log"] = n
|
|
||||||
if n % 200 == 0: # 每 200 行清一次,避免每行都跑一遍 DELETE
|
|
||||||
write("""DELETE FROM run_log WHERE key=? AND id NOT IN
|
|
||||||
(SELECT id FROM run_log WHERE key=? ORDER BY id DESC LIMIT ?)""",
|
|
||||||
(key, key, RUN_LOG_KEEP))
|
|
||||||
|
|
||||||
|
|
||||||
def run_log_tail(key, n=200):
|
|
||||||
n = max(1, min(int(n or 200), RUN_LOG_KEEP))
|
|
||||||
rows = query("SELECT line FROM run_log WHERE key=? ORDER BY id DESC LIMIT ?", (str(key), n))
|
|
||||||
return [r["line"] for r in reversed(rows)]
|
|
||||||
|
|
||||||
|
|
||||||
def run_log_keys():
|
|
||||||
return [{"key": r["key"], "lines": r["n"],
|
|
||||||
"updated": r["ts"]} for r in query(
|
|
||||||
"SELECT key, COUNT(*) AS n, MAX(ts) AS ts FROM run_log GROUP BY key ORDER BY ts DESC")]
|
|
||||||
|
|
||||||
|
|
||||||
def run_log_clear(key):
|
|
||||||
write("DELETE FROM run_log WHERE key=?", (str(key),))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ 运行状态
|
|
||||||
|
|
||||||
def run_state_set(key, state):
|
|
||||||
st = dict(state or {})
|
|
||||||
st.pop("log", None) # 日志单独进 run_log 表,不在这里重复一份
|
|
||||||
write("INSERT INTO run_state(key, state, updated) VALUES(?,?,?) "
|
|
||||||
"ON CONFLICT(key) DO UPDATE SET state=excluded.state, updated=excluded.updated",
|
|
||||||
(str(key), dumps(st), time.time()))
|
|
||||||
|
|
||||||
|
|
||||||
def run_state_all():
|
|
||||||
out = {}
|
|
||||||
for r in query("SELECT key, state FROM run_state"):
|
|
||||||
st = loads(r["state"], None)
|
|
||||||
if isinstance(st, dict):
|
|
||||||
out[r["key"]] = st
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def run_state_delete(key):
|
|
||||||
write("DELETE FROM run_state WHERE key=?", (str(key or ""),))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ 页面快照
|
|
||||||
|
|
||||||
def snapshot_add(plan_id, project_id, url, html):
|
|
||||||
cur = write("INSERT INTO snapshots(plan_id, project_id, url, html, created) VALUES(?,?,?,?,?)",
|
|
||||||
(str(plan_id or ""), str(project_id or ""), url or "", html or "", time.time()))
|
|
||||||
sid = cur.lastrowid
|
|
||||||
write("""DELETE FROM snapshots WHERE id NOT IN
|
|
||||||
(SELECT id FROM snapshots ORDER BY id DESC LIMIT ?)""", (SNAPSHOT_KEEP,))
|
|
||||||
return sid
|
|
||||||
|
|
||||||
|
|
||||||
def snapshot_get(sid):
|
|
||||||
r = query_one("SELECT * FROM snapshots WHERE id=?", (int(sid),))
|
|
||||||
return dict(r) if r else None
|
|
||||||
|
|
||||||
|
|
||||||
def snapshot_list(limit=50):
|
|
||||||
limit = max(1, min(int(limit or 50), 500))
|
|
||||||
rows = query("SELECT id, plan_id, project_id, url, created, LENGTH(html) AS size "
|
|
||||||
"FROM snapshots ORDER BY id DESC LIMIT ?", (limit,))
|
|
||||||
return [dict(r) for r in rows]
|
|
||||||
@@ -3,8 +3,7 @@
|
|||||||
单独一个模块,路由模块从这里 `from study_tool.webapp import app` 注册接口,
|
单独一个模块,路由模块从这里 `from study_tool.webapp import app` 注册接口,
|
||||||
避免 app.py <-> 路由模块循环导入。
|
避免 app.py <-> 路由模块循环导入。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
app = FastAPI(title="多账号学习控制台")
|
app = FastAPI(title="学习控制台")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
<meta name="theme-color" content="#f6f8fc" />
|
<meta name="theme-color" content="#f6f8fc" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>莞易学 · 多账号学习控制台</title>
|
<title>莞易学 · 多账号学习控制台</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DsTLd63E.js"></script>
|
<script type="module" crossorigin src="/assets/index-w-5XKYB3.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BubgUKHl.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DW6yuFYE.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1380,6 +1380,15 @@ wheels = [
|
|||||||
{ url = "https://mirrors.aliyun.com/pypi/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d" },
|
{ url = "https://mirrors.aliyun.com/pypi/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.4"
|
||||||
|
source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
|
||||||
|
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.34.2"
|
version = "2.34.2"
|
||||||
@@ -1421,6 +1430,151 @@ wheels = [
|
|||||||
{ url = "https://mirrors.aliyun.com/pypi/packages/07/66/473f7ca0be0b06e38ee8c85ee2db184e3981bd769fac2e7c19c8a8865211/soupsieve-3.0.4-py3-none-any.whl", hash = "sha256:8412826614f9c632227067d0985aeb9f584bd15efc852955430e097a9fe7afbf" },
|
{ url = "https://mirrors.aliyun.com/pypi/packages/07/66/473f7ca0be0b06e38ee8c85ee2db184e3981bd769fac2e7c19c8a8865211/soupsieve-3.0.4-py3-none-any.whl", hash = "sha256:8412826614f9c632227067d0985aeb9f584bd15efc852955430e097a9fe7afbf" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlalchemy"
|
||||||
|
version = "2.0.55"
|
||||||
|
source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version < '3.11'",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a5/bd/08864ddfffbb8914ee13b0c887d84b4b13bc8dd8da3077c0dace5a87699c/sqlalchemy-2.0.55.tar.gz", hash = "sha256:ae40c563bc10a8d56f1fd1f39f069aeb5eb85ca26913b2314dd8d190139258fa" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/3a/df/221b08eb2c1f421624b92a6cb24fd606268bcf299b96bd73e3ad83951aa0/sqlalchemy-2.0.55-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87a95fd30cf979a2563c7e709c640feb64fafa6449add14eb7c465a950968c80" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/d7/9f/65b06d212a305ac2a1ad6aebf45087c41e36b9416d07c981adb7438d095c/sqlalchemy-2.0.55-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3528e49819bef71ab6b05d95b1fa33aef8b15b4341f4d5d7954d1e2bc7e0fbe0" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/4e/1f/c4661f84df99587a216fd2f11c00a19776c4d67428dc084db6dd745a4c23/sqlalchemy-2.0.55-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0772f7bb7cdade7041cd8bfa1bb63ea61eaa42710d395aa1163f36bd5baca222" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/db/89/904577f10af6699986950a8863ef01b55e8bde557d8d0661e8e404ee0fae/sqlalchemy-2.0.55-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3e27aef676431e5ede5de310a8c6a573831aa3bb2b5be7a5a45701702a63ff8f" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/af/43/1f5c76243c61353cc1a14ea5e7e38803040060fc8b17a3b754ca0c4137fc/sqlalchemy-2.0.55-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:480f28a834e861b6a883ff9ce7d4c6251f3019089114ec71a5cf0cb7a54d3d07" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/a5/b1/6dc6c9885c643e312dad31ef2a964668889e27ddb5f94d77913eb0e43980/sqlalchemy-2.0.55-cp310-cp310-win32.whl", hash = "sha256:8422fea3b881e57519a2018ef04b77344082f20c5155a5f73743b1ad2ae0fed7" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/62/3f/6af1d9b844a46bb9dd8af5333dcec1d6a98f0b4998af6c03bf976ff0e728/sqlalchemy-2.0.55-cp310-cp310-win_amd64.whl", hash = "sha256:222ed30a6e5c8de03c5d2b276f50fbdad37b98dd62d99fe5e02c085782a4c38d" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/69/38/9ef1805b70930b26eb4550565df3e6efe95789e0b6dfdb3fa3f595b161f5/sqlalchemy-2.0.55-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61c65103130a2a19fd2e8907b68969364a89f21e37b786abbab22628c8d47ade" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/be/88/dad219bff649716f2e66f9d3dce2dc9d39d3d92f0b0b8880888c2c632016/sqlalchemy-2.0.55-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48345541c66b2ad1d18220f2ff75ed6baec2b3f70a5c48b9b4aabee7b9eb1244" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/d1/fb/302558dcbf04cd4ff69d1c1cc3a44baf3f2e15480ba806d411d80fbdace8/sqlalchemy-2.0.55-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7251866982c2bb5a62d900bed7b4b88c1b09f9ff494b9dd152610cb5a99bb4d6" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/1c/ea/242f71d757c02bfea5d567befb2e08f7a11f60ad62c0899aa13c405e50df/sqlalchemy-2.0.55-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aaf3a5ac50bf1277240cc367e26f120302d5b72b8405418f2201e84e4f661d1" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/d4/83/7bbfe732cde5299a9a2a23a09f3b71d862c37ed514bdf366199b986a69d5/sqlalchemy-2.0.55-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bd770f8722f3f35d6fe363b04f379774a2840a4e4afdf0f4a6324cf2f6f3819" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/48/1a/07cb54adde537ec67c8d3d64fb097bafeada630687a918f9d98d2f68616f/sqlalchemy-2.0.55-cp311-cp311-win32.whl", hash = "sha256:1a4f7165c15749a803f0956da35905ec00836a096454ca84e13ac10487c58a11" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/d4/4c/10d8e14feb1ce8354049ffefe49cd133e1172c8e132d364ebc7fa946ee4c/sqlalchemy-2.0.55-cp311-cp311-win_amd64.whl", hash = "sha256:7a0fef540ea1e3e87e565f885ed1e88eef2379e5011fd4eee85cce24d057de75" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/46/40/10bd5b60b22e7df7f893734ecdad7da1cbe81c435e3b9dd021a647d3fe21/sqlalchemy-2.0.55-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2c37c286877ca04b577fb02779ed77dbf884539dcb4712507e25ca4d8d48a6ec" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/b1/71/7135c45e8177630f55d1695659282d30ea03ca8142a660494f7976fc1235/sqlalchemy-2.0.55-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae6058240996c16cccc19dbd948aade826de3854171f45e045219dc9e9ea75ef" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/df/86/e1c1521350fba22109fb6605b2a1f3e572d8e294239bb59c575b18fe45c2/sqlalchemy-2.0.55-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7694e736d63b476d81a0eae7b304d9b747a3f0a1b7dc10134655c4cccf5eb7" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/9c/ee/d89371b9a841bcf718b9ecaaa502bf425456332d3bff9035c26e2a367ecf/sqlalchemy-2.0.55-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:11d7e28319efa0a52d4601ce4498df7e9c4014a740d9f0eae1d8dd6432a4ace2" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/44/37/c5bf4a303eb5ae8d44cc8a311a8ab7f257af406b97e4d9b3bf0a41ec3f26/sqlalchemy-2.0.55-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cfa3afdaccf4ddb91de5dcffdc9298395b4a3c740fbae478b4491e96085051d2" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/fd/3c/a09912dce2bb0bf10dab3f4bf1b851822528a9e4d0a3eadfb7828bb6dab8/sqlalchemy-2.0.55-cp312-cp312-win32.whl", hash = "sha256:4ff08c8c2f701d2bcbe731833bfbb96195e3ce7a0e400e8b699f8e4a0020545f" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/19/10/0ec446c9ee7467f869c298e12a3c5ec0f47a6cd64b2b59764ab6f1287017/sqlalchemy-2.0.55-cp312-cp312-win_amd64.whl", hash = "sha256:a13820f47eda53d603f56feaadeab9787d0110b7fcbe6ab2ff2415bd3ea31c79" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/13/87/1df7cab6f95a0c7dcf7047ede785133a829bd6ba159ca913c88f85802776/sqlalchemy-2.0.55-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f9636a6d2b6d0d1256c2687a775a406755e4f3eb5ee69756230f986e17cbffca" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/03/96/068d27b2030a0a3a945f0c73e8fede03da1fead8915eb52cf894a90b609e/sqlalchemy-2.0.55-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:051a9a6226b65292140ef1f02f451081e64d53db993ac126b4652b250e46e2e7" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/0e/02/b5ad20127741ada13ba74b6c71388af376bab26149477717e44c917ba437/sqlalchemy-2.0.55-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:998f729f57381f8fdee8e402d2081da76c17a2501fa92c9046c24fdff581c729" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/22/b1/f7fef432ed50ae5dbc4d47d68741a441a6c9d92fd931c8f38cf91b808dd9/sqlalchemy-2.0.55-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5bb983541837b67c7e335bffec8a5b26213f714079878ac85876b629726dbe73" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/52/83/0dfc35486099c6eb858dbde068c0738ce79fdc3f75b092719384d0baa9e5/sqlalchemy-2.0.55-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5477d92886122549aa08a61eb639cc0f8e01ee603259d942009081c294c30c4c" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/30/26/6bef63a3a542a18757df9e518afd71b244581012a3cf9e9b1d07bdde0836/sqlalchemy-2.0.55-cp313-cp313-win32.whl", hash = "sha256:c67f99ca639d28ee6145dd8857ca32b7e51bc2a2acf7a8bca388807442ffd1cc" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/cf/b0/3e3f51e94de30eaec3d0d0d75dfe341cea03bae98b291e9bb22ee580675a/sqlalchemy-2.0.55-cp313-cp313-win_amd64.whl", hash = "sha256:2ad873064b0267f7a30779eaf8c025bf25aa2214f9755d993bb185892ae751b7" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/3e/c0/c8e0079078d70e4d8a917e8903d547534f32f5e3d1be01b93ac8202c64b2/sqlalchemy-2.0.55-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:94b206f4e65f00cc5b4c72ddb29dfc864d19a1a1b059a83d8d30253df93768c5" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/ee/47/1f5fac19d335bc6639d9d2f7bd58d32dc3c6198167115d372fb935c54ced/sqlalchemy-2.0.55-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e426a7f326c3291a9669e577af54eecaa9585406fb4de9828ef258cd9c3cad7" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/4e/55/332d70b9321e2acd01ee9f669a6577321d889b697fd582384e54547c1f52/sqlalchemy-2.0.55-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:637c7da1c75626a398196bfe82ea93dcf8f3ab8a6613a07727fe48496d4c8562" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/39/28/f642cd58a1c35d883640cdb1e0117996c44ad1aff6f875616d44d6922011/sqlalchemy-2.0.55-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52851f3cad94cb527d17d9e0aa6b58262218c0227aef25722269d2e8784a871c" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/df/f9/cb7540670a2ded5100cfeac4f6c28fd88668bef5ff742b52ec3760bc7c10/sqlalchemy-2.0.55-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:01c1a72f1f2f5c107eb8f1c12a08d56527a393b191b2195edbbaaa028bcde04b" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/ae/15/062537d6f1e83ace4cd88f52fc54cf076e23dcb132e1bdc13052978b056b/sqlalchemy-2.0.55-cp314-cp314-win32.whl", hash = "sha256:7693adde7eb888bba2ef76cca7b2b3768134562db7c261a5933968059aa56785" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/53/f7/06b0b915351a3fe6d36ef1a0f9ab65980c3879187b48b9cdeea74b5d2d36/sqlalchemy-2.0.55-cp314-cp314-win_amd64.whl", hash = "sha256:6f19c4032bb8419b93d82c823e9e540060d1fc1ed1e1909ec4676004fec78a51" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/ad/4f/548733a09d320a997ec93c07307c4f3122da3e9ad705fec0f92b3044d954/sqlalchemy-2.0.55-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b6453a730ac76d2a9c1c4294537a9e9d2faad1e88c34ae5f8b89b369e4bb09e" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/17/58/ec6ccd1f3c530969b7e29757a8d91742b8823540df35f37509f68b37af37/sqlalchemy-2.0.55-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96fa91e0d94295159b7e505b39f8d98343846845958f63c72a194787943ce2ea" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/08/a1/bd126860c09a161409332c25767381863cf6315a16bbede769d3a0ad98ca/sqlalchemy-2.0.55-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b6a2f202291695b28253ca1ed8fdc0aca71e976ca785696b3b16f9fd048d383" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/ca/da/f06a8c6ea6285ba7d6c0bc9c6105e3f37c3dbbcd6296f6713bb0e58af2e6/sqlalchemy-2.0.55-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f79ff4e8f27ac5be95eedc43c252a4415816fa906081bba36a3b637ab6b77fea" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/7f/8a/96cb99f9667f6cb6fc487048abe92715b13f6e67c746db99d57e73dab993/sqlalchemy-2.0.55-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b6db1b880fd74df6cef10b31e414f40e2593bfd80034d3249ee14a02a6ea5b37" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/4e/e0/96eb9de49c8d7a3fde0ec696b35d487935257e0351db1912914ba7a16e55/sqlalchemy-2.0.55-cp314-cp314t-win32.whl", hash = "sha256:701a8f416fa99ce20c6f5909f95c2e557e16078bc50b844c094f1ada1e0b0fc5" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/77/5d/3625248e8532a4c8c16859671b6a0b5909200b91869a3053b72d79f9ebba/sqlalchemy-2.0.55-cp314-cp314t-win_amd64.whl", hash = "sha256:2cd239df22ea1f233d1d3a88551879dc8d35a7ed9466b4884c3b51c120f595bf" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/fd/91/de9283ed03f777daaf2a65823dfa9900bd0859dc0b1d3d314898536cfc08/sqlalchemy-2.0.55-py3-none-any.whl", hash = "sha256:d2954796165c08e2f70082e4577a4c3ce6f141c77e2e4b378b3b528653b54311" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlalchemy"
|
||||||
|
version = "2.1.6"
|
||||||
|
source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.12'",
|
||||||
|
"python_full_version >= '3.11.5' and python_full_version < '3.12'",
|
||||||
|
"python_full_version >= '3.11' and python_full_version < '3.11.5'",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/36/dc/9addbf490110b5727380059ee542ad8353bdcee45a37ba6a5e0976966ae0/sqlalchemy-2.1.6.tar.gz", hash = "sha256:499eae5c9051efe96cb9a73ec2fbde8ffa4186f102b9094ebab8fcc9d3b99f52" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/4b/e1/fca57b09555d90ed6f8ca043245d5205cb2269122acdf8eeee32176711d2/sqlalchemy-2.1.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4e4a43cde0dcdff974304ddb48246d6a094e618b6ee9642827ddbfe9fbebadb" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/bf/3b/e593c3cbb51713e6e646409a5de893c1b6432e45339c7741a6a2edfbd09b/sqlalchemy-2.1.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b6b5fdcdb6891025117f76f48d7d0f89245d66fab6519f3b856294bab317387" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/4b/69/8a1e812d3937f8367b64cf0ddcd2559cf9b0619c4061ebf08ff8084b949b/sqlalchemy-2.1.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d877b2c46298825e8f32059e8d912d0d100af0e9fefe31fb2abb52192817afb" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/9c/d4/7d42a5f4efdb859fa36e85168fcc3c824197193439ff7fac2b595bc94e14/sqlalchemy-2.1.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed6596fb63c574fd3dc4d9ea58a7b9c6e7235dc3d74a0bd3791559d5a618e947" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/4e/71/6395a7d168ce7f25d2c3f9b6f51d1ca775482c8510d96bfe75fe880ab91a/sqlalchemy-2.1.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b16a8960ad9cdc923b90dbf431a4739fc81a07b53374ff74b7bdad3be31ce189" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/54/b4/d9b221dd36f82d0e34413837deee25fef45cc13dc4e0483c7ef26b1c34f7/sqlalchemy-2.1.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9245bcbcc3056d440f82e43fb34f94c15361c86b62e14cd9d7880c8e0cf370f6" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/c0/b6/9a8a6f4c2e4035c0c3edc689de4e672ee1324a1fca22f7d9b775b3b87f33/sqlalchemy-2.1.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a909a8eb82ce5a2f1566ebd685a79f3cfd9e049d57e3a0d3e24d56da04913e73" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/db/d3/95332d026013398e9bf6edb608305cff4070d95aeeeadec6217018d4e1e6/sqlalchemy-2.1.6-cp311-cp311-win32.whl", hash = "sha256:69eeb530ce74be5e202a98ce35aa58b0776c24e64923c4873c25fa56f8e423bc" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/61/78/ed22f1e9f3597b004da38e853dd0c443908e19e74ceee623e45ed18b967f/sqlalchemy-2.1.6-cp311-cp311-win_amd64.whl", hash = "sha256:f0f22627d6436bd120c6dde0936b12fe75498d1e2e9214ec9863be17f9b80997" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/12/88/d0ccaa6f65d4de6929eb94b1fc474f1b82175b7251242d72cf76d77823a5/sqlalchemy-2.1.6-cp311-cp311-win_arm64.whl", hash = "sha256:8f9d12a8be271c7a83ded1cb7d85921f1b7f65c655c8dae972130a190211bb57" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/36/1c/33a77ef74758261e5a2206456713e43d0a72c98ef431fe68fa88ed03b428/sqlalchemy-2.1.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:80009748a5ce0c51a9947542935f9709a1c48b65c69f4a1e56d3072f99e750f2" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/07/05/45e6e2bae7c11e7dbaeb0e9205d92b1b12583b7e199eb2503a7a603f3b9f/sqlalchemy-2.1.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b5885ad1226c8eeea6aeabb4c4036e43202e1bd70cc7a50ece98bcd6ebe5566" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/ad/d8/8fbe61673b1bb9f5e98d9a9b28455d408ed8bbf6efaf0b03db13357c763d/sqlalchemy-2.1.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e4b4dcf85d7f289def0e3167f15aaad101b7744a065fb2019476edf306712c" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/c2/e4/642e3dbd106866d7f726b7c5479f9c9539f1cf010a2e7b8fe17ee8b8de43/sqlalchemy-2.1.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c314bf9026d865c2b2f67f3b853b4e2edcbe7ed46b67ef11b4a40f0af6aa17b8" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/2c/d8/bcaf98d9f1ac842df7287003a0db36c0f33af9bdb07f1f716d8ab8d6c709/sqlalchemy-2.1.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35348ffcbf686055ea7fd02ba4eb76060ed76761648720c63170c5c8bccd85de" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/44/ac/13226c429b8a87890433fb7dabf8a8b15725e8efdb2e673fdda336bd61cf/sqlalchemy-2.1.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:eafb738fbcba39d12543730eacb65e2d5d627965959b3cc6bd6f797b2d52cc0f" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/b5/5e/d9330f73b6e39f08baed5bd6ea410fd8ee78b8bb1549d0d6a5f555a73db5/sqlalchemy-2.1.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9b197f5d2bcd61b939d2f04b77e86ef24bda266518f3e1526bf666deb20d3ddd" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/62/a3/811e0702c62666d222cae8e7ece846661833896ed8a4c1081840dd49a513/sqlalchemy-2.1.6-cp312-cp312-win32.whl", hash = "sha256:991fa7c6756505e3dc5aad678db5fb0110d9816c2185bc3e2c520de46cfc8c5d" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/06/ee/5609e83ebc921663b3250d053a61b355db72ad3cbbde73e7105024e8bf10/sqlalchemy-2.1.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ea80b33e57f918509eb91cd074324bff0f9aae40d238121cdc369f449c20649" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/73/aa/5b3164be28000eecb4629c07522686ea0fa2a340ebf529801fc861518469/sqlalchemy-2.1.6-cp312-cp312-win_arm64.whl", hash = "sha256:84b359a458611d40061f536b1363733b0af7a71425e4b17d0abb7e543d00e979" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/d7/db/ecf1cf1f5c8cb7661081dcb0561633c174213d7af5fccc17ab48cbe9812a/sqlalchemy-2.1.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0534d3f41295a74d9fe2e0838e06ce6329a0855b2965df5f5d1c4d3f6f1406d0" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/9b/98/1cccefb4fc94f4efd621604063acbbea4e3d530c27151fd7249d68adabf2/sqlalchemy-2.1.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:650f97996a4c529f4930969f3ba11bcdb57ff160ed1cb87ea0174393bf4f5f73" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/d3/b0/1817d7406ed92c8647a1d1db965a1bc2dd5a3f80d18e4d28ad62daf9bd72/sqlalchemy-2.1.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bada6bd29ae58a0581bf6977c0a43dfd67388fd09129f1475c37eca370cccd8" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/41/5c/0bdaa1676fb182b93e3c87c09cafd08a734ef29820aac6312b87a88f8537/sqlalchemy-2.1.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:96f5fcdfb7e89b6d895e2ddc165f67b3126a585dfdf2a95b0c1f3fcf3380d7d4" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/c1/d7/319e5a049b6f5235d1229858972abb05b8eae2fdd6639dc7bdbbc9cdb68d/sqlalchemy-2.1.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:015bd7b0687b5feaecb9988bc2f36ecb39daca83bd806b094b43b19ea503b0a9" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/19/3b/6e8e5c14a3bcfae59a081a27f33e4f225df8140daab8e90fc3c42afd0d0e/sqlalchemy-2.1.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bf883ae772971bc6155cb64cdfb9e31c50a2099f3b7c8b51b0401bf608a9fe8e" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/b8/a7/7ad220d5ea6308a9a32395785f4ce8beff4d00b0ae56ab9f2dafcca28743/sqlalchemy-2.1.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ff897d13de613768123dee68474956a51fe641c42bba7af6cb7776716a8dbcb" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/08/81/53a198f5a29f247adc68c0c6da69bd2c7bdd89d3ffc58f9e626ee3160f18/sqlalchemy-2.1.6-cp313-cp313-win32.whl", hash = "sha256:9db8bacb2a69b4b5388bc1439463170af28f4cc710933085b81d1658d931d2c2" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/b4/87/69e86a23cd9ccef0d654c09dc69dfea6d62b1311d2f13ca3daab1bcd922e/sqlalchemy-2.1.6-cp313-cp313-win_amd64.whl", hash = "sha256:b9f0b92b4a88b630f1e67df816b43cb017bb583479917dd100541d4f490a9517" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/98/90/a7f81395135cc5da724403ece4340f7e04c1ce752da15d0ad337591ec9c9/sqlalchemy-2.1.6-cp313-cp313-win_arm64.whl", hash = "sha256:bc495d00f235c3df6f376187a4ef48a56e162fdcc0a113ee34518332aa1aa2df" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/d3/23/1d808dee65ee9a7fc0013d3f42af0655bc1e740c55b2ee8a2dfedc09be26/sqlalchemy-2.1.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5f9400b1234555b9ae78450123dec7aaf045d8d978a1e94967437ebe561ae120" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/c3/33/f3feba2de61a20df6293d7da26eedabdd5fd01bb44f6b6a6a192a5a902de/sqlalchemy-2.1.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d106c2191a3a0dea6873c96ab909f31c13978913714a25023f492e9ccc8ffcc7" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/2c/40/42ccf6f7f6adea241f51f236868b8f27076f072b22e194b197f1bafedd75/sqlalchemy-2.1.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39ca3a128260fb3aa1c33554f1993bc5ddf3f82375697028c49e7b411ab80b84" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/43/52/2007c76766751730e4ac51d5df9aa22be8c82a242413085f3a424d7efd7b/sqlalchemy-2.1.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8c34c200a124a1a5769671613b3fc05a21d0a50f7ad942cf0ee12d4390a112c" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/ec/7b/dd2b47ab0cb726abe54cc365a1d0bdf1a0424d936c740512173eaec7ab89/sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:17bc5f508f5e5cee2722a5a14af0a48768b40e1fc5d1bfc92abf6ac587636bd7" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/07/fd/6499902fd0fcf1dc078efc3d33c60434c55901c14dcf20bad98fca1ed556/sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c757e13b63a25d59a2566c9b2e77adf14d969207a375ca82a9b4fa1a04b37845" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/5a/22/d805c0b3ff191af4e3cf8b2303c0fca44f5845d019f694c22b2d05e4a3b0/sqlalchemy-2.1.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:73aa8d2f911a54d1310e8258a752207e2d7624f4db46e2320d734fe7218320a1" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/a8/4f/015929abb68561990262348bd18aec9768ad743c293e77ad659947c20fa4/sqlalchemy-2.1.6-cp314-cp314-win32.whl", hash = "sha256:1927eabf26265ed962627e7a64626d9058da29cc0faea341091302ec108ce305" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/0a/1b/19bc3e0b590228bd122237bf38092b292c04e6501f9de7024a5b6d68cf10/sqlalchemy-2.1.6-cp314-cp314-win_amd64.whl", hash = "sha256:fd1154c6394892e727ff234273f5eb2b4319e5714917b016602435d77add8dcb" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/6f/27/e01e46d55b60be7df1420604ad7d81ede3fbf5d41d9cdd7d2d25d5d98b6a/sqlalchemy-2.1.6-cp314-cp314-win_arm64.whl", hash = "sha256:10a4fe106adbb46c9452f750d8a5d4013d9a7e9752f63631c3b255c062b748d0" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/bd/71/62984152c616b26883a686cf05d650f34782108c26c17d0946683b2f13c1/sqlalchemy-2.1.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea5f3dd7450669acbfee7813d3a2a13876806b1d9e4c1626a7bf587c33c68472" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/19/cb/fc1fe57bd16a0b3d4ccf8bffb2b664b3b9fb53ca87e38873413bcec3ee74/sqlalchemy-2.1.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4e4782514c16753f8bce3dd925cd37d26c847bead06e179af6fa4993ce83e88" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/63/79/f155003077cee0893aca3f573e20d69ee28a6219966265b1be55c36a6f26/sqlalchemy-2.1.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da21b36d60f47dd04042e9372ffb3d1ededf5c60a76d43f8c3592221fb2d41cd" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/90/e2/f2787823ad943dbbdf6ceacd3a418bc5b633229e5f2d1d438eedf79a722b/sqlalchemy-2.1.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:048d1beb9bfd7ca1afbcfdb8da9519712827df6cccc80a225715ce387d37bb3b" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/67/9f/8c9eb3efec0c5909d7161c9c4039f9e0d108cb10b2e500f9191f3226a5b5/sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8de444335e3723959913f6635d579276a3517b8c19cbdfaefb04e73b5480e88b" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/d9/4f/c80016c99d2ede933088a68dc2b198e1ba09c574d82213703ddd91962349/sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:231d0fd85866f847c34c915116c386c83e677ab6f79a94c835b7ae7313b86095" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/fc/72/385e0312720af964a4d72f4a37f625fc966aed4115ea249db202521adb1c/sqlalchemy-2.1.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bf937348d4052eac6cfa93c809ad214be75b1f057e3fc0dd2e374a814a23a16b" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/3d/46/2e2eb1f296e4617fb11fbf12acc4aaf1be4e75dd3eed8693bc9967c596ed/sqlalchemy-2.1.6-cp314-cp314t-win32.whl", hash = "sha256:bc8b805fb04472e0f9cb3bc6aad90f24b202afc274b7b8bbd48042130e82997a" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/5e/80/9bdce178d771f569cf3c88516cb7a71eaf6d701086fa47fe5bfb2b3022b3/sqlalchemy-2.1.6-cp314-cp314t-win_amd64.whl", hash = "sha256:84033cab9fec2a515a62d76a7b4acafa83339917db9ea911b65b017928ae7dd8" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/f4/00/587dcab13fdb2ecc5854171a31eaa9ef5fc885bd1b034aa8d7e6ceba19cd/sqlalchemy-2.1.6-cp314-cp314t-win_arm64.whl", hash = "sha256:96ace18758136905f8d5588ec35d6ae4fdc29ce4e4902518263ca33abf10de04" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/34/da/b04b7e7da0374d8e40a6d894fe9969a4d7477d4b5963c94736e85599d27d/sqlalchemy-2.1.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:89d05a1ef531a1fee86d68e731c428b773bd37aabf743bf63dc3709c86f7254d" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/67/17/cc5203117626f71ab3a9ef2fee4cc5ec5c53a80d72b1dd791bc3d497afd2/sqlalchemy-2.1.6-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e12763cb2a400879c6dfe34995a032f707de635c8cad14d6ba777a1250be90b" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/be/bd/a4d24b125b92bb156fe2aa3ce8894d19d7d015e246ce88baceb724776a26/sqlalchemy-2.1.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6e1a9ae71366164800942c477e663b31f3accef55d2e039cb7919fdb54b0086" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/21/12/34545d002189c49f05121ecc2b393bd2a5d2dbd9dc0c6e4510c95e3284e1/sqlalchemy-2.1.6-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9bd573f8da863781ace89950d57a959397d5048b1cf69931c29b2b4ce1405bd7" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/98/bf/491ed3e97d1625245c153c083064f36b1c3c1ecc1b1258a84a1f67f80f98/sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0d0424a4189f95886c78b48bb500a1dd7ae6dbae76deb552b15813dc298d9c8f" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/10/7f/8750ec25961075140def7fa15f3d07d800a68bacfdbc90112387345b78ca/sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:cc60fffea328f38187a4fce43f99c6bbf930de2b87b78251f43f2ba64a1af622" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/62/5a/a9c53bb9dad2732de73d835f691784a3153b4ed693fa385534d2bba39c25/sqlalchemy-2.1.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c390f76f2fe408aa1a3121f3ca3d5c15698e14d913197417355fe3dc016f787d" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/18/02/bc6b004cd8e5906efca1dbbb4e158aa2efe31a5623c32946ec9da73126ae/sqlalchemy-2.1.6-cp315-cp315-win32.whl", hash = "sha256:79c1c20dffd4d8d7e669f096c88f261da8ba3646939cd3eb6faeccb92bae99c3" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/f3/d6/13087d9a1ded5bf8ccadc2f315c157d4796828829b2c2d6cf1031f1c00ba/sqlalchemy-2.1.6-cp315-cp315-win_amd64.whl", hash = "sha256:f1e78019147ff7e08afaffb88fc3a0aa795171b147eacfc7ff873ce7a0d37dff" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/6d/90/263b259d872cf4e11c7bcd1b278256096482db06fa23ddbc8c00c9918f3d/sqlalchemy-2.1.6-cp315-cp315-win_arm64.whl", hash = "sha256:3974e33fc322b645eedeac8a3dea93022466fee41a6385c744a6656aba293cd4" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/04/60/b4821fe7a42405d12abf9ba48e13b5e056e428d6f0e48c8166f0c7df61fa/sqlalchemy-2.1.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e269c68ec200538ae3fa7f9e8d8ed2b4b34d974c25945286ccc2a488438acf32" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/49/e0/4feccf4511123d55e26269aa22c426ca2cdba88298bd65781bd3609e0e1b/sqlalchemy-2.1.6-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b25199a1b499da643de52ab73e972eb07e55e2a95bb99eaa491846098fa2214c" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/7c/fa/7329c40698034056c924210eaba019f46f2914783c3d52b29c47806399c9/sqlalchemy-2.1.6-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:641a3742dac4b8f325667b585097de0f1d20eb3498506c92d5f04c20b3a6c823" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/79/66/098e0981ba55bf61a4b85ae904714987640a7f489da57641c8b6f7d1cfc6/sqlalchemy-2.1.6-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d96186af967d7ae3f76d1e9a8fb70b5beb0f0dbdacfd3b418a946fffbb8781ff" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/20/69/f7ef99af2f3b17e45c1189176decaa2be595848f67827dd7c665ec8e2166/sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:5a9b4cbc5f5cda3c9429e1ac503a57f3b95c59886f26af6ffb44e2e7628b7df8" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/72/d7/3dbbefd2342237fbce9b9169ac4f425d039e97b8a9f765467e4a0319c9aa/sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b7ddb305126d34fda64c3fe4632a43fb9b724da6cc7d1dedd8434e6c783b285b" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/a4/22/07acba0614b788f94c655a25d76188591921a459fe4f4925a3176b576328/sqlalchemy-2.1.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:70b435f719d7156623f49ac6539f30dd8471500b5acf399f3b318c1c17f6fa7f" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/59/0d/ea88e6e0e069b6c1c65c7a32043ab2203386c80d0bd1a25cf1ba39007741/sqlalchemy-2.1.6-cp315-cp315t-win32.whl", hash = "sha256:654192c9f192459ee5f63e6606cc6dd8c50681c28e0689d2a494fc9522b357c1" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/66/25/2182431a4955adfeab69a89e7e4e81eaabbfbf1846ff8634b0f9448fd6cd/sqlalchemy-2.1.6-cp315-cp315t-win_amd64.whl", hash = "sha256:a053a9d9c9ccc9b72d90ecdcbabcbe57446defc7e8c52fc9978188f78742ac19" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/b3/69/e290384d311e6e2c495734673bdbd2e358dc6725e7c1525326eb8e34af8d/sqlalchemy-2.1.6-cp315-cp315t-win_arm64.whl", hash = "sha256:61dc3fa488208a2f03e0e25ad32950dc024545291acc7348f2e550ea1bc5abb4" },
|
||||||
|
{ url = "https://mirrors.aliyun.com/pypi/packages/c1/69/c75534e336b06b3fd3f76de73d89736510800e60c77c58d4e4ce18389018/sqlalchemy-2.1.6-py3-none-any.whl", hash = "sha256:106878cf3795cd3d357e2e858609f9e44f20f19b2d8e48a3532bef3f90b03fbe" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "1.7.0"
|
version = "1.7.0"
|
||||||
@@ -1457,7 +1611,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "study-tool"
|
name = "study-tool"
|
||||||
version = "0.0.1"
|
version = "0.0.8"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "beautifulsoup4" },
|
{ name = "beautifulsoup4" },
|
||||||
@@ -1465,7 +1619,10 @@ dependencies = [
|
|||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "lxml" },
|
{ name = "lxml" },
|
||||||
{ name = "playwright" },
|
{ name = "playwright" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
|
{ name = "sqlalchemy", version = "2.0.55", source = { registry = "https://mirrors.aliyun.com/pypi/simple" }, marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "sqlalchemy", version = "2.1.6", source = { registry = "https://mirrors.aliyun.com/pypi/simple" }, marker = "python_full_version >= '3.11'" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1476,7 +1633,9 @@ requires-dist = [
|
|||||||
{ name = "fastapi", specifier = ">=0.110" },
|
{ name = "fastapi", specifier = ">=0.110" },
|
||||||
{ name = "lxml", specifier = ">=5.0" },
|
{ name = "lxml", specifier = ">=5.0" },
|
||||||
{ name = "playwright", specifier = ">=1.40" },
|
{ name = "playwright", specifier = ">=1.40" },
|
||||||
|
{ name = "python-dotenv", specifier = ">=1.0" },
|
||||||
{ name = "requests", specifier = ">=2.31" },
|
{ name = "requests", specifier = ">=2.31" },
|
||||||
|
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||||
{ name = "uvicorn", specifier = ">=0.27" },
|
{ name = "uvicorn", specifier = ">=0.27" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+82
-23
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { Plus, RefreshCw, Users, WifiOff } from 'lucide-react'
|
import { Plus, RefreshCw, Users, WifiOff } from 'lucide-react'
|
||||||
|
|
||||||
import { AccountCard } from './components/AccountCard'
|
import { AccountCard } from './components/AccountCard'
|
||||||
|
import { AccountEditDialog, type AccountEdit } from './components/AccountEditDialog'
|
||||||
import { AddAccountDialog, type NewAccount } from './components/AddAccountDialog'
|
import { AddAccountDialog, type NewAccount } from './components/AddAccountDialog'
|
||||||
|
import { PluginsDialog } from './components/PluginsDialog'
|
||||||
import { QuizDialog } from './components/QuizDialog'
|
import { QuizDialog } from './components/QuizDialog'
|
||||||
import { SettingsDialog } from './components/SettingsDialog'
|
import { SettingsDialog } from './components/SettingsDialog'
|
||||||
import { StatBar } from './components/StatBar'
|
import { StatBar } from './components/StatBar'
|
||||||
@@ -10,6 +12,7 @@ import { SystemLogDialog } from './components/SystemLogDialog'
|
|||||||
import { Toast, type ToastState } from './components/Toast'
|
import { Toast, type ToastState } from './components/Toast'
|
||||||
import { TopBar } from './components/TopBar'
|
import { TopBar } from './components/TopBar'
|
||||||
import { Button } from './components/ui/Button'
|
import { Button } from './components/ui/Button'
|
||||||
|
import { useConfirm } from './components/ui/ConfirmDialog'
|
||||||
import { ApiError, api } from './lib/api'
|
import { ApiError, api } from './lib/api'
|
||||||
import { countCourses, toastMessage } from './lib/utils'
|
import { countCourses, toastMessage } from './lib/utils'
|
||||||
import type { Account, Cookie, Course, LogResponse, PlatformInfo, RunState } from './types'
|
import type { Account, Cookie, Course, LogResponse, PlatformInfo, RunState } from './types'
|
||||||
@@ -47,7 +50,9 @@ interface InlineLog {
|
|||||||
|
|
||||||
type DialogState =
|
type DialogState =
|
||||||
| { type: 'add' }
|
| { type: 'add' }
|
||||||
|
| { type: 'edit'; accountId: string }
|
||||||
| { type: 'settings' }
|
| { type: 'settings' }
|
||||||
|
| { type: 'plugins' }
|
||||||
| { type: 'system' }
|
| { type: 'system' }
|
||||||
| { type: 'quiz'; accountId: string; course: Course }
|
| { type: 'quiz'; accountId: string; course: Course }
|
||||||
| null
|
| null
|
||||||
@@ -70,12 +75,11 @@ function persistOpenLogs(ids: Set<string>) {
|
|||||||
// 隐私模式禁用 localStorage 时忽略
|
// 隐私模式禁用 localStorage 时忽略
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function accountPayload(account: Account) {
|
function accountPayload(account: Account) {
|
||||||
return {
|
return {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
username: account.username,
|
username: account.username,
|
||||||
platform: account.platform || 'guanyixue',
|
platform: account.platform || '',
|
||||||
note: account.note || '',
|
note: account.note || '',
|
||||||
user: account.user || '',
|
user: account.user || '',
|
||||||
cookies: account.cookies || [],
|
cookies: account.cookies || [],
|
||||||
@@ -93,10 +97,8 @@ function accountPayload(account: Account) {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [accounts, setAccounts] = useState<Account[]>([])
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
const accountsRef = useRef(accounts)
|
const accountsRef = useRef(accounts)
|
||||||
const [platforms, setPlatforms] = useState<Record<string, string>>({ guanyixue: '莞易学' })
|
const [platforms, setPlatforms] = useState<Record<string, string>>({})
|
||||||
const [platformList, setPlatformList] = useState<PlatformInfo[]>([
|
const [platformList, setPlatformList] = useState<PlatformInfo[]>([])
|
||||||
{ id: 'guanyixue', label: '莞易学' },
|
|
||||||
])
|
|
||||||
const [offline, setOffline] = useState(false)
|
const [offline, setOffline] = useState(false)
|
||||||
const [toast, setToast] = useState<ToastState | null>(null)
|
const [toast, setToast] = useState<ToastState | null>(null)
|
||||||
const [pending, setPending] = useState<Record<string, string>>({})
|
const [pending, setPending] = useState<Record<string, string>>({})
|
||||||
@@ -107,6 +109,7 @@ export default function App() {
|
|||||||
const [logs, setLogs] = useState<Record<string, InlineLog>>({})
|
const [logs, setLogs] = useState<Record<string, InlineLog>>({})
|
||||||
const logsRef = useRef(logs)
|
const logsRef = useRef(logs)
|
||||||
const toastTimer = useRef<number | undefined>(undefined)
|
const toastTimer = useRef<number | undefined>(undefined)
|
||||||
|
const { ask: askConfirm, dialog: confirmDialog } = useConfirm()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
accountsRef.current = accounts
|
accountsRef.current = accounts
|
||||||
@@ -164,6 +167,10 @@ export default function App() {
|
|||||||
setOffline(false)
|
setOffline(false)
|
||||||
|
|
||||||
for (const account of next) {
|
for (const account of next) {
|
||||||
|
// 只同步「正在跑」的那一轮:运行结束后 run 里留的是开跑时的旧会话,
|
||||||
|
// 再回写会把刚登录的新 cookie 盖回失效的那一份,刷新课程就会一直重登。
|
||||||
|
const status = account.run?.status
|
||||||
|
if (status !== 'discovering' && status !== 'studying') continue
|
||||||
const cookies = account.run?.cookies
|
const cookies = account.run?.cookies
|
||||||
if (
|
if (
|
||||||
cookies &&
|
cookies &&
|
||||||
@@ -194,19 +201,23 @@ export default function App() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
useEffect(() => {
|
const loadPlatforms = useCallback(async () => {
|
||||||
api<{ platforms: PlatformInfo[] }>('/api/platforms')
|
try {
|
||||||
.then((response) => {
|
const response = await api<{ platforms: PlatformInfo[] }>('/api/platforms')
|
||||||
const list = response.platforms || []
|
const list = response.platforms || []
|
||||||
if (!list.length) return
|
|
||||||
setPlatformList(list)
|
setPlatformList(list)
|
||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
for (const platform of list) map[platform.id] = platform.label
|
for (const platform of list) map[platform.id] = platform.label
|
||||||
setPlatforms(map)
|
setPlatforms(map)
|
||||||
})
|
} catch {
|
||||||
.catch(() => {})
|
// 后端未连接时保留上一次的平台列表
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadPlatforms()
|
||||||
|
}, [loadPlatforms])
|
||||||
|
|
||||||
const pullLog = useCallback(
|
const pullLog = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const current = logsRef.current[id] || {
|
const current = logsRef.current[id] || {
|
||||||
@@ -377,7 +388,7 @@ export default function App() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
cookies: account.cookies,
|
cookies: account.cookies,
|
||||||
platform: account.platform || 'guanyixue',
|
platform: account.platform || '',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!response.ok && response.code === 'session_expired') {
|
if (!response.ok && response.code === 'session_expired') {
|
||||||
@@ -387,7 +398,11 @@ export default function App() {
|
|||||||
if (!latest) return
|
if (!latest) return
|
||||||
response = await api<CoursesResponse>('/api/courses', {
|
response = await api<CoursesResponse>('/api/courses', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ cookies: latest.cookies }),
|
body: JSON.stringify({
|
||||||
|
cookies: latest.cookies,
|
||||||
|
// 重试也要带平台:漏了它后端会拿到空平台,报「平台「」还没有实现刷新课程」
|
||||||
|
platform: latest.platform || account.platform || '',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -523,9 +538,31 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveAccountEdit(account: Account, data: AccountEdit) {
|
||||||
|
const updated: Account = {
|
||||||
|
...account,
|
||||||
|
username: data.username,
|
||||||
|
note: data.note,
|
||||||
|
platform: data.platform,
|
||||||
|
}
|
||||||
|
const response = await api<{ ok?: boolean; error?: string }>('/api/accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ...accountPayload(updated), password: data.password }),
|
||||||
|
})
|
||||||
|
if (response.ok === false) throw new Error(response.error || '保存失败')
|
||||||
|
replaceAccount(updated)
|
||||||
|
notify({ message: '账号信息已保存,正在用新凭据重新登录…' })
|
||||||
|
// 先让弹窗关掉,重新登录在后台跑:失败了会走 toast + 卡片红条提示
|
||||||
|
void login(updated)
|
||||||
|
}
|
||||||
|
|
||||||
async function removeAccount(account: Account) {
|
async function removeAccount(account: Account) {
|
||||||
if (!window.confirm(`删除账号「${account.user || account.username}」?课程缓存和日志不会自动清理。`))
|
const ok = await askConfirm({
|
||||||
return
|
title: '删除账号',
|
||||||
|
message: `删除「${account.user || account.username}」?课程缓存和日志不会自动清理,删掉后要重新添加账号才能继续学习。`,
|
||||||
|
confirmText: '删除账号',
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
try {
|
try {
|
||||||
await api(`/api/accounts/${encodeURIComponent(account.id)}`, { method: 'DELETE' })
|
await api(`/api/accounts/${encodeURIComponent(account.id)}`, { method: 'DELETE' })
|
||||||
setAccounts((previous) => {
|
setAccounts((previous) => {
|
||||||
@@ -636,9 +673,17 @@ export default function App() {
|
|||||||
function platformLabel(account: Account) {
|
function platformLabel(account: Account) {
|
||||||
const id =
|
const id =
|
||||||
account.platform || (account.courses || []).find((course) => course.platform)?.platform
|
account.platform || (account.courses || []).find((course) => course.platform)?.platform
|
||||||
return (id && platforms[id]) || '莞易学'
|
return (id && platforms[id]) || id || '未选择平台'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function platformCaps(account: Account) {
|
||||||
|
return platformList.find((item) => item.id === account.platform)?.capabilities || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const editTarget =
|
||||||
|
dialog?.type === 'edit'
|
||||||
|
? accounts.find((account) => account.id === dialog.accountId) || null
|
||||||
|
: null
|
||||||
const quizAccount =
|
const quizAccount =
|
||||||
dialog?.type === 'quiz'
|
dialog?.type === 'quiz'
|
||||||
? accounts.find((account) => account.id === dialog.accountId) || null
|
? accounts.find((account) => account.id === dialog.accountId) || null
|
||||||
@@ -656,6 +701,7 @@ export default function App() {
|
|||||||
onRefresh={() => void refreshNow()}
|
onRefresh={() => void refreshNow()}
|
||||||
onSettings={() => setDialog({ type: 'settings' })}
|
onSettings={() => setDialog({ type: 'settings' })}
|
||||||
onSystemLog={() => setDialog({ type: 'system' })}
|
onSystemLog={() => setDialog({ type: 'system' })}
|
||||||
|
onPlugins={() => setDialog({ type: 'plugins' })}
|
||||||
onAdd={() => setDialog({ type: 'add' })}
|
onAdd={() => setDialog({ type: 'add' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -679,9 +725,6 @@ export default function App() {
|
|||||||
<div className="mb-3 mt-6 flex items-end justify-between gap-3">
|
<div className="mb-3 mt-6 flex items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-[14px] font-semibold text-slate-900">账号列表</h2>
|
<h2 className="text-[14px] font-semibold text-slate-900">账号列表</h2>
|
||||||
<p className="mt-0.5 text-[11.5px] text-slate-400">
|
|
||||||
新旧版课程自动识别为「莞易学」,每 5 秒自动刷新状态
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{accounts.length > 0 && (
|
{accounts.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
@@ -703,8 +746,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="mt-4 text-[14px] font-semibold text-slate-800">还没有账号</h3>
|
<h3 className="mt-4 text-[14px] font-semibold text-slate-800">还没有账号</h3>
|
||||||
<p className="mt-1.5 max-w-md text-[12.5px] leading-relaxed text-slate-500">
|
<p className="mt-1.5 max-w-md text-[12.5px] leading-relaxed text-slate-500">
|
||||||
添加东莞市教师培训平台账号后,可自动读取课程、勾选学习、提交反馈表与测验。
|
添加平台账号后,可自动读取课程、勾选学习、提交反馈表与测验。
|
||||||
新旧版课程页会统一识别为「莞易学」。
|
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -722,6 +764,7 @@ export default function App() {
|
|||||||
key={account.id}
|
key={account.id}
|
||||||
account={account}
|
account={account}
|
||||||
platformLabel={platformLabel(account)}
|
platformLabel={platformLabel(account)}
|
||||||
|
capabilities={platformCaps(account)}
|
||||||
pending={pending[account.id]}
|
pending={pending[account.id]}
|
||||||
openLog={openLogs.has(account.id)}
|
openLog={openLogs.has(account.id)}
|
||||||
logLines={logs[account.id]?.lines || []}
|
logLines={logs[account.id]?.lines || []}
|
||||||
@@ -732,6 +775,7 @@ export default function App() {
|
|||||||
onStop={() => void stopStudy(account)}
|
onStop={() => void stopStudy(account)}
|
||||||
onCapture={() => void capture(account)}
|
onCapture={() => void capture(account)}
|
||||||
onStopCapture={() => void stopCapture(account)}
|
onStopCapture={() => void stopCapture(account)}
|
||||||
|
onEdit={() => setDialog({ type: 'edit', accountId: account.id })}
|
||||||
onDelete={() => void removeAccount(account)}
|
onDelete={() => void removeAccount(account)}
|
||||||
onQuiz={() => {
|
onQuiz={() => {
|
||||||
const list = (account.courses || []).filter((course) => course.in_study !== false)
|
const list = (account.courses || []).filter((course) => course.in_study !== false)
|
||||||
@@ -758,7 +802,7 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="border-t border-line/70 py-5 text-center text-[11px] text-slate-400">
|
<footer className="border-t border-line/70 py-5 text-center text-[11px] text-slate-400">
|
||||||
莞易学 · 多账号学习控制台 · 数据保存在本机
|
多账号学习控制台 · 数据保存在本机
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<AddAccountDialog
|
<AddAccountDialog
|
||||||
@@ -767,12 +811,25 @@ export default function App() {
|
|||||||
onClose={() => setDialog(null)}
|
onClose={() => setDialog(null)}
|
||||||
onSubmit={addAccount}
|
onSubmit={addAccount}
|
||||||
/>
|
/>
|
||||||
|
<AccountEditDialog
|
||||||
|
open={dialog?.type === 'edit' && !!editTarget}
|
||||||
|
account={editTarget}
|
||||||
|
platforms={platformList}
|
||||||
|
onClose={() => setDialog(null)}
|
||||||
|
onSubmit={(data) => (editTarget ? saveAccountEdit(editTarget, data) : Promise.resolve())}
|
||||||
|
/>
|
||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
open={dialog?.type === 'settings'}
|
open={dialog?.type === 'settings'}
|
||||||
onClose={() => setDialog(null)}
|
onClose={() => setDialog(null)}
|
||||||
notify={notify}
|
notify={notify}
|
||||||
/>
|
/>
|
||||||
<SystemLogDialog open={dialog?.type === 'system'} onClose={() => setDialog(null)} />
|
<SystemLogDialog open={dialog?.type === 'system'} onClose={() => setDialog(null)} />
|
||||||
|
<PluginsDialog
|
||||||
|
open={dialog?.type === 'plugins'}
|
||||||
|
onClose={() => setDialog(null)}
|
||||||
|
notify={notify}
|
||||||
|
onChanged={() => void loadPlatforms()}
|
||||||
|
/>
|
||||||
<QuizDialog
|
<QuizDialog
|
||||||
open={dialog?.type === 'quiz' && !!quizAccount}
|
open={dialog?.type === 'quiz' && !!quizAccount}
|
||||||
account={quizAccount}
|
account={quizAccount}
|
||||||
@@ -782,6 +839,8 @@ export default function App() {
|
|||||||
onSubmitted={() => void load()}
|
onSubmitted={() => void load()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{confirmDialog}
|
||||||
|
|
||||||
<Toast toast={toast} />
|
<Toast toast={toast} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Square,
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
UserPen,
|
||||||
Video,
|
Video,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ import type { Account, Course } from '../types'
|
|||||||
import { ageText, cn, countCourses } from '../lib/utils'
|
import { ageText, cn, countCourses } from '../lib/utils'
|
||||||
import { Badge } from './ui/Badge'
|
import { Badge } from './ui/Badge'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
|
import { LogLines } from './ui/LogLines'
|
||||||
|
|
||||||
type Tone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger' | 'running'
|
type Tone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger' | 'running'
|
||||||
|
|
||||||
@@ -87,6 +89,7 @@ function CourseRow({
|
|||||||
export function AccountCard({
|
export function AccountCard({
|
||||||
account,
|
account,
|
||||||
platformLabel,
|
platformLabel,
|
||||||
|
capabilities,
|
||||||
pending,
|
pending,
|
||||||
openLog,
|
openLog,
|
||||||
logLines,
|
logLines,
|
||||||
@@ -98,6 +101,7 @@ export function AccountCard({
|
|||||||
onCapture,
|
onCapture,
|
||||||
onStopCapture,
|
onStopCapture,
|
||||||
onQuiz,
|
onQuiz,
|
||||||
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleCourse,
|
onToggleCourse,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
@@ -106,6 +110,7 @@ export function AccountCard({
|
|||||||
}: {
|
}: {
|
||||||
account: Account
|
account: Account
|
||||||
platformLabel: string
|
platformLabel: string
|
||||||
|
capabilities: string[]
|
||||||
pending?: string
|
pending?: string
|
||||||
openLog: boolean
|
openLog: boolean
|
||||||
logLines: string[]
|
logLines: string[]
|
||||||
@@ -117,6 +122,7 @@ export function AccountCard({
|
|||||||
onCapture: () => void
|
onCapture: () => void
|
||||||
onStopCapture: () => void
|
onStopCapture: () => void
|
||||||
onQuiz: () => void
|
onQuiz: () => void
|
||||||
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onToggleCourse: (planId: string, checked: boolean) => void
|
onToggleCourse: (planId: string, checked: boolean) => void
|
||||||
onSelectAll: () => void
|
onSelectAll: () => void
|
||||||
@@ -355,11 +361,18 @@ export function AccountCard({
|
|||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute bottom-10 right-0 z-20 w-40 overflow-hidden rounded-xl border border-line bg-white py-1 shadow-xl shadow-slate-900/10">
|
<div className="absolute bottom-10 right-0 z-20 w-40 overflow-hidden rounded-xl border border-line bg-white py-1 shadow-xl shadow-slate-900/10">
|
||||||
{[
|
{[
|
||||||
|
{ label: '修改账号信息', icon: UserPen, action: onEdit },
|
||||||
{ label: '课程日志', icon: ListVideo, action: onToggleLog },
|
{ label: '课程日志', icon: ListVideo, action: onToggleLog },
|
||||||
{ label: '打开测验', icon: ClipboardCheck, action: onQuiz },
|
...(capabilities.includes('quiz')
|
||||||
|
? [{ label: '打开测验', icon: ClipboardCheck, action: onQuiz }]
|
||||||
|
: []),
|
||||||
|
...(capabilities.includes('capture')
|
||||||
|
? [
|
||||||
account.run?.capturing
|
account.run?.capturing
|
||||||
? { label: '停止抓包', icon: Square, action: onStopCapture }
|
? { label: '停止抓包', icon: Square, action: onStopCapture }
|
||||||
: { label: '抓包 10 分钟', icon: Video, action: onCapture },
|
: { label: '抓包 10 分钟', icon: Video, action: onCapture },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{ label: '删除账号', icon: Trash2, action: onDelete, danger: true },
|
{ label: '删除账号', icon: Trash2, action: onDelete, danger: true },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<button
|
<button
|
||||||
@@ -423,6 +436,14 @@ export function AccountCard({
|
|||||||
<span className="ml-1 text-red-400">· {ageText(account.lastErrorAt)}</span>
|
<span className="ml-1 text-red-400">· {ageText(account.lastErrorAt)}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="mt-1.5 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<UserPen className="size-3" />
|
||||||
|
修改账号信息
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -445,13 +466,9 @@ export function AccountCard({
|
|||||||
const el = event.currentTarget
|
const el = event.currentTarget
|
||||||
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="scrollbar-thin mx-4 mb-3 mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-line bg-slate-50 p-3 font-mono text-[11px] leading-relaxed text-slate-600"
|
||||||
'scrollbar-thin mx-4 mb-3 mt-2 max-h-56 overflow-auto rounded-lg bg-slate-950/95 p-3',
|
|
||||||
'font-mono text-[11px] leading-relaxed text-slate-300',
|
|
||||||
logLines.length === 0 && 'text-slate-500',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{logLines.length ? logLines.join('\n') : '暂无日志 · 开始学习后这里会实时显示'}
|
<LogLines lines={logLines} empty="暂无日志 · 开始学习后这里会实时显示" />
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { ChevronDown, Eye, EyeOff, GraduationCap, KeyRound, UserPen } from 'lucide-react'
|
||||||
|
|
||||||
|
import type { Account, PlatformInfo } from '../types'
|
||||||
|
import { inputCls } from '../lib/utils'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
|
||||||
|
export interface AccountEdit {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
note: string
|
||||||
|
platform: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountEditDialog({
|
||||||
|
open,
|
||||||
|
account,
|
||||||
|
platforms,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
account: Account | null
|
||||||
|
platforms: PlatformInfo[]
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: AccountEdit) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [note, setNote] = useState('')
|
||||||
|
const [platform, setPlatform] = useState('')
|
||||||
|
const [showPwd, setShowPwd] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 卡片每 5 秒轮询会换掉 account 对象,只在打开 / 换账号时回填,避免正在输入的内容被重置
|
||||||
|
const accountRef = useRef(account)
|
||||||
|
accountRef.current = account
|
||||||
|
const platformsRef = useRef(platforms)
|
||||||
|
platformsRef.current = platforms
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const acc = accountRef.current
|
||||||
|
setUsername(acc?.username || '')
|
||||||
|
setPassword('')
|
||||||
|
setNote(acc?.note || '')
|
||||||
|
setPlatform(acc?.platform || platformsRef.current[0]?.id || '')
|
||||||
|
setShowPwd(false)
|
||||||
|
setSaving(false)
|
||||||
|
setError('')
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, account?.id])
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!username.trim()) {
|
||||||
|
setError('登录账号不能为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!platform) {
|
||||||
|
setError('请选择学习平台')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await onSubmit({ username: username.trim(), password, note: note.trim(), platform })
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<UserPen className="size-4 text-brand-600" />
|
||||||
|
修改账号信息
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
account?.user ? `当前会话:${account.user}` : '这个账号还没有登录成功,改完会自动重试登录'
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button onClick={onClose} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" loading={saving} onClick={submit}>
|
||||||
|
保存并重新登录
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-[12px] font-medium text-slate-600">学习平台</label>
|
||||||
|
<div className="relative">
|
||||||
|
<GraduationCap className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
|
<select
|
||||||
|
className={`${inputCls} appearance-none pl-8 pr-8`}
|
||||||
|
value={platform}
|
||||||
|
onChange={(event) => setPlatform(event.target.value)}
|
||||||
|
>
|
||||||
|
{platforms.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-[12px] font-medium text-slate-600">登录账号</label>
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={username}
|
||||||
|
autoFocus
|
||||||
|
placeholder="手机号 / 身份证号 / 教师账号"
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
onKeyDown={(event) => event.key === 'Enter' && submit()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-slate-600">
|
||||||
|
<KeyRound className="size-3.5 text-slate-400" />
|
||||||
|
密码
|
||||||
|
<span className="font-normal text-slate-400">(留空表示不修改)</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className={`${inputCls} pr-9`}
|
||||||
|
type={showPwd ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={account?.hasPassword ? '已保存密码,需要更换时再填' : '还没有保存密码,请填写'}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
onKeyDown={(event) => event.key === 'Enter' && submit()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
||||||
|
onClick={() => setShowPwd((value) => !value)}
|
||||||
|
aria-label={showPwd ? '隐藏密码' : '显示密码'}
|
||||||
|
>
|
||||||
|
{showPwd ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-[12px] font-medium text-slate-600">
|
||||||
|
备注 <span className="font-normal text-slate-400">(可选)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
value={note}
|
||||||
|
placeholder="例如:张三 · 语文"
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="rounded-lg border border-line bg-slate-50 px-3 py-2 text-[11.5px] leading-relaxed text-slate-500">
|
||||||
|
保存后会用新的账号密码重新登录一次:登录成功会刷新会话和课程,失败则保留当前会话并提示原因。
|
||||||
|
改「学习平台」只影响之后用哪个插件登录和读取课程。
|
||||||
|
</p>
|
||||||
|
{error && <p className="rounded-lg bg-red-50 px-3 py-2 text-[12px] text-red-700">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export function AddAccountDialog({
|
|||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
const [platform, setPlatform] = useState(platforms[0]?.id || 'guanyixue')
|
const [platform, setPlatform] = useState(platforms[0]?.id || '')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
@@ -36,12 +36,16 @@ export function AddAccountDialog({
|
|||||||
setUsername('')
|
setUsername('')
|
||||||
setPassword('')
|
setPassword('')
|
||||||
setNote('')
|
setNote('')
|
||||||
setPlatform(platforms[0]?.id || 'guanyixue')
|
setPlatform(platforms[0]?.id || '')
|
||||||
setError('')
|
setError('')
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}, [open, platforms])
|
}, [open, platforms])
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
if (!platform) {
|
||||||
|
setError('没有可用的学习平台,请先在「插件」里启用一个平台插件')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!username.trim() || !password) {
|
if (!username.trim() || !password) {
|
||||||
setError('请填写账号和密码')
|
setError('请填写账号和密码')
|
||||||
return
|
return
|
||||||
@@ -94,7 +98,7 @@ export function AddAccountDialog({
|
|||||||
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-[11px] leading-relaxed text-slate-400">
|
<p className="mt-1.5 text-[11px] leading-relaxed text-slate-400">
|
||||||
选「莞易学」后,后台会自动识别旧版 / 新版课程页并按对应实现学习;后续新增的平台会出现在这里。
|
账号的登录、刷新课程、学习都按所选平台的插件实现;新增或启用平台插件后会出现在这里。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Puzzle, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { toastMessage } from '../lib/utils'
|
||||||
|
import type { PluginInfo } from '../types'
|
||||||
|
import { Badge } from './ui/Badge'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
|
||||||
|
interface PluginsResponse {
|
||||||
|
ok?: boolean
|
||||||
|
error?: string
|
||||||
|
plugins?: PluginInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 能力名(contract.CAPABILITIES 的键)→ 界面文案
|
||||||
|
const CAPABILITY_LABELS: Record<string, string> = {
|
||||||
|
login: '登录',
|
||||||
|
session: '会话',
|
||||||
|
renew: '续期',
|
||||||
|
profile: '账号信息',
|
||||||
|
courses: '课程列表',
|
||||||
|
discover: '课程详情',
|
||||||
|
study: '学习',
|
||||||
|
quiz: '测验',
|
||||||
|
capture: '抓包',
|
||||||
|
push: '推送',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginsDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
notify,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
notify: (toast: { message: string; tone?: 'info' | 'success' | 'error' }) => void
|
||||||
|
onChanged: () => void
|
||||||
|
}) {
|
||||||
|
const [plugins, setPlugins] = useState<PluginInfo[]>([])
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const pull = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await api<PluginsResponse>('/api/plugins')
|
||||||
|
setPlugins(response.plugins || [])
|
||||||
|
} catch (error) {
|
||||||
|
notify({ message: toastMessage(error), tone: 'error' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [notify])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) void pull()
|
||||||
|
}, [open, pull])
|
||||||
|
|
||||||
|
async function run(name: string, path: string, okText: string) {
|
||||||
|
setBusy(name)
|
||||||
|
try {
|
||||||
|
const response = await api<PluginsResponse>(path, { method: 'POST' })
|
||||||
|
if (response.plugins) setPlugins(response.plugins)
|
||||||
|
if (response.ok === false) {
|
||||||
|
notify({ message: response.error || '插件操作失败', tone: 'error' })
|
||||||
|
} else {
|
||||||
|
notify({ message: okText, tone: 'success' })
|
||||||
|
onChanged()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
notify({ message: toastMessage(error), tone: 'error' })
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="插件管理"
|
||||||
|
size="lg"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<span className="mr-auto text-[11px] text-slate-400">
|
||||||
|
共 {plugins.length} 个 · 停用后平台会从「添加账号」下拉里消失,推送渠道也会停止推送
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
icon={<RefreshCw className="size-3.5" />}
|
||||||
|
loading={busy === '*'}
|
||||||
|
onClick={() => void run('*', '/api/plugins/reload', '全部插件已重载')}
|
||||||
|
>
|
||||||
|
全部重载
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<div key={plugin.name} className="rounded-xl border border-line bg-slate-50/70 px-3.5 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Puzzle className="size-4 shrink-0 text-brand-600" />
|
||||||
|
<span className="text-[13px] font-semibold text-slate-900">
|
||||||
|
{plugin.title || plugin.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-slate-400">{plugin.name}</span>
|
||||||
|
<Badge tone={plugin.enabled ? 'success' : 'neutral'} dot>
|
||||||
|
{plugin.enabled ? '已启用' : '已停用'}
|
||||||
|
</Badge>
|
||||||
|
{plugin.enabled && (
|
||||||
|
<Badge tone={plugin.loaded ? 'brand' : 'warning'}>
|
||||||
|
{plugin.loaded ? '已加载' : '未加载'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-1.5 rounded-lg border border-line/80 bg-white px-3 py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="w-[52px] shrink-0 text-[11px] text-slate-400">适配网站</span>
|
||||||
|
{plugin.sites?.length ? (
|
||||||
|
plugin.sites.map((site) => (
|
||||||
|
<span
|
||||||
|
key={site}
|
||||||
|
className="rounded-md border border-line bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] text-slate-600"
|
||||||
|
>
|
||||||
|
{site}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-slate-400">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="w-[52px] shrink-0 text-[11px] text-slate-400">能力</span>
|
||||||
|
{plugin.capabilities.length ? (
|
||||||
|
plugin.capabilities.map((cap) => (
|
||||||
|
<span
|
||||||
|
key={cap}
|
||||||
|
className="rounded-md bg-brand-50 px-1.5 py-0.5 text-[11px] font-medium text-brand-700 ring-1 ring-inset ring-brand-100"
|
||||||
|
>
|
||||||
|
{CAPABILITY_LABELS[cap] || cap}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-slate-400">无</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 pl-[52px] text-[11px] text-slate-400">
|
||||||
|
<span>
|
||||||
|
平台 <span className="text-slate-600">{plugin.label || '—'}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
id <span className="text-slate-600">{plugin.id || '—'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={plugin.enabled ? 'secondary' : 'primary'}
|
||||||
|
loading={busy === plugin.name}
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
plugin.name,
|
||||||
|
`/api/plugins/${plugin.name}/${plugin.enabled ? 'disable' : 'enable'}`,
|
||||||
|
plugin.enabled ? `${plugin.name} 已停用` : `${plugin.name} 已启用`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{plugin.enabled ? '停用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!plugin.enabled}
|
||||||
|
loading={busy === plugin.name}
|
||||||
|
onClick={() =>
|
||||||
|
void run(plugin.name, `/api/plugins/${plugin.name}/reload`, `${plugin.name} 已重载`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
重载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!plugins.length && !loading && (
|
||||||
|
<p className="py-8 text-center text-[12.5px] text-slate-400">
|
||||||
|
plugins/ 下还没有插件。建一个含 plugin.json + main.py 的目录丢进去,刷新即可看到。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user