f1a57853f4
- platforms/gdgxk.py → pugins/gdgxk/main.py(实现不变,仍是 class Gdgxk(Platform)) - pugins/gdgxk/pugin.json 只放注册信息 id / title / name,不声明任何能力绑定 - pugins/__init__.py:load_plugins() 扫 pugins/*/pugin.json、import 各自的 main.py, 再用 pugin.json 的 id 校验注册结果;要换加载方式只改这一个函数 - platforms/__init__.py::load_builtin() 调 load_plugins();guanyixue 依赖 jspx/study, 暂留 platforms/ - engine / routes / quiz / contract / 前端未改动 - 同步 AGENTS.md、docs/architecture.md、docs/OPTIMIZATION.md
186 lines
10 KiB
Markdown
186 lines
10 KiB
Markdown
# 架构说明
|
||
|
||
## 目录
|
||
|
||
```
|
||
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():扫 pugins/*/pugin.json 并 import main.py
|
||
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()`。
|