Files
study-tool/docs/architecture.md
T
study-tool 491fd08c1e refactor(plugins): 莞易学迁到 pugins/guanyixue
- platforms/guanyixue.py → pugins/guanyixue/main.py
- jspx.py / study.py / detect.py 一并搬进插件目录,插件自包含:
  热重载会把实现模块一起重新 import,不再是半截插件
- 导入路径改成 study_tool.pugins.guanyixue.*;quiz.py 两处引用同步更新
- platforms/__init__.py 只剩运行时,load_builtin() 只调 load_plugins()
- 新增 pugins/guanyixue/pugin.json(id / title / name)
- 同步 AGENTS.md、docs/architecture.md、docs/pugin.md、docs/OPTIMIZATION.md
2026-10-31 16:18:45 +08:00

194 lines
11 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
pugins/ 插件式平台(加载器扫目录,新增插件不改代码)
__init__.py load_plugins / discover / set_enabled / reload(启停 + 热重载)
gdgxk/
pugin.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 心跳上报
guanyixue/
pugin.json 注册信息:id / title / name
main.py 莞易学:Platform 子类,按 generation 分发
jspx.py 旧版页面:会话 / 登录 / 课程抓取 / 旧版学习流程
study.py 新版 SPA:StudyClient / 接口学习 / 视频流程
detect.py 页面代际识别(legacy 旧版 / spa 新版)
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=莞易学`,实现在 `pugins/guanyixue/`。两代课程页:
- `jspx.py`:旧版页面(`tier-tree-one` / `tier3`),负责会话、登录、课程抓取,
以及旧版学习流程(`_study_builtin`:反馈表 + 测验 + 刷课件)。
- `study.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`
只放 `Platform` 子类,登录 / 解析 / 心跳工具在 `utils/`):
在 `jsglpt.gds.edu.cn` 登录(账号、密码
都是 base64),再用 `ssoItlmsGxkTicket.action` 换票进 `jsxx.gds.edu.cn`;视频按真实
流逝时间上报 `updateViewTime1`。没有测验 / 反馈表,所以不实现对应能力。
### 新增一个平台
1. 新建 `src/study_tool/pugins/<名字>/`,写 `pugin.json`(`id` / `title` / `name`)和 `main.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. 不用改任何代码:`load_plugins()` 扫目录时会自动 import 这个插件。实现复杂时可以像
`pugins/guanyixue/` 那样在同一目录再拆 `jspx.py` / `study.py` 等实现模块。
3. 平台注册后会自动出现在「添加账号」的平台下拉框;账号选了该平台,登录 / 刷新课程 /
发现课程 / 学习都走它的实现,不需要改路由或前端。
已内置莞易学(`guanyixue`)和广东公需课(`gdgxk`),都在 `pugins/` 下;`accounts.platform` 是
平台分发依据,接入新平台只需建一个插件目录,引擎与路由不用改。
## 前端
`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()`。