# 学习平台插件开发指南 欢迎来到学习平台插件开发指南!本章节将会引导你如何开发学习平台的插件,在这之前你需要具备以下基础知识: - 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`:契约的最终定义(有疑问以代码为准)。