From aa48145557fdb452f09c41f30656708ce6824a8a Mon Sep 17 00:00:00 2001 From: study-tool Date: Sun, 1 Nov 2026 17:47:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=85=E9=9A=9C=E6=8E=A8=E9=80=81=20?= =?UTF-8?q?/=20=E5=85=AC=E9=9C=80=E8=AF=BE=E8=87=AA=E5=8A=A8=E9=87=8D?= =?UTF-8?q?=E5=AD=A6=20/=20=E6=8F=92=E4=BB=B6=E8=A7=A3=E8=80=A6=EF=BC=8C?= =?UTF-8?q?=E5=8F=91=E7=89=88=20v0.0.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。 --- .gitignore | 2 + .pre-commit-config.yaml | 29 + AGENTS.md | 14 +- CHANGELOG.md | 57 ++ Dockerfile | 2 +- README.md | 29 +- app.py | 3 +- docs/OPTIMIZATION.md | 301 ++++++- docs/agent_board.md | 14 + docs/agent_workflow.md | 0 docs/architecture.md | 139 ++- docs/plugin.md | 665 ++++++++++++++ docs/progress/todo.md | 20 +- docs/pugin.md | 205 ----- docs/todo.md | 2 +- metadata.yaml | 2 +- pyproject.toml | 30 +- requirements.txt | 1 + script/test-gdgxk.py | 41 - src/study_tool/__init__.py | 3 +- src/study_tool/__main__.py | 1 + src/study_tool/app.py | 112 ++- src/study_tool/bank.py | 122 +++ src/study_tool/config/__init__.py | 12 + src/study_tool/config/settings.py | 73 ++ src/study_tool/database/__init__.py | 387 ++++++++ src/study_tool/database/models.py | 102 +++ src/study_tool/engine.py | 180 +++- src/study_tool/{llm.py => llm/__init__.py} | 30 +- src/study_tool/models.py | 75 +- src/study_tool/notify.py | 204 +++++ src/study_tool/platforms/__init__.py | 12 +- src/study_tool/platforms/base.py | 19 +- src/study_tool/platforms/contract.py | 15 +- src/study_tool/platforms/errors.py | 3 +- src/study_tool/platforms/registry.py | 1 + src/study_tool/plugins/__init__.py | 174 ++++ .../plugins/daidai_push/__init__.py | 1 + src/study_tool/plugins/daidai_push/main.py | 111 +++ .../plugins/daidai_push/plugin.json | 6 + .../{pugins => plugins}/gdgxk/__init__.py | 2 +- src/study_tool/plugins/gdgxk/answer.py | 21 + src/study_tool/plugins/gdgxk/bank/2026.json | 486 +++++++++++ src/study_tool/plugins/gdgxk/config.json | 4 + src/study_tool/plugins/gdgxk/main.py | 304 +++++++ .../pugin.json => plugins/gdgxk/plugin.json} | 0 ...…¬需课·全自动完美版(后台保活+10分钟刷新).js | 522 +++++++++++ .../plugins/gdgxk/script/__init__.py | 0 .../plugins/gdgxk/script}/capture-jsglpt.py | 49 +- .../plugins/gdgxk/script/test-gdgxk.py | 54 ++ .../plugins/gdgxk/utils/__init__.py | 7 + src/study_tool/plugins/gdgxk/utils/bank.py | 17 + src/study_tool/plugins/gdgxk/utils/course.py | 93 ++ src/study_tool/plugins/gdgxk/utils/exam.py | 166 ++++ src/study_tool/plugins/gdgxk/utils/http.py | 19 + src/study_tool/plugins/gdgxk/utils/learn.py | 86 ++ src/study_tool/plugins/gdgxk/utils/login.py | 52 ++ .../plugins/gdgxk/utils/register.py | 57 ++ src/study_tool/plugins/guanyixue/__init__.py | 1 + .../{ => plugins/guanyixue}/capture.py | 41 +- .../{ => plugins/guanyixue}/config.py | 19 +- .../{pugins => plugins}/guanyixue/detect.py | 10 +- src/study_tool/plugins/guanyixue/main.py | 70 ++ .../guanyixue/plugin.json} | 0 .../{ => plugins/guanyixue}/quiz.py | 394 ++++++--- .../plugins/guanyixue/script/__init__.py | 0 .../plugins/guanyixue/script}/des.js | 0 .../plugins/guanyixue/script}/desenc.js | 0 .../plugins/guanyixue/script/study_video.py | 130 +-- .../plugins/guanyixue/utils/__init__.py | 1 + .../plugins/guanyixue/utils/course.py | 238 +++++ .../plugins/guanyixue/utils/feedback.py | 107 +++ .../plugins/guanyixue/utils/http.py | 39 + .../plugins/guanyixue/utils/learn.py | 96 ++ .../plugins/guanyixue/utils/login.py | 372 ++++++++ .../guanyixue/utils/spa.py} | 54 +- src/study_tool/pugins/__init__.py | 133 --- src/study_tool/pugins/gdgxk/main.py | 401 --------- src/study_tool/pugins/guanyixue/__init__.py | 1 - src/study_tool/pugins/guanyixue/jspx.py | 825 ------------------ src/study_tool/pugins/guanyixue/main.py | 46 - src/study_tool/routes/__init__.py | 13 +- src/study_tool/routes/accounts.py | 72 +- src/study_tool/routes/llm.py | 29 + src/study_tool/routes/logs.py | 15 +- src/study_tool/routes/misc.py | 180 ++-- src/study_tool/routes/query.py | 35 + src/study_tool/routes/settings.py | 92 ++ src/study_tool/runtime.py | 17 +- src/study_tool/settings_store.py | 90 -- src/study_tool/store.py | 514 ----------- src/study_tool/webapp.py | 5 +- static/assets/index-Cl1ZyI7J.css | 1 - static/assets/index-DW6yuFYE.css | 1 + static/assets/index-DrnB8PWg.js | 233 ----- static/assets/index-w-5XKYB3.js | 266 ++++++ static/index.html | 4 +- uv.lock | 148 ++++ web/src/App.tsx | 80 +- web/src/components/AccountCard.tsx | 37 +- web/src/components/AccountEditDialog.tsx | 180 ++++ web/src/components/AddAccountDialog.tsx | 10 +- web/src/components/PluginsDialog.tsx | 64 +- web/src/components/QuizDialog.tsx | 26 +- web/src/components/SettingsDialog.tsx | 396 +++++++-- web/src/components/SystemLogDialog.tsx | 7 +- web/src/components/TopBar.tsx | 164 ++-- web/src/components/ui/Button.tsx | 4 +- web/src/components/ui/ConfirmDialog.tsx | 83 ++ web/src/components/ui/LogLines.tsx | 40 + web/src/components/ui/Modal.tsx | 4 +- web/src/main.tsx | 6 +- web/src/pages/QueryPage.tsx | 147 ++++ web/src/types.ts | 2 + 114 files changed, 7779 insertions(+), 3302 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 docs/agent_board.md create mode 100644 docs/agent_workflow.md create mode 100644 docs/plugin.md delete mode 100644 docs/pugin.md delete mode 100644 script/test-gdgxk.py create mode 100644 src/study_tool/bank.py create mode 100644 src/study_tool/config/__init__.py create mode 100644 src/study_tool/config/settings.py create mode 100644 src/study_tool/database/__init__.py create mode 100644 src/study_tool/database/models.py rename src/study_tool/{llm.py => llm/__init__.py} (64%) create mode 100644 src/study_tool/notify.py create mode 100644 src/study_tool/plugins/__init__.py create mode 100644 src/study_tool/plugins/daidai_push/__init__.py create mode 100644 src/study_tool/plugins/daidai_push/main.py create mode 100644 src/study_tool/plugins/daidai_push/plugin.json rename src/study_tool/{pugins => plugins}/gdgxk/__init__.py (75%) create mode 100644 src/study_tool/plugins/gdgxk/answer.py create mode 100644 src/study_tool/plugins/gdgxk/bank/2026.json create mode 100644 src/study_tool/plugins/gdgxk/config.json create mode 100644 src/study_tool/plugins/gdgxk/main.py rename src/study_tool/{pugins/gdgxk/pugin.json => plugins/gdgxk/plugin.json} (100%) create mode 100644 src/study_tool/plugins/gdgxk/script/2026广东公需课·全自动完美版(后台保活+10分钟刷新).js create mode 100644 src/study_tool/plugins/gdgxk/script/__init__.py rename {script => src/study_tool/plugins/gdgxk/script}/capture-jsglpt.py (74%) create mode 100644 src/study_tool/plugins/gdgxk/script/test-gdgxk.py create mode 100644 src/study_tool/plugins/gdgxk/utils/__init__.py create mode 100644 src/study_tool/plugins/gdgxk/utils/bank.py create mode 100644 src/study_tool/plugins/gdgxk/utils/course.py create mode 100644 src/study_tool/plugins/gdgxk/utils/exam.py create mode 100644 src/study_tool/plugins/gdgxk/utils/http.py create mode 100644 src/study_tool/plugins/gdgxk/utils/learn.py create mode 100644 src/study_tool/plugins/gdgxk/utils/login.py create mode 100644 src/study_tool/plugins/gdgxk/utils/register.py create mode 100644 src/study_tool/plugins/guanyixue/__init__.py rename src/study_tool/{ => plugins/guanyixue}/capture.py (72%) rename src/study_tool/{ => plugins/guanyixue}/config.py (78%) rename src/study_tool/{pugins => plugins}/guanyixue/detect.py (86%) create mode 100644 src/study_tool/plugins/guanyixue/main.py rename src/study_tool/{pugins/guanyixue/pugin.json => plugins/guanyixue/plugin.json} (100%) rename src/study_tool/{ => plugins/guanyixue}/quiz.py (68%) create mode 100644 src/study_tool/plugins/guanyixue/script/__init__.py rename {script => src/study_tool/plugins/guanyixue/script}/des.js (100%) rename {script => src/study_tool/plugins/guanyixue/script}/desenc.js (100%) rename study_video.py => src/study_tool/plugins/guanyixue/script/study_video.py (73%) create mode 100644 src/study_tool/plugins/guanyixue/utils/__init__.py create mode 100644 src/study_tool/plugins/guanyixue/utils/course.py create mode 100644 src/study_tool/plugins/guanyixue/utils/feedback.py create mode 100644 src/study_tool/plugins/guanyixue/utils/http.py create mode 100644 src/study_tool/plugins/guanyixue/utils/learn.py create mode 100644 src/study_tool/plugins/guanyixue/utils/login.py rename src/study_tool/{pugins/guanyixue/study.py => plugins/guanyixue/utils/spa.py} (96%) delete mode 100644 src/study_tool/pugins/__init__.py delete mode 100644 src/study_tool/pugins/gdgxk/main.py delete mode 100644 src/study_tool/pugins/guanyixue/__init__.py delete mode 100644 src/study_tool/pugins/guanyixue/jspx.py delete mode 100644 src/study_tool/pugins/guanyixue/main.py create mode 100644 src/study_tool/routes/llm.py create mode 100644 src/study_tool/routes/query.py create mode 100644 src/study_tool/routes/settings.py delete mode 100644 src/study_tool/settings_store.py delete mode 100644 src/study_tool/store.py delete mode 100644 static/assets/index-Cl1ZyI7J.css create mode 100644 static/assets/index-DW6yuFYE.css delete mode 100644 static/assets/index-DrnB8PWg.js create mode 100644 static/assets/index-w-5XKYB3.js create mode 100644 web/src/components/AccountEditDialog.tsx create mode 100644 web/src/components/ui/ConfirmDialog.tsx create mode 100644 web/src/components/ui/LogLines.tsx create mode 100644 web/src/pages/QueryPage.tsx diff --git a/.gitignore b/.gitignore index 5fe0c5f..d02ec13 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ data/ .env .venv/ venv/ +.idea/ +.ruff_cache/ *.egg-info/ __pycache__/ *.py[cod] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..916e6aa --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +default_install_hook_types: [pre-commit, commit-msg] +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.17.3 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: check-merge-conflict + - id: detect-private-key + - id: check-added-large-files + args: [--maxkb=1024] + - repo: https://github.com/commitizen-tools/commitizen + rev: v4.19.3 + hooks: + - id: commitizen + stages: [commit-msg] + - repo: local + hooks: + - id: web-typecheck + name: web typecheck + entry: pnpm --dir web typecheck + language: system + files: ^web/ + pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 36661f3..faa4c29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,9 @@ ## 基本原则 - 先读现有代码,再动手修改,优先沿用项目已有结构和写法。 +- 先声明后动手:AI 开工前,必须在 docs/agent_board.md 或任务看板中认领路径。如果发现路径已被其他 Agent 占用,必须停下等待,不能硬改。 +- 禁止 git add -A:在共享仓库中,必须按具体文件名暂存,防止误提交其他 Agent 未完成的改动。 +- 任何任务,必须在独立的 git worktree 或独立工作区中执行,严禁在 main 工作区直接并行修改。 - 写代码保持最少行数,能简单实现就不要引入复杂抽象。 - 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。 - 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。 @@ -16,9 +19,10 @@ ## 反复提醒沉淀 -- 如果开发过程中总是遇到某个问题,或者用户反复提醒同一个注意事项,需要把该注意事项补充到本文件。 +- 如果开发过程中总是遇到某个问题,或者用户反复提醒或同一个问题或同一个注意事项被用户纠正2次以上,需要把该注意事项补充到文档`docs/agent_workflow.md`中。 - 补充时写成明确、可执行的规则,避免只写模糊描述。 -- 新规则应放到最相关的章节;找不到合适章节时放到“项目注意事项”。 +- 关于“遇到重复问题时的总结沉淀流程”及“自我进化机制”,详见 `docs/agent_workflow.md`。 +- ## 日志规范 @@ -30,9 +34,9 @@ ## 代码结构 / 平台插件 -- 业务代码都在 `src/study_tool/` 包里;根目录只放启动壳 `app.py`、`pyproject.toml`、`static/`、`data/`、`script/` 这类运行时资源。散落的 JS 脚本统一收进 `script/`,不参与运行的参考脚本放 `script/reference/`。 -- 平台实现只能放在 `src/study_tool/pugins/<名字>/`:`pugin.json` 只放 `id` / `title` / `name` 注册信息,`main.py` 放 `Platform` 子类,实现复杂的再拆 `jspx.py` / `study.py` 等同目录模块;`load_plugins()` 扫目录 import,新增插件不用改任何 Python 代码(`GET /api/plugins` 看清单,`POST /api/plugins/{name}/enable|disable` 启停,`POST /api/plugins/reload` 热重载)。`src/study_tool/platforms/` 只放运行时(`base` / `contract` / `errors` / `registry`),不再放具体平台。能力只能通过「在子类里实现同名方法」绑定,禁止再写 `Platform(id=..., study=..., session=...)` 这种逐项传函数的注册;模块末尾 `PLATFORM = registry.register(<子类>())`,一个平台只注册一次。同一平台的不同页面代际拆成插件目录内的实现模块,由平台模块按 `course["generation"]` 分发。 -- 新增平台的标准步骤:建 `pugins/<名字>/`,写 `pugin.json`(`id` / `title` / `name`)和 `main.py`(`class X(Platform)`,声明 `id` / `label` / `entries`,实现 `study(self, key, s, d, **opts)`,需要登录 / 课程列表 / 发现时再实现 `session` / `dump_cookies` / `login` / `list_courses` / `discover` / `renew_session` / `profile` 对应方法,没有的阶段不写,末尾 `PLATFORM = registry.register(X())`)→ 启动即可,不用改 `load_builtin()`、`engine.py`、`routes/`、前端。 +- 业务代码都在 `src/study_tool/` 包里;根目录只放启动壳 `app.py`、`pyproject.toml`、`static/`、`data/`、`script/` 这类运行时资源。通用脚本收进 `script/`,不参与运行的参考脚本放 `script/reference/`;只服务某个平台的脚本(含运行必需的 JS)放该插件目录的 `script/` 下(`plugins/<名字>/script/`),不放根目录,保证删掉插件目录不留残件。插件专属的常量、请求模型、提示词放插件自己的 `config.py` / 模块里,核心目录不写具体平台的内容,也不写死平台 id。 +- 平台实现只能放在 `src/study_tool/plugins`:`plugin.json` 只放 `id` / `title` / `name` 注册信息,`main.py` 放 `Platform` 子类,实现复杂的再拆 `jspx.py` / `study.py` 等同目录模块;`load_plugins()` 扫目录 import,新增插件不用改任何 Python 代码(`GET /api/plugins` 看清单,`POST /api/plugins/{name}/enable|disable` 启停,`POST /api/plugins/reload` 热重载)。`src/study_tool/platforms/` 只放运行时(`base` / `contract` / `errors` / `registry`),不再放具体平台。能力只能通过「在子类里实现同名方法」绑定,禁止再写 `Platform(id=..., study=..., session=...)` 这种逐项传函数的注册;模块末尾 `PLATFORM = registry.register(<子类>())`,一个平台只注册一次。同一平台的不同页面代际拆成插件目录内的实现模块,由平台模块按 `course["generation"]` 分发。 +- 新增平台的标准步骤:建 `plugins/<名字>/`,写 `plugin.json`(`id` / `title` / `name`),实现 `study(self, key, s, d, **opts)`,需要登录 / 课程列表 / 发现时再实现 `session` / `dump_cookies` / `login` / `list_courses` / `discover` / `renew_session` / `profile` 对应方法,没有的阶段不写,末尾 `PLATFORM = registry.register(X())`)→ 启动即可,不用改 `load_builtin()`、`engine.py`、`routes/`、前端。 - 引擎、路由、前端都不允许直接 import 某个具体平台模块;需要平台能力时用 `registry.get(course["platform"])`;账号级操作(登录 / 刷新课程 / 发现课程 / 会话)按 `accounts.platform` 用 `registry.get()` 取 `Platform` 上的 `session` / `dump_cookies` / `login` / `list_courses` / `discover` / `renew_session` / `profile`;「添加账号」的平台下拉来自 `GET /api/platforms`。 - 前端源码在 `web/`(React + TypeScript + Vite + Tailwind CSS),构建产物输出到 `static/` 并提交进仓库;改完前端必须执行 `cd web && pnpm build`,不要直接改 `static/` 里的产物。 - 新代码的 import 一律显式(如 `from study_tool.config import JSPX`),不要再新增 `from xxx import *`;历史遗留的 star import 只保留在拆分前就存在的模块里。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 62fa08a..18f8d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,64 @@ ## Unreleased +## v0.0.8 + +### 体检修复 + +- 启动日志不再打印 `AUTH_TOKEN` 明文:日志同时进控制台 / 内存 / SQLite,前端「系统日志」里能直接看到,之前等于把令牌贴在墙上;现在只提示「需要访问令牌:见 .env 的 AUTH_TOKEN」 +- 系统设置:温度留空(0)不再把已保存的温度清成 0;API Key / app_secret 设置后原本无法清空,字段右侧加「清空」按钮(`clear_api_key` / `notify.clear_app_secret`) +- 故障推送:推送失败不再占用整段 `cooldown_min` 冷却,留 1 分钟重试,避免一次网络抖动把通知压掉十分钟 +- 用户可见报错里的旧目录名 `platforms/` 改成 `plugins/` +- `pyproject.toml` 的版本与 `metadata.yaml` 对齐,`bumpversion` 改成同时更新两处,发版不再漂移 +- 文档里的旧目录名 `pugins/` 全部改成 `plugins/`,`docs/pugin.md` 更名为 `docs/plugin.md`;`.gitignore` 补上 `.idea/`、`.ruff_cache/` + +### 故障推送 + +- 新增通知中心 `study_tool/notify.py`:`fault(kind, key, title, content)` 上报故障、`retry(key, what)` 累计重试、`send()` 直接推送;同一 `(类型, 账号)` 按 `cooldown_min` 冷却,重试只在刚达到阈值时推一次 +- 新增推送渠道插件 `plugins/daidai_push/`(呆呆面板):`/api/open-api/token` 换 token(内存缓存、按 `expires_in` 过期、401 自动换新重发)+ `/api/notifications/send`;`plugin.json` 用 `"kind": "notify"` 声明类型 +- 插件加载器支持非平台插件:`kind=notify` 的插件注册到 `notify` 注册表,停用 / 重载同样摘除;`/api/plugins` 返回 `kind`,前端能力显示「推送」 +- 设置项新增 `notify`(`base_url` / `app_key` / `app_secret` / `channel_name` / 各类型开关 / `retry_threshold` / `cooldown_min`),`/api/settings` 读写(密钥只回显前后几位),新增 `POST /api/notify/test` 测试推送 +- 触发点:课程学完仍未完成 / 进度没推进 / 没有可识别活动(`notify.check_progress`)、课程处理异常、整体运行异常、登录失败、会话失效且无法续期;公需课掉线重登与自动重学、莞易学单课时重报计入重试次数 +- 前端「系统设置」改成左侧分类(大模型 / 学习模式 / 故障推送)+ 右侧内容的两栏布局:`Modal` 新增 `settings` 宽度档与 `bodyClassName` 覆盖;「测试连接」「测试推送」分别放进对应面板右上角,弹窗底部只留取消 / 保存 + +### 刷新课程 / 会话 + +- 修复「平台「」还没有实现刷新课程」:`session_expired` 自动重登后的重试请求补上 `platform`,不再把登录成功的结果丢掉 +- 修复反复重登:运行状态里的旧 cookie 不再在运行结束后回写账号(只在 `discovering` / `studying` 时同步),重新登录时也会清掉运行状态里的 `cookies` +- 公需课学习中掉线重登后,把新会话写回账号与运行状态(新增 `database.account_save_cookies`),库里不再留失效会话 + +### 公需课交互式测验 + +- 平台契约新增 `quiz_submit(self, s, b)` 槽位,能力 `quiz` 改为「`quiz` + `quiz_submit` 都实现」才算;`/api/quiz/submit` 改成按 `registry.get(platform).quiz_submit` 分发,莞易学的交卷逻辑移到 `quiz.submit_quiz` +- 公需课实现 `quiz` / `quiz_submit`:考核是**独立活动页**(`//study/course/`),从课程目录找标题含「考核 / 考试」的活动再解析 `#testForm`(题型映射 01 单选 / 02 多选 / 03 判断),弹窗里看题、AI 答题、交卷;交卷按用户选的字母换算成平台选项值提交,已交卷的课也能回看题目 +- 修复公需课自动考核:`exam.run` 之前读课程主页(没有 `testForm`,永远拿不到题),改为读考核活动页 +- 前端测验弹窗交卷请求带上 `platform` + +### 公需课自动重学 + +- 公需课 `study` 改为「刷一轮 → 重取进度页(`/study/course/progess`)」循环:即使进入时所有任务点都显示已完成,也会重新读取一次平台状态;仍有未完成任务点时等 60 秒后自动重新开始学习,不再需要手动点「开始学习」 +- 连续 3 轮平台完成数都没增加(如考核等非视频任务点)或达到 5 轮(`MAX_ROUNDS`)时停止,并在日志里列出剩余任务点 + +### 公需课选课兜底 + +- 公需课刷新课程时,若 `/uc/store/courseRegister` 里没有目标年度的课,自动去 `/uc/store/course/` 选课:已有「进入学习」的课就直接用,否则按配置选第几门;目标年度和序号放在插件目录 `plugins/gdgxk/config.json`(`year` 空 = 当前年份,`pick` 默认 1),不进数据库、不动前端 + +### 插件解耦 + +- 插件可直接删除:核心不再 import 或写死任何具体平台。加载器记录插件 import 期间注册的路由,停用 / 重载 / 加载失败时一并摘除(修复重载后 `/api/quiz*` 重复注册、停用后仍可访问) +- 平台契约新增能力 `quiz`、`capture`;前端「打开测验」「抓包」改为按 `GET /api/platforms` 的 `capabilities` 显示;`/api/quiz` 改走平台的 `quiz` 能力 +- 去掉写死的 `guanyixue` 默认值(`accounts.platform`、请求模型、抓包兜底、前端初值与回退文案),页脚与添加账号提示不再写死平台名 +- 插件专属脚本迁到插件目录:`des.js` / `desenc.js` / `study_video.py` → `plugins/guanyixue/script/`,`capture-jsglpt.py` / `test-gdgxk.py` → `plugins/gdgxk/script/`;`test-gdgxk.py` 改用新的插件 API + +### 工程 + +- 新增 `.pre-commit-config.yaml`:提交前跑 ruff 检查 / 格式化、yaml 与私钥 / 大文件检查,`web/` 有改动时跑 `pnpm typecheck`,提交信息由 commitizen 校验(约定式提交);`pyproject.toml` 增加 `[tool.ruff]` 与 `[tool.bumpversion]`,发版用 `bump-my-version bump patch` 同步 `metadata.yaml`、提交并打 `v` 开头的 tag + ### 重构 +- 清理 `study_tool` 顶层的插件专属代码:莞易学的站点地址 / 登录授权入口 / 课程板块 / `NODE` / `DESENC` / `HEX32` / `CODE_RE` / `DEBUG_DIR` 等常量从 `config/__init__.py` 迁到 `plugins/guanyixue/config.py`,`config/` 只剩 `ROOT` / `DATA` / `STATIC_DIR` 通用路径;测验请求模型(`QuizIn` / `QuizSubmitIn` / `QuizAnswerIn`)、答题提示词 `DEFAULT_ANSWER_PROMPT`、题型名 `TYPE_LABEL` 从 `models.py` / `config/settings.py` 迁到 `plugins/guanyixue/quiz.py`;引擎里"新版课程 ID"日志挪进莞易学 `study()` 分发 +- 删除无人引用的代码:`config` 里的 `LOGDIR`(及启动时建 `data/logs` 目录)、`SessionExpired` 再导出、`models.py` 的 `AccountsIn` +- `app.py` / `engine.py` / `routes/accounts.py` / `routes/misc.py` 以及莞易学 `utils/*` 的 `from study_tool.config import *`、`from study_tool.models import *` 改成显式 import - 前端从单文件 `static/index.html`(原生 JS)改为 `web/` 工程:React 19 + TypeScript + Vite + Tailwind CSS v4;Vite 直接输出到 `static/`,FastAPI 托管 `/` 与 `/assets`,运行 Python 不再需要 Node - 新旧学习平台合并为「莞易学」:`jspx.dgjy.net`(登录入口)与 `study.dgjy.net`(学习入口)统一注册为 `Platform(id="guanyixue", label="莞易学")`,课程页代际(`legacy` / `spa`)由 `platforms/detect.py` 判断,平台内部按代际分发到 `jspx.py` / `study.py` - `Platform` 新增 `entries`(登录 / 学习入口 URL 片段)与 `registry.detect()`;课程发现按入口识别平台,新增平台不再需要改引擎。新增 `GET /api/platforms` 供前端展示平台名 @@ -19,6 +75,7 @@ ### 新增 +- 公开查询页 `/query`:不需要 `AUTH_TOKEN`,顶部输入框填账号用户名后点「查询」,显示该账号的课程列表与当前学习进度;对应公开接口 `GET /api/query?username=`,只返回课程名 / 章节 / 状态 / 进度与运行进度,不含密码、cookie、账号 id - 新增平台「广东公需课」(`platforms/gdgxk.py`):`jsglpt.gds.edu.cn` 账号密码(base64)登录后换 ITLMS 票进 `jsxx.gds.edu.cn`,课程列表读 `/uc/store/courseRegister`;学习只跑 `updateViewTime1` 进度心跳(服务端按 `lastViewTime` 直接记账,不需要真播)。票按 `ITLMS-xxx||ticketId` 两段交给 `jsxx.gds.edu.cn/nts/loginToStoreByAccount`;登录要过 4 位图形验证码,用 ddddocr 离线识别并自动重试。配套 `script/capture-jsglpt.py`(抓包取证)与 `script/test-gdgxk.py`(接口自测);课程列表解析出培训年度 / 学时 / 完成状态,卡片显示成「2026年公需课 · 未完成 · 30 学时」 - 统一日志源 `runtime.log()`:控制台、内存(`/api/log`)、SQLite(`/api/log/history`)同一份内容;新增右上角「系统日志」弹窗查看 `key=system` 的启动/设置类日志 - 设置保存、服务启动都写入统一日志;卡片「课程日志」和「系统日志」的「清空」会同时清内存和 SQLite diff --git a/Dockerfile b/Dockerfile index 3615147..51bfd95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ ENV PYTHONUNBUFFERED=1 \ WORKDIR /app -# nodejs: script/desenc.js 要用(登录表单 rsa 字段走站点原版 DES 算法) +# nodejs: 莞易学插件 plugins/guanyixue/script/desenc.js 要用(登录表单 rsa 字段走站点原版 DES 算法) # curl: 健康检查用 RUN apt-get update \ && apt-get install -y --no-install-recommends nodejs ca-certificates curl tzdata \ diff --git a/README.md b/README.md index b49abbd..1f03fbc 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ pnpm typecheck | --- | --- | | `accounts` | 账号 / 密码 / 会话 cookie / 课程缓存 / 勾选 / 自动交测验·反馈表开关 | | `settings` | 大模型配置(`base_url` / `api_key` / `model` / 温度 / 超时 / 提示词) | -| `kv` | 通用缓存(例如 study 平台试出来的接口前缀、`study_video.py` 的会话) | +| `kv` | 通用缓存(例如 study 平台试出来的接口前缀、莞易学 `script/study_video.py` 的会话) | | `login_log` | 每次登录尝试(平台原话,`/api/loginlog` 读它) | | `run_log` | 每个账号的运行日志(`/api/log/history?key=&tail=200`) | | `run_state` | 运行状态快照,进程重启后 `/api/runs` 仍能看到,未跑完的标 `interrupted` | @@ -164,30 +164,26 @@ src/study_tool/ app.py 组装:FastAPI、鉴权中间件、页面 / health、启动 main() webapp.py FastAPI 实例(路由模块从这里注册,避免循环导入) runtime.py 运行状态 + 统一日志源(控制台 / 内存 / SQLite) - config.py 常量、环境变量、根目录定位(data / static / script) - store.py SQLite3:账号 / 设置 / 登录记录 / 运行日志 / 状态 / 缓存 / 快照 - models.py HTTP 请求模型 - settings_store.py 系统设置读写 - llm.py 大模型调用 - quiz.py 测验解析 / 交卷 / AI 答题 + config/ 全局路径常量(ROOT / DATA / STATIC_DIR);settings.py 系统设置读写 + database/ SQLite 存储(SQLAlchemy ORM):models.py + 读写函数 + models.py 通用 HTTP 请求模型 + llm/ 大模型调用 engine.py 学习引擎:发现 → 按平台分发 → worker / 视频 / 提交 - capture.py 抓包 - platforms/ 平台插件 + platforms/ 平台运行时 base.py Platform 契约(id / label / study / entries) registry.py 注册表:register / get / detect / all - detect.py 页面代际识别(旧版 / 新版 SPA) - guanyixue.py 莞易学:合并 jspx 登录入口与 study 学习入口 - jspx.py 莞易学旧版页面实现 - study.py 莞易学新版 SPA 实现 + plugins/ 平台插件 + guanyixue/ 莞易学:main.py + config.py(站点常量)+ utils/ + capture.py(抓包)+ quiz.py(测验) routes/ HTTP 接口 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/des.js 服务端 DES 加密(登录表单 rsa 字段,站点原版算法) -script/desenc.js DES 命令行封装(Node 调用,config.py 里定位) script/reference/ 不参与运行的参考脚本:油猴 dgjy-toolkit.user.js、study-axios.mjs +(插件专属脚本在 plugins/<名字>/script/:莞易学的 des.js、desenc.js、study_video.py,公需课的抓包 / 自测脚本) start.bat 一键启动(Windows) Dockerfile Docker 镜像(python:3.12-slim + nodejs + chromium) docker-compose.yml Docker 编排:端口、数据卷、AUTH_TOKEN、健康检查 @@ -195,7 +191,6 @@ docker-compose.yml Docker 编排:端口、数据卷、AUTH_TOKEN、健 requirements.txt 依赖 data/study.db SQLite 库(账号/设置/日志/快照) docs/architecture.md 分层说明 + 平台插件怎么加 -study_video.py 独立命令行工具:按 trainCourseId 直接刷视频课时(会话也存 study.db) ``` 分层与插件契约见 [docs/architecture.md](docs/architecture.md):引擎只按 `course["platform"]` @@ -222,7 +217,7 @@ GET jspx.dgjy.net/admin/main.action table-results 课程表 新版页面(慕课)另走一条 OAuth 码换 token 的链,拿到 `study.dgjy.net` 的 `Middle-Token`,见 `ensure_study_session()`。 `jspx.dgjy.net` 与 `study.dgjy.net` 在注册表里是同一个平台(`guanyixue` / 莞易学), -课程发现时自动判断页面代际(旧版 / 新版),分别走 `jspx.py` / `study.py` 的学习实现。 +课程发现时自动判断页面代际(旧版 / 新版),分别走 `utils/learn.py` / `utils/spa.py` 的学习实现。 ## 接口 diff --git a/app.py b/app.py index acb62e7..87fbf52 100644 --- a/app.py +++ b/app.py @@ -4,12 +4,13 @@ 真正的代码在 src/study_tool/ 包里,这里只把 src 加进 sys.path 再调 main(), 这样 `python app.py`、`start.bat`、Dockerfile 都不用改。 """ + import os import sys sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")) -from study_tool.app import main # noqa: E402 +from study_tool.app import main if __name__ == "__main__": main() diff --git a/docs/OPTIMIZATION.md b/docs/OPTIMIZATION.md index d7dd6c1..b68eb55 100644 --- a/docs/OPTIMIZATION.md +++ b/docs/OPTIMIZATION.md @@ -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()` 改成先解析个人中心头像区的 ``:学员账号平台显示的是打码姓名(如「*栋」),老师账号是完整姓名;解析不到才退回原来的「你好,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//study/course/`(课程主页上没有 `#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//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 ` 能跑(脚本自己把 `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):插件骨架、槽位契约、新增与迁移流程、常见坑。 ### 怎么测 diff --git a/docs/agent_board.md b/docs/agent_board.md new file mode 100644 index 0000000..a61dda8 --- /dev/null +++ b/docs/agent_board.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、密钥可清空、推送失败不占整段冷却、目录名文案统一 | 已完成,待实测 | diff --git a/docs/agent_workflow.md b/docs/agent_workflow.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/architecture.md b/docs/architecture.md index 6e84940..f6a723a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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。 diff --git a/docs/plugin.md b/docs/plugin.md new file mode 100644 index 0000000..b9301a6 --- /dev/null +++ b/docs/plugin.md @@ -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`,浏览器打开 。 +换端口 / 对外开放用环境变量,或把 `.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":""}' + +# 用已保存的 cookie 刷新课程(cookie 从上一步返回里取) +curl -X POST http://127.0.0.1:8848/api/courses \ + -H 'Content-Type: application/json' \ + -d '{"platform":"","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`:契约的最终定义(有疑问以代码为准)。 diff --git a/docs/progress/todo.md b/docs/progress/todo.md index f3c9d01..1dc078c 100644 --- a/docs/progress/todo.md +++ b/docs/progress/todo.md @@ -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()` 会发给所有已加载渠道。 diff --git a/docs/pugin.md b/docs/pugin.md deleted file mode 100644 index 2c3ad4b..0000000 --- a/docs/pugin.md +++ /dev/null @@ -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` 都已完成迁移。 diff --git a/docs/todo.md b/docs/todo.md index a188770..ebf42c7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -2,7 +2,7 @@ ## 待评估:接入广东公需课平台(jsxx.gds.edu.cn) -**状态**:已接入,见 `src/study_tool/platforms/gdgxk.py` 与 `OPTIMIZATION.md`;登录 / 课程列表 / 进度心跳已实测,待用未学完课程确认。 +**状态**:已接入,见 `../src/study_tool/platforms/gdgxk.py` 与 `OPTIMIZATION.md`;登录 / 课程列表 / 进度心跳已实测,待用未学完课程确认。 ### 背景 diff --git a/metadata.yaml b/metadata.yaml index 94afbbd..af93c1d 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,3 +1,3 @@ name: study-tool -version: 0.0.7 +version: 0.0.8 description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台 diff --git a/pyproject.toml b/pyproject.toml index bb3f186..b5857a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "study-tool" -version = "0.0.1" +version = "0.0.8" description = "东莞市教师培训平台多账号学习控制台" requires-python = ">=3.10" dependencies = [ @@ -16,6 +16,7 @@ dependencies = [ "playwright>=1.40", "ddddocr", "python-dotenv>=1.0", + "sqlalchemy>=2.0", ] [project.scripts] @@ -35,3 +36,30 @@ where = ["src"] name = "aliyun" url = "https://mirrors.aliyun.com/pypi/simple" default = true + +[tool.ruff] +line-length = 120 +extend-exclude = ["static", "web"] + +# 只拦真正的错误(语法 / 未定义名 / 未用导入);历史模块仍有 star import,先不拦 F403 / F405 +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] +ignore = ["F403", "F405"] + +# 发版:uv tool run bump-my-version bump patch(工作区需干净),自动改 metadata.yaml、提交并打 tag +[tool.bumpversion] +current_version = "0.0.8" +commit = true +tag = true +tag_name = "v{new_version}" +message = "chore(release): v{new_version}" + +[[tool.bumpversion.files]] +filename = "metadata.yaml" +search = "version: {current_version}" +replace = "version: {new_version}" + +[[tool.bumpversion.files]] +filename = "pyproject.toml" +search = '(?m)^version = "{current_version}"$' +replace = 'version = "{new_version}"' diff --git a/requirements.txt b/requirements.txt index c2bf424..81b474b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ lxml>=5.0 playwright>=1.40 ddddocr python-dotenv>=1.0 +sqlalchemy>=2.0 diff --git a/script/test-gdgxk.py b/script/test-gdgxk.py deleted file mode 100644 index 5e3356f..0000000 --- a/script/test-gdgxk.py +++ /dev/null @@ -1,41 +0,0 @@ -"""广东公需课自测:用浏览器抓到的 cookie 走一遍 列表 → 发现 → 心跳。 - -用法:PYTHONPATH=src python script/test-gdgxk.py [课件id] -""" -import json -import sys -import uuid - -from study_tool.platforms import gdgxk - -cid = sys.argv[1] if len(sys.argv) > 1 else "" -cookies = json.load(open("data/capture-jsglpt-cookies.json", encoding="utf-8")) -s = gdgxk.new_session(cookies) - -courses = gdgxk.list_courses(s) -print("课程数:", len(courses)) -for c in courses: - print(" ", c["plan_id"], c["name"]) - -if not cid and courses: - cid = courses[0]["plan_id"] - -d = gdgxk.discover(s, cid, "") -print("\n课程:", d["course"]) -print("进度:", d["progress"]) -print("视频:", d["info"]) -cw = d["courseware"][0] -print("课件:", cw["id"], "| 总长", cw["total"], "| 已看", cw["viewTime"]) - -sid = uuid.uuid4().hex -sign = gdgxk._sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, cw["uid"]) -print("\n播放注册签名:", sign) -print("服务端已看秒数:", gdgxk._view_time(s, cw, cw["uid"], sign)) - -r = gdgxk._xhr(s, "POST", - gdgxk.X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1", - data={"_method": "PUT", "aid": cw["aid"], "isLimit": "false", "md5": sign["md5"], - "expires": sign["time"], "md5Uid": cw["uid"], - "lastViewTime": str(min(cw["viewTime"] + 60, cw["total"])), - "playbackSessionId": sid}) -print("\n心跳响应:", r.status_code, r.text[:300]) diff --git a/src/study_tool/__init__.py b/src/study_tool/__init__.py index 73627ab..b40d695 100644 --- a/src/study_tool/__init__.py +++ b/src/study_tool/__init__.py @@ -1,10 +1,11 @@ """多账号学习控制台(东莞市教师培训平台)。""" + import os from dotenv import load_dotenv # 仓库根目录的 .env:本机启动(python app.py / uv run app.py)也按它配置。 -# 必须在任何子模块之前加载 —— store / config 在 import 时就读环境变量; +# 必须在任何子模块之前加载 —— database / config 在 import 时就读环境变量; # 已经在环境里的变量优先(Docker 由 compose 注入,不会被 .env 覆盖)。 _ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) load_dotenv(os.path.join(_ROOT, ".env")) diff --git a/src/study_tool/__main__.py b/src/study_tool/__main__.py index 616c394..8971dc1 100644 --- a/src/study_tool/__main__.py +++ b/src/study_tool/__main__.py @@ -1,4 +1,5 @@ """`python -m study_tool` 入口(与根目录 app.py 等价)。""" + from study_tool.app import main if __name__ == "__main__": diff --git a/src/study_tool/app.py b/src/study_tool/app.py index 2029645..f5b5f43 100644 --- a/src/study_tool/app.py +++ b/src/study_tool/app.py @@ -1,22 +1,19 @@ """多账号学习控制台 —— 入口。 代码结构 - config 常量 / 环境变量 + config 常量 / 环境变量 / 系统设置(config.settings) + database SQLite 存储(SQLAlchemy ORM:模型 + 读写函数) runtime 运行状态 + 统一日志(控制台 / 内存 / SQLite) - platform_jspx 旧版平台登录与课程抓取 - platform_study 新版平台 API + platforms 平台运行时(registry / base / contract / errors) + plugins 平台插件(guanyixue / gdgxk …);平台实现、抓包、测验都在插件目录 engine 学习引擎(worker / 播放 / 提交) - capture 抓包 - models HTTP 请求模型 - settings_store 系统设置 llm 大模型调用 - quiz 测验解析 / 交卷 / AI 答题 - accounts 账号与登录接口 - routes_misc 课程 / 学习 / 抓包 / 快照接口 - log_routes 日志与运行状态接口 + models HTTP 请求模型 + routes 账号 / 设置 / 日志 / 课程等接口 日志只走 runtime.log(),不要自己 print。 """ + import json import os import threading @@ -26,17 +23,16 @@ from fastapi import Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles -from study_tool import store -from study_tool.config import * +from study_tool import database +from study_tool.config import STATIC_DIR from study_tool.platforms import load_builtin + +from study_tool.routes import load_routes from study_tool.runtime import RUNS, RUNS_LOCK, log from study_tool.webapp import app -# 导入即把各路由模块的接口注册到 app 上(必须在 main() 之前) -from study_tool import llm, quiz, settings_store # noqa: E402,F401 -from study_tool.routes import accounts, logs, misc # noqa: E402,F401 - -load_builtin() # 注册内置平台插件(jspx / study) +load_routes() # 扫描 routes/ 并导入全部路由模块,接口注册到 app 上(必须在 main() 之前) +load_builtin() # 扫 plugins/ 目录加载全部插件(目录为空也能正常启动) APP_ID = "dgjy-study-console" @@ -49,6 +45,7 @@ if os.path.isdir(_ASSETS_DIR): # 不设置 = 不校验(本机使用,或前面已经有反代做鉴权)。/api/health 永远放行,供健康检查用。 AUTH_TOKEN = (os.environ.get("AUTH_TOKEN") or "").strip() AUTH_COOKIE = "dgjy_auth" +_PUBLIC_PATHS = {"/query", "/api/query", "/favicon.svg"} _AUTH_PAGE = """需要访问令牌