feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
+289
-12
@@ -2,6 +2,264 @@
|
||||
|
||||
已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。
|
||||
|
||||
## 发版体检修复(`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
|
||||
|
||||
**状态**:已实现,待实测确认。
|
||||
@@ -11,7 +269,7 @@
|
||||
- 修问题:以前只有 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
|
||||
读环境变量(`store.py` 是 import 时就读 `DB_PATH` / `RUN_LOG_KEEP`)。已存在的环境变量优先,Docker 由 compose 注入的不受影响。
|
||||
读环境变量(`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 都读」。
|
||||
@@ -23,17 +281,36 @@
|
||||
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` → `pugins/guanyixue/main.py`;`jspx.py` / `study.py` / `detect.py`
|
||||
一并搬进 `pugins/guanyixue/`,插件自包含,热重载能覆盖全部实现模块。
|
||||
- 导入路径改成 `study_tool.pugins.guanyixue.*`;`quiz.py` 里对 `jspx` / `study` 的引用同步更新。
|
||||
- `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()`。
|
||||
- 新增 `pugins/guanyixue/pugin.json`(id / title / name)。
|
||||
- 新增 `plugins/guanyixue/plugin.json`(id / title / name)。
|
||||
|
||||
### 怎么测
|
||||
|
||||
@@ -47,8 +324,8 @@
|
||||
|
||||
### 做了什么
|
||||
|
||||
- `pugins/__init__.py` 重写:`load_plugins()` / `discover()` / `set_enabled()` / `reload()`。
|
||||
- 热加载:把 `study_tool.pugins.<名字>`(含子模块)从 `sys.modules` 摘掉后重新 import,
|
||||
- `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` 与账号平台下拉同步消失。
|
||||
@@ -63,7 +340,7 @@
|
||||
|
||||
1. `GET /api/plugins` 列出 `gdgxk`(enabled / loaded 为 true,capabilities 七项)。
|
||||
2. `POST /api/plugins/gdgxk/disable` → `GET /api/platforms` 里 gdgxk 消失;再 enable 回来。
|
||||
3. 改 `pugins/gdgxk/main.py` 里一句日志文案 → `POST /api/plugins/gdgxk/reload` →
|
||||
3. 改 `plugins/gdgxk/main.py` 里一句日志文案 → `POST /api/plugins/gdgxk/reload` →
|
||||
不重启服务再跑一次学习,日志应变成新文案。
|
||||
4. 重启服务:停用状态应被记住(设置项 `plugins`)。
|
||||
5. 前端顶栏「插件管理」:停用 gdgxk → 「添加账号」的平台下拉立刻少一项;重新启用后恢复。
|
||||
@@ -76,15 +353,15 @@
|
||||
|
||||
- `Platform` 从 dataclass「注册时逐项传函数」改成基类:`id` / `label` / `entries` 是类属性,
|
||||
能力是子类方法;没实现的阶段保持基类的 `None`,`capabilities()` 与 `/api/platforms` 不变。
|
||||
- `pugins/gdgxk/main.py`(原 `platforms/gdgxk.py`):`session`(原 `new_session`)/ `login` /
|
||||
- `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`。
|
||||
- `pugins/` 改成插件目录:`pugins/<名字>/pugin.json`(只放 `id` / `title` / `name`)+ `main.py`
|
||||
(`Platform` 子类);`pugins/__init__.py::load_plugins()` 扫目录 import,`load_builtin()` 调它。
|
||||
- `plugins/` 改成插件目录:`plugins/<名字>/plugin.json`(只放 `id` / `title` / `name`)+ `main.py`
|
||||
(`Platform` 子类);`plugins/__init__.py::load_plugins()` 扫目录 import,`load_builtin()` 调它。
|
||||
插件里不再声明任何能力绑定。
|
||||
- 引擎 / 路由 / `quiz.py` / 前端 / `platforms/contract.py` 均未改动。
|
||||
- 新增 [docs/pugin.md](plugin.md):插件骨架、槽位契约、新增与迁移流程、常见坑。
|
||||
- 新增 [docs/plugin.md](plugin.md):插件骨架、槽位契约、新增与迁移流程、常见坑。
|
||||
|
||||
### 怎么测
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# 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、密钥可清空、推送失败不占整段冷却、目录名文案统一 | 已完成,待实测 |
|
||||
+98
-41
@@ -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。
|
||||
|
||||
+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`:契约的最终定义(有疑问以代码为准)。
|
||||
+14
-6
@@ -1,12 +1,20 @@
|
||||
# 后续待办
|
||||
|
||||
- 接入第二个学习平台时,在 `platforms/<平台>.py` 里实现 `session` / `login` / `list_courses` /
|
||||
`discover` / `study` 并注册;账号在「添加账号」里选平台后即可用,登录与课程发现不需要再改路由。
|
||||
- 接入第二个学习平台时,建 `plugins/<名字>/`(`plugin.json` + `main.py` 的 `Platform` 子类),实现
|
||||
`session` / `login` / `list_courses` / `discover` / `study` 并注册;账号在「添加账号」里选平台后即可用,登录与课程发现不需要再改路由。
|
||||
- 前端补一条自动化回归(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`,
|
||||
并校验 `static/` 与源码同步。
|
||||
- 广东公需课:用一门**未学完**的课实测进度上报,确认平台端「已学时长 / 已完成」状态跟着变。
|
||||
- 广东公需课:**试卷考核**未接入 —— 平台判定通过要「所有教学活动 100% + 试卷考核 ≥60 分」,所以视频报满后课程仍显示「未完成」。
|
||||
- 广东公需课:视频弹题未接入。
|
||||
- 平台契约第三步:把答题下沉为平台能力(`quiz`),`quiz.py` 只留 LLM 与通用交卷;前端按 `/api/platforms`
|
||||
返回的 `capabilities` 显示答题 / 反馈按钮。
|
||||
- 广东公需课:**试卷考核**已接入(`utils/exam.py`:解析内嵌题面 → 题库/AI 答题 → `finishTest2` 交卷),待用
|
||||
一门只差考核的课实测交卷响应与得分。
|
||||
- 广东公需课:视频弹题仍未接入;`answer.py` 的两套按序答案留作考核备选,暂未使用。
|
||||
- 测验 `/api/quiz/submit` 已改成按 `registry.get(platform).quiz_submit` 分发(公需课也能交卷),但 `/api/quiz*` 三个接口仍由莞易学插件注册:
|
||||
停用莞易学后公需课的「打开测验」会 404。要让接口彻底跟插件解耦,需把它们挪到 `routes/`,并把 AI 答题的提示词 / 解析一起下沉到核心。
|
||||
- `engine.py` 的 legacy / spa 日志标签、`enable_quiz` / `optQuiz` 字段、`snapshots` 表仍带着莞易学的概念,
|
||||
有第二个平台再决定是否抽象。
|
||||
- 推送渠道目前只有呆呆面板(`plugins/daidai_push/`):要接 WxPusher / Telegram / 邮件,按
|
||||
`"kind": "notify"` 再建一个插件目录即可,核心不用改;`notify.send()` 会发给所有已加载渠道。
|
||||
|
||||
-205
@@ -1,205 +0,0 @@
|
||||
# 学习平台插件开发文档
|
||||
|
||||
分层与整体结构见 [architecture.md](architecture.md),本文只讲「怎么写一个平台插件」。
|
||||
|
||||
一个平台 = 一个 `Platform` 子类:类属性放元数据,自己确实有的阶段实现成方法,模块末尾
|
||||
注册一次。插件式平台放 `src/study_tool/pugins/<名字>/`(`pugin.json` + `main.py`),由加载器
|
||||
扫目录自动加载;跟共享实现模块关系紧密的放 `src/study_tool/platforms/<名字>.py`。
|
||||
引擎、路由、前端都不需要改。
|
||||
|
||||
## 插件骨架
|
||||
|
||||
```python
|
||||
"""<平台名>:登录 → 发现 → 学习。"""
|
||||
import requests
|
||||
|
||||
from study_tool import runtime
|
||||
from study_tool.platforms import registry
|
||||
from study_tool.platforms.base import Platform
|
||||
from study_tool.platforms.errors import SessionExpired
|
||||
|
||||
UA = "Mozilla/5.0 ..."
|
||||
DOMAINS = ("<平台域名>",)
|
||||
|
||||
|
||||
class MyPlatform(Platform):
|
||||
id = "<平台 id>" # 写进 accounts.platform / course["platform"]
|
||||
label = "展示名" # 日志与界面展示
|
||||
entries = ("<登录入口域名>", "<学习入口域名>") # 识别平台归属
|
||||
|
||||
def session(self, cookies=None):
|
||||
s = requests.Session()
|
||||
s.headers.update({"User-Agent": UA})
|
||||
for c in cookies or []:
|
||||
if not any(d in (c.get("domain") or "") for d in DOMAINS):
|
||||
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()
|
||||
# ... 登录失败抛 RuntimeError;会话失效抛 SessionExpired
|
||||
return s, self.profile(s)
|
||||
|
||||
def list_courses(self, s):
|
||||
# -> [{"name", "plan_id", "project_id", "in_study", ...}]
|
||||
...
|
||||
|
||||
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()` 按「子类是否把它覆盖成
|
||||
方法」推导能力名,前端据此决定显示哪些按钮。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/study_tool/pugins/
|
||||
__init__.py 加载器:load_plugins / discover / set_enabled / reload
|
||||
<名字>/
|
||||
pugin.json 注册信息:id / title / name(不写能力绑定)
|
||||
main.py class <名字>(Platform)
|
||||
gdgxk/ 广东公需课:pugin.json + main.py + utils/(拆开的实现模块)
|
||||
guanyixue/ 莞易学:pugin.json + main.py + jspx.py / study.py / detect.py
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- 一个平台只注册一次;注册发生在模块被 import 时,模块里除注册外不要有副作用。
|
||||
- 平台 id = 模块里 `id` = `accounts.platform` = `course["platform"]`,三处必须一致。
|
||||
- 同一平台不同页面代际拆成同目录下的实现模块(`jspx.py` / `study.py`),由平台类按
|
||||
`course["generation"]` 分发,不要一个代际注册一个平台。
|
||||
- 平台私有的工具函数放同一模块或同平台的实现模块里;跨平台共用的能力放 `base.py` /
|
||||
`contract.py` / `errors.py`。
|
||||
|
||||
## 加载与启停
|
||||
|
||||
加载器在 `pugins/__init__.py`,对外四个函数 + 对应 HTTP 接口:
|
||||
|
||||
| 函数 | 接口 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `load_plugins()` | 启动时自动调用 | 按启用清单 import 全部插件 |
|
||||
| `discover()` | `GET /api/plugins` | 磁盘清单 + 是否启用 / 已加载 / label / 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` 消失,账号的平台下拉里也不会出现。
|
||||
热重载先把旧实现从注册表摘掉再 import;正在跑的学习任务持有的是旧实例,跑到结束不受影响。
|
||||
前端顶栏的「插件管理」弹窗(`web/src/components/PluginsDialog.tsx`)就是这几个接口的界面。
|
||||
|
||||
## 槽位契约
|
||||
|
||||
| 槽位 | 方法签名 | 调用方 | 要求 |
|
||||
| --- | --- | --- | --- |
|
||||
| `study` | `(self, key, s, d, **opts)` | `engine.run_one_course` | 必填 |
|
||||
| `discover` | `(self, s, plan_id, project_id, name)` | `run_one_course`、`/api/discover` | 学习前必填 |
|
||||
| `session` | `(self, cookies) -> requests.Session` | 引擎与各路由 | 强烈建议(缺失直接报错) |
|
||||
| `dump_cookies` | `(self, s) -> [cookie]` | 登录 / 刷新 / 发现后回写前端 | 强烈建议 |
|
||||
| `login` | `(self, username, password) -> (session, user)` | `/api/login` | 要密码登录则必填 |
|
||||
| `list_courses` | `(self, s) -> [course]` | 登录后、`/api/courses` | 要刷新课程则必填 |
|
||||
| `renew_session` | `(self, s) -> bool` | `SessionExpired` 时无密码续期 | 选填 |
|
||||
| `profile` | `(self, s) -> 显示名` | 登录后卡片显示 | 选填 |
|
||||
|
||||
`study` 收到的是 `discover` 的返回值(`d`),必须包含:
|
||||
|
||||
- `platform`:本平台 id(引擎据此再次取实现)
|
||||
- `course` / `progress` / `info` / `user`:课程名、进度、说明、显示名
|
||||
- `courseware` / `feedback` / `test`:三类活动列表,元素至少含 `title`、`href` / `period_id`、`done`
|
||||
- 可选:`generation`(`legacy` / `spa`)、`train_id`、`page_url`、`need_join`
|
||||
|
||||
`opts` 固定包含 `workers / cap / enable_quiz / enable_feedback / mode / parallel /
|
||||
quiz_key / cookies / plan / project / name`,插件只取自己需要的。
|
||||
|
||||
## 开发流程
|
||||
|
||||
1. 新建 `src/study_tool/pugins/<名字>/`,放两个文件:`pugin.json`(`id` / `title` / `name`)
|
||||
和 `main.py`(按上面的骨架写 `class <名字>(Platform)`)。
|
||||
2. `main.py` 末尾 `PLATFORM = registry.register(<名字>())`。
|
||||
3. 不需要改 `load_builtin()`、路由或前端 —— 加载器扫目录时自动 import 各插件。
|
||||
实现复杂时可以在同一目录再拆 `jspx.py` / `study.py` 这类实现模块(见 `guanyixue/`)。
|
||||
4. 启动服务,`GET /api/platforms` 应出现该平台。
|
||||
5. 联调顺序:`/api/login` → `/api/courses` → `/api/discover` → `/api/study`。
|
||||
|
||||
## 把已有实现改成子类
|
||||
|
||||
老写法(自由函数 + 逐项注册)已经废弃,迁移步骤:
|
||||
|
||||
1. 保留模块级纯函数(`_b64` / `_ocr` / `_parse_courses` 等),它们不属于契约。
|
||||
2. 把原来的槽位自由函数改成同名方法:加 `self`,模块内互相调用改成 `self.xxx`,
|
||||
平台私有的重登逻辑改成 `_relogin` 这类私有方法。
|
||||
3. 加类属性 `id` / `label` / `entries`,删掉模块末尾的逐项注册。
|
||||
4. 模块末尾改成 `PLATFORM = registry.register(<类名>())`。
|
||||
5. 实现模块(`jspx.py` / `study.py` / `detect.py`)一起搬进 `pugins/<名字>/`,目录内互相
|
||||
引用改成 `study_tool.pugins.<名字>.xxx`;外部(如 `quiz.py`)引用同步改路径。
|
||||
6. 逐个验证槽位行为与迁移前一致;同一平台的两个代际由 `study` 里按
|
||||
`course["generation"]` 分发(参见 `pugins/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,
|
||||
模块级互相 import 成环时改用函数内延迟导入(参见 `study.py` ↔ `jspx.py`)。
|
||||
- cookie 域名过滤在平台自己的 `session` 里做,引擎不再兜底:漏写 `session` 会直接报
|
||||
「平台没有实现 session」,不会静默过滤。
|
||||
- 时间、解析、编码这类通用能力用现成库,不要手写。
|
||||
|
||||
## 联调检查表
|
||||
|
||||
- [ ] `GET /api/platforms` 里能看到该平台,`label` 与 `capabilities` 正确
|
||||
- [ ] 添加账号 → 选平台 → 登录成功,卡片显示 `profile` 名字
|
||||
- [ ] 刷新课程返回非空列表,`in_study` 标记正确
|
||||
- [ ] 发现课程返回的活动数与页面一致
|
||||
- [ ] 开始学习:日志出现在该账号卡片里,进度递增
|
||||
- [ ] 点停止后几秒内循环退出
|
||||
- [ ] 会话失效时能走到续期 / 重登,不需要手动刷新
|
||||
- [ ] 平台没实现 `session` 时,`/api/courses` 报「还没有实现 session」
|
||||
|
||||
## 常见坑
|
||||
|
||||
- **自由函数改方法后忘加 `self`**:模块内原来的 `_relogin(key)` 必须写成 `self._relogin(key)`,
|
||||
否则运行时才报错;提交前可用 `symtable` 扫一遍模块的未定义全局名。
|
||||
- **注册写两遍**:既在类里又在模块末尾逐项注册,会导致同一个 id 被覆盖,`capabilities`
|
||||
也可能和实际方法不一致。只保留 `registry.register(<类名>())`。
|
||||
- **cookie 域写错**:`session` 里过滤的是保存下来的 cookie,域名不匹配会得到空会话,
|
||||
表现为「登录成功但刷新课程提示未登录」。
|
||||
- **日志 key 用错**:平台用自己拼的字符串当 key,日志会跑进别的卡片或 `system` 弹窗。
|
||||
- **把代际当平台**:旧版 / 新版注册成两个平台,账号绑定会漂移;应该在
|
||||
`discover` 里写 `generation`,由 `study` 分发。
|
||||
|
||||
## 方案演进
|
||||
|
||||
1. 早期:平台实现直接写在 `engine.py` 里。
|
||||
2. 拆包:`platforms/base.py` 定义 `Platform` 契约 + `registry` 注册表,引擎按
|
||||
`course["platform"]` 分发;平台用自由函数 + 逐项注册;学习流程仍在引擎。
|
||||
3. 契约补全:`contract.py` 推导 `capabilities`;`SessionExpired` 移到 `platforms/errors.py`;
|
||||
各平台学习流程从引擎搬回平台模块(`engine.py` 只剩账号级编排)。
|
||||
4. 类式:`Platform` 变成普通基类,平台写成子类(类属性 + 方法),禁止逐项传函数注册。
|
||||
5. 插件目录(当前):平台全部迁到 `pugins/<名字>/`(`pugin.json` + `main.py`,实现复杂的
|
||||
再拆 `jspx.py` / `study.py` 等),`load_plugins()` 扫目录加载,支持启停与热重载;
|
||||
`gdgxk`、`guanyixue` 都已完成迁移。
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## 待评估:接入广东公需课平台(jsxx.gds.edu.cn)
|
||||
|
||||
**状态**:已接入,见 `src/study_tool/platforms/gdgxk.py` 与 `OPTIMIZATION.md`;登录 / 课程列表 / 进度心跳已实测,待用未学完课程确认。
|
||||
**状态**:已接入,见 `../src/study_tool/platforms/gdgxk.py` 与 `OPTIMIZATION.md`;登录 / 课程列表 / 进度心跳已实测,待用未学完课程确认。
|
||||
|
||||
### 背景
|
||||
|
||||
|
||||
Reference in New Issue
Block a user