Files
study-tool/docs/architecture.md
T
study-tool f8458cff51 feat(plugins): 插件支持热加载与启用/停用清单
- pugins/__init__.py 重写:load_plugins / discover / set_enabled / reload
- 热加载:把 study_tool.pugins.<名字> 的模块从 sys.modules 摘掉后重新 import;
  重载前先 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
- 同步 docs/pugin.md(新增「加载与启停」)、docs/architecture.md、docs/OPTIMIZATION.md、AGENTS.md
2026-10-31 15:50:06 +08:00

186 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构说明
## 目录
```
app.py 启动壳:把 src 加进 sys.path,调用 study_tool.app.main()
pyproject.toml 打包配置(src 布局,console script: study-tool)
src/study_tool/
__init__.py 包信息(版本)
__main__.py python -m study_tool 入口
app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main()
webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入)
config.py 常量 / 环境变量 / 根目录定位(data、static、script)
runtime.py 运行状态 RUNS + 统一日志 log()
store.py SQLite:账号 / 设置 / 日志 / 状态 / 缓存 / 快照
models.py HTTP 请求模型(Pydantic)
settings_store.py 系统设置读写
llm.py 大模型调用(OpenAI 兼容)
quiz.py 测验解析 / 交卷 / AI 答题(含 /api/quiz/*)
engine.py 账号级编排:课程循环、会话续期、按 platform 分发
capture.py 抓包
platforms/ 平台运行时(见下)
base.py Platform 契约(id / label / 槽位 / capabilities)
contract.py 生命周期 ↔ 槽位 ↔ 能力名映射
errors.py SessionExpired(平台层错误类型)
registry.py 注册表:register / get / detect / all
detect.py 页面代际识别(legacy 旧版 / spa 新版)
guanyixue.py 莞易学平台:合并 jspx 登录入口与 study 学习入口
jspx.py 莞易学旧版:会话 / 登录 / 课程抓取 / 旧版学习流程
study.py 莞易学新版 SPA:StudyClient / 接口学习 / 视频流程
pugins/ 插件式平台(加载器扫目录,新增插件不改代码)
__init__.py load_plugins / discover / set_enabled / reload(启停 + 热重载)
gdgxk/
pugin.json 注册信息:id / title / name(只放元数据)
main.py 广东公需课:SSO 换票 + 视频进度心跳
routes/ HTTP 接口
accounts.py 账号 / 登录 / 登录记录
misc.py 课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 平台列表
logs.py /api/log、/api/runs
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
```
## 分层
- **入口层**:`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`。
依赖方向单向:接口 → 领域 → 基础设施。基础设施不反向 import 接口。
`engine.py` 与 `routes/*` 只用 `registry.get(...)` 取平台实现,不 import 具体平台模块;
平台实现之间只有同平台不同代际会互相 import(`study.py` → `jspx.py`),且用函数内延迟导入避免成环。
## 平台插件与识别
平台实现在 `platforms/` 或插件目录 `pugins/<名字>/`,都通过注册表注入,`engine` 只按
`course["platform"]` 分发:
```python
# engine.run_one_course 末尾
pf = registry.get(d.get("platform"))
pf.study(key, s, d, workers=..., cap=..., enable_quiz=..., ...)
```
`Platform` 契约(见 `platforms/base.py`):一个平台 = 一个 `Platform` 子类,类属性放元数据,
自己确实有的阶段实现成方法;注册时只实例化子类,不再逐项绑定函数。
```python
class Platform:
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) -> 显示名
# capabilities() 按非空槽位推导能力名
```
`registry.detect(url, html)` 按 `entries` 命中平台;「添加账号」时选择的平台写进
`accounts.platform`,之后登录 / 刷新课程 / 发现课程都用 `registry.get(account["platform"])`
取实现。课程发现时把平台 id 写进 `course["platform"]`,把页面代际写进
`course["generation"]`(`legacy` / `spa`);引擎按平台分发,平台内部再按代际选择学习实现。
`opts` 固定包含:`workers / cap / enable_quiz / enable_feedback / mode / parallel /
quiz_key / cookies / plan / project / name`。插件可以只取自己需要的。
### 生命周期与能力
阶段与槽位对应,缺的阶段不实现即可(不要抛 `NotImplementedError`):
- 登录 / 会话:`login`、`session`、`renew_session`、`profile`
- 查询课程:`list_courses`
- 课程详情:`discover`
- 学习:`study`
- 测验:暂未进契约(`quiz.py` 仍是莞易学专用),下沉为平台能力后再加 `quiz`
`Platform.capabilities()` 按非空槽位推导能力名(映射见 `platforms/contract.py`):
`login` / `session` / `renew` / `profile` / `courses` / `discover` / `study`。
`GET /api/platforms` 一并返回 `capabilities`,前端据此决定显示哪些按钮。
流程顺序只有一个实现(引擎),插件只负责单步。
### 内置:莞易学
`jspx.dgjy.net`(登录入口)和 `study.dgjy.net`(学习入口)是同一个平台,统一注册为
`id=guanyixue`、`label=莞易学`。两代课程页:
- `platforms/jspx.py`:旧版页面(`tier-tree-one` / `tier3`),负责会话、登录、课程抓取,
以及旧版学习流程(`_study_builtin`:反馈表 + 测验 + 刷课件)。
- `platforms/study.py`:新版 Vue SPA,`StudyClient` + `/train/course/front/*` 接口,
以及新版学习流程(`run_study_course`:接口上报 / 真播视频)。
- `platforms/guanyixue.py`:注册平台并按 `course["generation"]` 分发;
`platforms/detect.py` 只判断页面代际,不判断平台归属。
### 内置:广东公需课
`id=gdgxk`,`pugins/gdgxk/main.py` 实现(插件目录,`pugin.json` 只放 id / title / name):
在 `jsglpt.gds.edu.cn` 登录(账号、密码
都是 base64),再用 `ssoItlmsGxkTicket.action` 换票进 `jsxx.gds.edu.cn`;视频按真实
流逝时间上报 `updateViewTime1`。没有测验 / 反馈表,所以不实现对应能力。
### 新增一个平台
1. 新建 `src/study_tool/platforms/<名字>.py`:
```python
from study_tool.platforms import registry
from study_tool.platforms.base 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)把课程活动跑完
# 日志一律 runtime.log(key, ...)
...
PLATFORM = registry.register(MyPlatform())
```
平台确实没有的阶段不要写,保持基类的 `None`,引擎按能力判断走不走那个分支。
2. 在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 `from study_tool.platforms import <名字>`。
3. 平台注册后会自动出现在「添加账号」的平台下拉框;账号选了该平台,登录 / 刷新课程 /
发现课程 / 学习都走它的实现,不需要改路由或前端。
已内置莞易学(`guanyixue`)和广东公需课(`gdgxk`);`accounts.platform` 是平台分发依据,
接入新平台只需在 `platforms/<平台>.py` 实现对应阶段的槽位,引擎与路由不用改。
## 前端
`web/` 是独立的前端工程:React 19 + TypeScript + Vite + Tailwind CSS v4。
- `web/src/App.tsx`:账号轮询、操作编排、弹窗状态;`components/` 放卡片 / 弹窗 / 基础 UI。
- `web/src/lib/api.ts`:统一 fetch 封装(401 / 离线判定)与 SSE 读取。
- `web/src/types.ts`:账号、课程、运行状态、测验等接口类型。
- 构建产物输出到仓库根的 `static/`(`index.html` + `assets/*` + `favicon.svg`),
FastAPI 直接托管:`/` 返回 `static/index.html`(禁止缓存),`/assets` 挂 `StaticFiles`。
- 开发:`cd web && pnpm install && pnpm dev`(5173,`/api` 代理到 `127.0.0.1:8848`)。
- 构建 / 类型检查:`pnpm build` / `pnpm typecheck`。
## 日志
统一入口 `runtime.log(key, msg, level="info")`,一次写三处:控制台、内存
(`/api/log` 给前端轮询)、SQLite(`/api/log/history`)。key 用账号 id 或 `system`。
规范见 `AGENTS.md` 的「日志规范」,不要自己 `print()`。