feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8

体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
study-tool
2026-11-01 17:47:22 +08:00
parent 491fd08c1e
commit aa48145557
114 changed files with 7779 additions and 3302 deletions
+98 -41
View File
@@ -10,60 +10,75 @@ src/study_tool/
__main__.py python -m study_tool 入口
app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main()
webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入)
config.py 常量 / 环境变量 / 根目录定位(data、static、script)
config/ 全局路径常量(ROOT / DATA / STATIC_DIR),不放任何平台常量
settings.py 系统设置读写(大模型 + 学习方式)
runtime.py 运行状态 RUNS + 统一日志 log()
store.py SQLite:账号 / 设置 / 日志 / 状态 / 缓存 / 快照
models.py HTTP 请求模型(Pydantic)
settings_store.py 系统设置读写
llm.py 大模型调用(OpenAI 兼容)
quiz.py 测验解析 / 交卷 / AI 答题(含 /api/quiz/*)
database/ SQLite 存储(SQLAlchemy ORM)
models.py ORM 模型:settings / kv / accounts / login_log / run_log / run_state / snapshots
__init__.py engine / Session + 读写函数(账号、设置、日志、状态、缓存、快照)
models.py 通用 HTTP 请求模型(Pydantic);测验请求模型在 plugins/guanyixue/quiz.py
llm/ 大模型调用(OpenAI 兼容),后续大模型代码都放这里
bank.py 题库匹配 + 大模型兜底(与平台无关):只决定「这题选什么」,不碰平台页面 / 接口
engine.py 账号级编排:课程循环、会话续期、按 platform 分发
capture.py 抓包
platforms/ 平台运行时(见下)
base.py Platform 契约(id / label / 槽位 / capabilities)
contract.py 生命周期 ↔ 槽位 ↔ 能力名映射
errors.py SessionExpired(平台层错误类型)
registry.py 注册表:register / get / detect / all
pugins/ 插件式平台(加载器扫目录,新增插件不改代码)
plugins/ 插件式平台(加载器扫目录,新增插件不改代码)
__init__.py load_plugins / discover / set_enabled / reload(启停 + 热重载)
gdgxk/
pugin.json 注册信息:id / title / name(只放元数据)
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/
pugin.json 注册信息:id / title / name
plugin.json 注册信息:id / title / name
main.py 莞易学:Platform 子类,按 generation 分发
jspx.py 旧版页面:会话 / 登录 / 课程抓取 / 旧版学习流程
study.py 新版 SPA:StudyClient / 接口学习 / 视频流程
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 新版)
routes/ HTTP 接口
script/ 插件专属脚本:des.js、desenc.js(Node 调用)、study_video.py(命令行刷视频)
capture.py 抓包调试:把页面 XHR/fetch 落成 data/capture-*.json
quiz.py 测验解析 / 交卷 / AI 答题(含 /api/quiz/*、测验请求模型、答题提示词、题型名)
routes/ HTTP 接口(load_routes() 启动时扫描本目录,新增文件自动注册)
accounts.py 账号 / 登录 / 登录记录
misc.py 课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 平台列表
logs.py /api/log、/api/runs
settings.py /api/settings
llm.py /api/llm/test
web/ 前端源码(React 19 + TypeScript + Vite + Tailwind CSS v4)
static/ 前端构建产物(index.html + assets/ + favicon.svg,提交进仓库)
script/ 服务端 JS 脚本:des.js、desenc.js(Node 调用)
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` 与启动,后者组装应用。
- **接口层**:`routes/*` 与 `quiz.py` 里的 `/api/quiz/*`,只做参数校验、调用引擎、返回结果。
- **领域层**:`engine.py`(账号级编排:课程循环 / 会话续期 / 按平台分发)、`platforms/*`(平台实现,含各自的学习流程)、`llm.py` / `quiz.py` 的解析逻辑。
- **基础设施**:`store.py`(SQLite)、`runtime.py`(状态 + 日志)、`config.py`。
- **接口层**:`routes/*` 与 `plugins/guanyixue/quiz.py` 里的 `/api/quiz/*`,只做参数校验、调用引擎、返回结果。
- **领域层**:`engine.py`(账号级编排:课程循环 / 会话续期 / 按平台分发)、`platforms/*`(平台运行时)、`plugins/*`(平台实现,含各自的学习流程)、`notify.py`(故障上报 / 推送渠道注册)、`llm/` 与 `plugins/guanyixue/quiz.py` 的解析逻辑。
- **基础设施**:`database/`(SQLAlchemy ORM + SQLite)、`runtime.py`(状态 + 日志)、`config/`(常量 + 设置)。
依赖方向单向:接口 → 领域 → 基础设施。基础设施不反向 import 接口。
`engine.py` 与 `routes/*` 只用 `registry.get(...)` 取平台实现,不 import 具体平台模块;
平台实现之间只有同平台不同代际会互相 import(`study.py` → `jspx.py`),且用函数内延迟导入避免成环。
平台实现之间只有同平台不同代际会互相 import(`course.py` → `spa.py`),依赖保持单向,避免成环。
## 平台插件与识别
平台实现在 `platforms/` 或插件目录 `pugins/<名字>/`,都通过注册表注入,`engine` 只按
平台实现在 `platforms/` 或插件目录 `plugins/<名字>/`,都通过注册表注入,`engine` 只按
`course["platform"]` 分发:
```python
@@ -77,18 +92,18 @@ pf.study(key, s, d, workers=..., cap=..., enable_quiz=..., ...)
```python
class Platform:
id = "" # course["platform"] 的取值
label = "" # 日志 / 界面展示名,如「莞易学」
entries = () # 登录入口 / 学习入口的 URL 片段
id = "" # course["platform"] 的取值
label = "" # 日志 / 界面展示名,如「莞易学」
entries = () # 登录入口 / 学习入口的 URL 片段
# 槽位:子类实现哪个阶段就覆盖成方法(第一个参数是 self);保持 None = 该平台没有这项能力
study = None # study(self, key, s, d, **opts) -> None
session = None # session(self, cookies) -> requests.Session
dump_cookies = None # dump_cookies(self, session) -> [cookie]
login = None # login(self, username, password) -> (session, user)
list_courses = None # list_courses(self, session) -> [course]
discover = None # discover(self, session, plan_id, project_id, name) -> 课程描述
renew_session = None # renew_session(self, session) -> bool,无密码续期
profile = None # profile(self, session) -> 显示名
study = None # study(self, key, s, d, **opts) -> None
session = None # session(self, cookies) -> requests.Session
dump_cookies = None # dump_cookies(self, session) -> [cookie]
login = None # login(self, username, password) -> (session, user)
list_courses = None # list_courses(self, session) -> [course]
discover = None # discover(self, session, plan_id, project_id, name) -> 课程描述
renew_session = None # renew_session(self, session) -> bool,无密码续期
profile = None # profile(self, session) -> 显示名
# capabilities() 按非空槽位推导能力名
```
@@ -108,7 +123,17 @@ quiz_key / cookies / plan / project / name`。插件可以只取自己需要的
- 查询课程:`list_courses`
- 课程详情:`discover`
- 学习:`study`
- 测验:暂未进契约(`quiz.py` 仍是莞易学专用),下沉为平台能力后再加 `quiz`
- 测验:`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`。
@@ -118,18 +143,19 @@ quiz_key / cookies / plan / project / name`。插件可以只取自己需要的
### 内置:莞易学
`jspx.dgjy.net`(登录入口)和 `study.dgjy.net`(学习入口)是同一个平台,统一注册为
`id=guanyixue`、`label=莞易学`,实现在 `pugins/guanyixue/`。两代课程页:
`id=guanyixue`、`label=莞易学`,实现在 `plugins/guanyixue/`。两代课程页:
- `jspx.py`:旧版页面(`tier-tree-one` / `tier3`),负责会话、登录、课程抓取,
以及旧版学习流程(`_study_builtin`:反馈表 + 测验 + 刷课件)。
- `study.py`:新版 Vue SPA,`StudyClient` + `/train/course/front/*` 接口,
- `utils/http.py` / `utils/login.py`:会话、cookie、账号密码登录与 CASTGC 续期。
- `utils/course.py`:旧版课程页(`tier-tree-one` / `tier3`)的课程抓取与代际分发。
- `utils/feedback.py` / `utils/learn.py`:旧版反馈表、测验与刷课件(`_study_builtin`)。
- `utils/spa.py`:新版 Vue SPA,`StudyClient` + `/train/course/front/*` 接口,
以及新版学习流程(`run_study_course`:接口上报 / 真播视频)。
- `main.py`:注册平台并按 `course["generation"]` 分发;`detect.py` 只判断页面代际,
不判断平台归属。
### 内置:广东公需课
`id=gdgxk`,`pugins/gdgxk/` 实现(插件目录,`pugin.json` 只放 id / title / name,`main.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`;视频按真实
@@ -137,11 +163,13 @@ quiz_key / cookies / plan / project / name`。插件可以只取自己需要的
### 新增一个平台
1. 新建 `src/study_tool/pugins/<名字>/`,写 `pugin.json`(`id` / `title` / `name`)和 `main.py`:
1. 新建 `../src/study_tool/plugins`,写 `plugin.json`(`id` / `title` / `name`)和 `main.py`:
```python
from study_tool.platforms import registry
from study_tool.platforms.base import Platform
from study_tool.platforms import registry
from study_tool.platforms import Platform
class MyPlatform(Platform):
id = "<平台 id>"
@@ -160,20 +188,49 @@ quiz_key / cookies / plan / project / name`。插件可以只取自己需要的
# 日志一律 runtime.log(key, ...)
...
PLATFORM = registry.register(MyPlatform())
```
平台确实没有的阶段不要写,保持基类的 `None`,引擎按能力判断走不走那个分支。
2. 不用改任何代码:`load_plugins()` 扫目录时会自动 import 这个插件。实现复杂时可以像
`pugins/guanyixue/` 那样在同一目录再拆 `jspx.py` / `study.py` 等实现模块。
`plugins/guanyixue/` 那样把工具按阶段拆到 `utils/`(http / login / course / learn / spa)。
3. 平台注册后会自动出现在「添加账号」的平台下拉框;账号选了该平台,登录 / 刷新课程 /
发现课程 / 学习都走它的实现,不需要改路由或前端。
已内置莞易学(`guanyixue`)和广东公需课(`gdgxk`),都在 `pugins/` 下;`accounts.platform` 是
已内置莞易学(`guanyixue`)和广东公需课(`gdgxk`),都在 `plugins/` 下;`accounts.platform` 是
平台分发依据,接入新平台只需建一个插件目录,引擎与路由不用改。
## 故障推送
`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/`(呆呆面板)。
## 前端
`web/` 是独立的前端工程:React 19 + TypeScript + Vite + Tailwind CSS v4。