bdd0bdf073
Platform 由 dataclass 改为普通基类(类属性 + 方法槽位),禁止逐项传函数注册;gdgxk 与 guanyixue 完成迁移;AGENTS.md、architecture.md 同步新契约;新增 docs/pugin.md(骨架 / 槽位契约 / 开发与迁移流程 / 常见坑),OPTIMIZATION.md 补待测条目。
9.4 KiB
9.4 KiB
学习平台插件开发文档
分层与整体结构见 architecture.md,本文只讲「怎么写一个平台插件」。
一个平台 = src/study_tool/platforms/<平台>.py 里的一个 Platform 子类:类属性放元数据,
自己确实有的阶段实现成方法,模块末尾注册一次。引擎、路由、前端都不需要改。
插件骨架
"""<平台名>:登录 → 发现 → 学习。"""
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/platforms/
base.py Platform 基类:id / label / entries + 8 个槽位默认 None
contract.py 槽位 ↔ 能力名映射,capabilities() 用它推导
errors.py SessionExpired(会话失效,引擎据此续期 / 重登)
registry.py register / get / detect / all
detect.py 页面代际识别(legacy 旧版 / spa 新版)
guanyixue.py 平台:莞易学(jspx 登录入口 + study 学习入口,按 generation 分发)
jspx.py 同平台实现模块:旧版页面(不是插件,不注册)
study.py 同平台实现模块:新版 SPA(不是插件,不注册)
gdgxk.py 平台:广东公需课
约定:
- 一个平台只注册一次;注册发生在模块被 import 时,模块里除注册外不要有副作用。
- 平台 id = 模块里
id=accounts.platform=course["platform"],三处必须一致。 - 同一平台不同页面代际拆成同目录下的实现模块(
jspx.py/study.py),由平台类按course["generation"]分发,不要一个代际注册一个平台。 - 平台私有的工具函数放同一模块或同平台的实现模块里;跨平台共用的能力放
base.py/contract.py/errors.py。
槽位契约
| 槽位 | 方法签名 | 调用方 | 要求 |
|---|---|---|---|
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,插件只取自己需要的。
开发流程
- 新建
src/study_tool/platforms/<名字>.py,按上面的骨架写class <名字>(Platform): 类属性id/label/entries,按需实现槽位方法。 - 模块末尾
PLATFORM = registry.register(<名字>())。 - 在
platforms/__init__.py的load_builtin()里加一行from study_tool.platforms import <名字>。 - 启动服务,
GET /api/platforms应出现该平台。 - 联调顺序:
/api/login→/api/courses→/api/discover→/api/study。
把已有实现改成子类
老写法(自由函数 + 逐项注册)已经废弃,迁移步骤:
- 保留模块级纯函数(
_b64/_ocr/_parse_courses等),它们不属于契约。 - 把原来的槽位自由函数改成同名方法:加
self,模块内互相调用改成self.xxx, 平台私有的重登逻辑改成_relogin这类私有方法。 - 加类属性
id/label/entries,删掉模块末尾的逐项注册。 - 模块末尾改成
PLATFORM = registry.register(<类名>())。 - 逐个验证槽位行为与迁移前一致;同一平台的两个代际由
study里按course["generation"]分发(参见guanyixue.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分发。
方案演进
- 早期:平台实现直接写在
engine.py里。 - 拆包:
platforms/base.py定义Platform契约 +registry注册表,引擎按course["platform"]分发;平台用自由函数 + 逐项注册;学习流程仍在引擎。 - 契约补全:
contract.py推导capabilities;SessionExpired移到platforms/errors.py; 各平台学习流程从引擎搬回平台模块(engine.py只剩账号级编排)。 - 类式(当前):
Platform变成普通基类,平台写成子类(类属性 + 方法), 禁止逐项传函数注册;gdgxk与guanyixue已完成迁移。