refactor(platforms): 平台能力改为子类实现,注册不再逐项绑定函数

- Platform 由 dataclass 字段绑定改为基类:id / label / entries 是类属性,能力是子类方法,
  未实现的阶段保持基类 None,capabilities() 与 /api/platforms 输出不变
- gdgxk:session(原 new_session)/ login / list_courses / discover / study / _relogin
  收进 class Gdgxk(Platform),解析与上报 helper 仍是模块级函数
- guanyixue:改成 class Guanyixue(Platform),方法转调 jspx / study
- 删除 src/study_tool/pugins/ 草稿目录(内容与 platforms/gdgxk.py 重复,
  pugin.json 里按名字声明 study / entries 的绑定方式作废,元数据改由类属性承载)
- 引擎 / 路由 / quiz / contract / 前端未改动
- 同步 docs/architecture.md、AGENTS.md、docs/OPTIMIZATION.md
This commit is contained in:
study-tool
2026-10-31 15:02:21 +08:00
parent b1ccfe89f6
commit f999e4fa4d
7 changed files with 293 additions and 241 deletions
+3 -2
View File
@@ -1,8 +1,9 @@
"""平台插件包。
内置平台在 `load_builtin()` 里导入(导入即注册)。新增平台:
1. 新建 `platforms/<名字>.py`,实现 `Platform.study`、声明 `entries` 登录 / 学习入口,
并 `registry.register(...)`;
1. 新建 `platforms/<名字>.py`,写一个 `Platform` 子类:类属性声明 `id` / `label` /
`entries`,自己有的阶段实现成方法(见 `base.py`),末尾
`PLATFORM = registry.register(<子类>())`;
2. 在下面的 `load_builtin()` 里加一行 import;
3. 路由 / 引擎不用改 —— 它们只认 `course["platform"]`。
"""
+39 -21
View File
@@ -1,41 +1,59 @@
"""平台插件契约。
"""平台插件契约:一个平台 = 一个 `Platform` 子类。
新增一个平台 = 在 `platforms/` 下加一个模块,构造 `Platform` 并 `registry.register()`,
然后把它加进 `platforms/__init__.py` 的 `load_builtin()`。引擎只按
`course["platform"]` 去注册表取实现,不再 import 具体平台。
子类用类属性声明元数据,用**方法**实现自己确实有的阶段;没实现的阶段不要写,
保持基类的 `None`(`capabilities()` 按非空槽位推导,引擎按能力决定走不走这个分支):
class Gdgxk(Platform):
id = "gdgxk"
label = "广东公需课"
entries = ("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn")
def session(self, cookies=None): ...
def login(self, username, password): ...
def study(self, key, s, d, **opts): ...
PLATFORM = registry.register(Gdgxk())
新增平台 = 在 `platforms/` 下加一个这样的模块,再把它加进 `platforms/__init__.py`
的 `load_builtin()`。引擎只按 `course["platform"]` 去注册表取实现,不 import 具体平台。
`entries` 声明登录入口 / 学习入口的 URL 片段,`registry.detect(url, html)` 用它识别
页面属于哪个平台;同一平台的不同代页面(如莞易学的旧版 / 新版)用 `course["generation"]`
区分,由平台内部自行分发。
Platform.study 的签名约定:
槽位方法签名(第一个参数是 self,其余同自由函数):
study(key, s, d, **opts)
key 运行 key(账号 id,日志也用它)
s requests.Session(带该账号 cookie)
d discover() 返回的课程描述(course/courseware/feedback/test/platform/generation/...)
opts workers / cap / enable_quiz / enable_feedback / mode / parallel /
quiz_key / cookies / plan / project / name
session(cookies) -> requests.Session
dump_cookies(session) -> [cookie]
login(username, password) -> (session, user)
list_courses(session) -> [course]
discover(session, plan_id, project_id, name) -> 课程描述
renew_session(session) -> bool,无密码续期
profile(session) -> 显示名
"""
from dataclasses import dataclass
from typing import Callable, Optional
from study_tool.platforms.contract import capabilities_of
@dataclass
class Platform:
id: str # course["platform"] 的取值
label: str # 日志 / 界面里的展示名
study: Optional[Callable] = None # 学一门课;None = 这个平台还没实现
entries: tuple = () # 登录 / 学习入口 URL 片段,用于识别平台
# 账号级能力:添加账号时选了哪个平台,登录 / 发现 / 刷新课程就走哪个平台的实现
session: Optional[Callable] = None # (cookies) -> requests.Session
dump_cookies: Optional[Callable] = None # (session) -> [cookie]
login: Optional[Callable] = None # (username, password) -> (session, user)
list_courses: Optional[Callable] = None # (session) -> [course]
discover: Optional[Callable] = None # (session, plan_id, project_id, name) -> 课程描述
renew_session: Optional[Callable] = None # (session) -> bool,无密码续期
profile: Optional[Callable] = None # (session) -> 显示名
id = "" # course["platform"] 的取值
label = "" # 日志 / 界面里的展示名
entries = () # 登录 / 学习入口 URL 片段,用于识别平台
# 槽位:子类实现哪个阶段就把它覆盖成方法;保持 None = 该平台没有这项能力
study = None # 学一门课;None = 这个平台还没实现
session = None
dump_cookies = None
login = None
list_courses = None
discover = None
renew_session = None
profile = None
def matches(self, url="", html=""):
"""登录入口或学习入口命中任意片段,就认为页面属于本平台。"""
+158 -169
View File
@@ -28,7 +28,6 @@ X = "https://jsxx.gds.edu.cn"
PLATFORM_ID = "gdgxk"
def _xhr(s, method, url, **kw):
"""这些上报接口必须带 Referer(指向课程页),否则直接 500。"""
kw.setdefault("headers", {})["X-Requested-With"] = "XMLHttpRequest"
@@ -44,23 +43,6 @@ def _b64(text):
return base64.b64encode(str(text).encode("utf-8")).decode()
def new_session(cookies=None):
s = requests.Session()
s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
for c in (cookies or []):
if "gds.edu.cn" not in (c.get("domain") or ""):
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(s):
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path}
for c in s.cookies]
def _sso(s):
"""jsglpt 会话换 ITLMS 票,票按 `ITLMS-xxx||ticketId` 两段交给 jsxx。
@@ -104,44 +86,6 @@ def _login_error(s):
return (m.group(1).strip() if m else "")
def login(username, password):
s = new_session()
s.get(G + "/login.jsp", timeout=30)
err = ""
for _ in range(5):
data = {
"loginUser.userName": _b64(username),
"loginUser.loginType": "normal",
"loginUser.password": _b64(password),
"captcha": _ocr().classification(_captcha(s)),
}
r = s.post(G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
if r.status_code == 302 and "/default.jsp" in (r.headers.get("location") or ""):
_sso(s)
return s, profile(s) or username
err = _login_error(s)
if "验证码" not in err: # 不是验证码的问题,重试没意义
break
raise RuntimeError("登录失败:%s" % (err or "账号或密码不对"))
def renew_session(s):
try:
_sso(s)
return True
except Exception:
return False
def profile(s):
html = s.get(G + "/teacher/index.do", timeout=30).text
m = re.search(r"你好[,,]?\s*([^\s<]+)\s*老师", re.sub(r"\s+", " ", html))
if m:
return m.group(1)
m = re.search(r'class="user"[^>]*>\s*<[^>]*>\s*([^\s<]{2,20})', html)
return m.group(1) if m else ""
def _parse_courses(html):
"""课程列表页:每个 `showCourse('id','名称')` 前面就是这张卡片的信息。"""
out = []
@@ -163,16 +107,6 @@ def _parse_courses(html):
return out
def list_courses(s):
html = s.get(X + "/uc/store/courseRegister", timeout=30).text
out = _parse_courses(html)
if not out:
if "logout" not in html:
raise _expired("学习平台会话已失效,请重新登录")
return []
return out
def parse_course(html):
"""从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、要求时长、已看秒数。"""
d = {}
@@ -241,51 +175,11 @@ def _progress(s, cid):
return {"total": total or len(acts), "done": done, "acts": acts}
def discover(s, plan_id, project_id, name=""):
cid = plan_id
page = X + "/study/course/" + cid
s.headers["Referer"] = page
r = s.get(page + "?v=1", timeout=30)
if "loginBtn" in r.text and "md5Uid" not in r.text:
raise _expired("学习平台会话已失效,请重新登录")
# 一门课是「章 / 节 / 活动」的目录,不是单个视频;活动清单和完成情况都从进度页取
pr = _progress(s, cid)
if not pr["acts"]:
raise RuntimeError("进度页里没有找到活动列表,页面结构可能变了")
courseware = [{
"aid": a["aid"], "title": a["title"], "done": a["done"],
"href": X + "/" + a["aid"] + "/study/course/" + cid,
"total": 0, "viewTime": 0,
} for a in pr["acts"]]
return {
"user": profile(s),
"course": name or "公需课程",
"progress": "已完成 %d/%d 个活动" % (pr["done"], pr["total"]),
"info": page,
"courseware": courseware,
"feedback": [], "test": [],
"platform": PLATFORM_ID, "generation": "gxk",
"plan_id": cid, "project_id": "",
}
def _stopped(key):
with runtime.RUNS_LOCK:
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
def _relogin(key):
"""掉线后按库里存的账号密码重登,返回新会话。"""
acc = store.account_get(key) or {}
pw = store.account_password(key)
if not pw:
raise _expired("会话掉线,且没有保存密码,无法自动重连")
runtime.log(key, "会话掉线,正在用账号密码自动重新登录…", level="warn")
s, _ = login(acc.get("username") or "", pw)
runtime.log(key, "重新登录成功,继续学习")
return s
def _study_one(key, s, cw, speed, beat):
"""打开活动页 → 注册播放 → 1x 心跳;会话失效时抛 SessionExpired 交给上层重登。"""
try:
@@ -312,56 +206,6 @@ def _study_one(key, s, cw, speed, beat):
runtime.log(key, "服务端已记 %d 秒,达到要求,等平台刷新完成状态" % cur, level="warn")
def study(key, s, d, **opts):
"""按进度页的活动清单逐个心跳;平台只按真实时间放行,所以默认 1 倍速慢慢刷。"""
speed = float(opts.get("speed") or 1)
beat = int(opts.get("beat") or 60)
cid = d["plan_id"]
total = len(d["courseware"])
runtime.set_state(key, status="studying", total=total, done=0)
skipped = []
for i, cw in enumerate(d["courseware"], 1):
if _stopped(key):
runtime.log(key, "已按要求停止")
return
if cw.get("done"):
runtime.log(key, "活动 %d/%d 已完成,跳过:%s" % (i, total, cw["title"]))
runtime.set_state(key, done=i)
continue
for attempt in (1, 2):
try:
_study_one(key, s, cw, speed, beat)
break
except Exception as e:
expired = e.__class__.__name__ == "SessionExpired"
if expired and attempt == 1:
s = _relogin(key) # 掉线:重登后把当前活动重新走一遍
continue
if expired:
raise
runtime.log(key, "活动「%s」跳过:%s" % (cw["title"], e), level="warn")
skipped.append(cw["title"])
break
runtime.set_state(key, done=i)
# 以平台进度页为准收尾:视频秒数够 ≠ 课程完成,考核没做就是没完成
try:
pr = _progress(s, cid)
except Exception as e:
if e.__class__.__name__ != "SessionExpired":
raise
s = _relogin(key)
pr = _progress(s, cid)
act_total = pr["total"] or total
runtime.set_state(key, status="done", done=pr["done"], total=act_total)
if pr["done"] >= act_total:
runtime.log(key, "平台进度:%d/%d 个活动已完成" % (pr["done"], act_total))
else:
runtime.log(key, "平台进度:%d/%d 个活动已完成,剩余需人工处理%s"
% (pr["done"], act_total,
("(" + "、".join(skipped) + ")") if skipped else ""),
level="warn")
def _learn(key, s, cw, speed, beat):
"""心跳上报。平台只按真实流逝时间放行(1x),所以目标值按墙钟推,不靠每拍累加。"""
uid, total = cw["uid"], cw["total"]
@@ -397,16 +241,161 @@ def _learn(key, s, cw, speed, beat):
runtime.log(key, "课件完成:%s(%d 秒)" % (cw["title"], total))
PLATFORM = registry.register(Platform(
id=PLATFORM_ID,
label="广东公需课",
study=study,
entries=("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn"),
session=new_session,
dump_cookies=dump_cookies,
login=login,
list_courses=list_courses,
discover=discover,
renew_session=renew_session,
profile=profile,
))
class Gdgxk(Platform):
"""广东公需课:jsglpt 登录(账号 / 密码都是 base64 + 图形验证码),jsxx 学习。
账号级能力都实现在这里;反馈表 / 测验这类平台没有的阶段不实现,保持基类的 None。
"""
id = PLATFORM_ID
label = "广东公需课"
entries = ("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn")
def session(self, cookies=None):
s = requests.Session()
s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
for c in (cookies or []):
if "gds.edu.cn" not in (c.get("domain") or ""):
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()
s.get(G + "/login.jsp", timeout=30)
err = ""
for _ in range(5):
data = {
"loginUser.userName": _b64(username),
"loginUser.loginType": "normal",
"loginUser.password": _b64(password),
"captcha": _ocr().classification(_captcha(s)),
}
r = s.post(G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
if r.status_code == 302 and "/default.jsp" in (r.headers.get("location") or ""):
_sso(s)
return s, self.profile(s) or username
err = _login_error(s)
if "验证码" not in err: # 不是验证码的问题,重试没意义
break
raise RuntimeError("登录失败:%s" % (err or "账号或密码不对"))
def renew_session(self, s):
try:
_sso(s)
return True
except Exception:
return False
def profile(self, s):
html = s.get(G + "/teacher/index.do", timeout=30).text
m = re.search(r"你好[,,]?\s*([^\s<]+)\s*老师", re.sub(r"\s+", " ", html))
if m:
return m.group(1)
m = re.search(r'class="user"[^>]*>\s*<[^>]*>\s*([^\s<]{2,20})', html)
return m.group(1) if m else ""
def list_courses(self, s):
html = s.get(X + "/uc/store/courseRegister", timeout=30).text
out = _parse_courses(html)
if not out:
if "logout" not in html:
raise _expired("学习平台会话已失效,请重新登录")
return []
return out
def discover(self, s, plan_id, project_id, name=""):
cid = plan_id
page = X + "/study/course/" + cid
s.headers["Referer"] = page
r = s.get(page + "?v=1", timeout=30)
if "loginBtn" in r.text and "md5Uid" not in r.text:
raise _expired("学习平台会话已失效,请重新登录")
# 一门课是「章 / 节 / 活动」的目录,不是单个视频;活动清单和完成情况都从进度页取
pr = _progress(s, cid)
if not pr["acts"]:
raise RuntimeError("进度页里没有找到活动列表,页面结构可能变了")
courseware = [{
"aid": a["aid"], "title": a["title"], "done": a["done"],
"href": X + "/" + a["aid"] + "/study/course/" + cid,
"total": 0, "viewTime": 0,
} for a in pr["acts"]]
return {
"user": self.profile(s),
"course": name or "公需课程",
"progress": "已完成 %d/%d 个活动" % (pr["done"], pr["total"]),
"info": page,
"courseware": courseware,
"feedback": [], "test": [],
"platform": PLATFORM_ID, "generation": "gxk",
"plan_id": cid, "project_id": "",
}
def study(self, key, s, d, **opts):
"""按进度页的活动清单逐个心跳;平台只按真实时间放行,所以默认 1 倍速慢慢刷。"""
speed = float(opts.get("speed") or 1)
beat = int(opts.get("beat") or 60)
cid = d["plan_id"]
total = len(d["courseware"])
runtime.set_state(key, status="studying", total=total, done=0)
skipped = []
for i, cw in enumerate(d["courseware"], 1):
if _stopped(key):
runtime.log(key, "已按要求停止")
return
if cw.get("done"):
runtime.log(key, "活动 %d/%d 已完成,跳过:%s" % (i, total, cw["title"]))
runtime.set_state(key, done=i)
continue
for attempt in (1, 2):
try:
_study_one(key, s, cw, speed, beat)
break
except Exception as e:
expired = e.__class__.__name__ == "SessionExpired"
if expired and attempt == 1:
s = self._relogin(key) # 掉线:重登后把当前活动重新走一遍
continue
if expired:
raise
runtime.log(key, "活动「%s」跳过:%s" % (cw["title"], e), level="warn")
skipped.append(cw["title"])
break
runtime.set_state(key, done=i)
# 以平台进度页为准收尾:视频秒数够 ≠ 课程完成,考核没做就是没完成
try:
pr = _progress(s, cid)
except Exception as e:
if e.__class__.__name__ != "SessionExpired":
raise
s = self._relogin(key)
pr = _progress(s, cid)
act_total = pr["total"] or total
runtime.set_state(key, status="done", done=pr["done"], total=act_total)
if pr["done"] >= act_total:
runtime.log(key, "平台进度:%d/%d 个活动已完成" % (pr["done"], act_total))
else:
runtime.log(key, "平台进度:%d/%d 个活动已完成,剩余需人工处理%s"
% (pr["done"], act_total,
("(" + "、".join(skipped) + ")") if skipped else ""),
level="warn")
def _relogin(self, key):
"""掉线后按库里存的账号密码重登,返回新会话。"""
acc = store.account_get(key) or {}
pw = store.account_password(key)
if not pw:
raise _expired("会话掉线,且没有保存密码,无法自动重连")
runtime.log(key, "会话掉线,正在用账号密码自动重新登录…", level="warn")
s, _ = self.login(acc.get("username") or "", pw)
runtime.log(key, "重新登录成功,继续学习")
return s
PLATFORM = registry.register(Gdgxk())
+35 -22
View File
@@ -2,31 +2,44 @@
账号在「添加账号」里选择平台(`accounts.platform`),登录 / 刷新课程 / 发现课程都会
走这里的 `login` / `list_courses` / `discover`;学习时 `discover` 会把页面代际写进
`course["generation"]`(legacy / spa),由 `_study` 分发到对应实现。
新增其他平台时,照本文件写一个模块并在 `Platform.entries` 声明登录 / 学习入口。
`course["generation"]`(legacy / spa),由 `study` 分发到对应实现。
新增其他平台时,照本文件写一个 `Platform` 子类并在 `entries` 声明登录 / 学习入口。
"""
from study_tool.platforms import jspx, registry, study
from study_tool.platforms.base import Platform
ID = "guanyixue"
LABEL = "莞易学"
# 登录入口 / 学习入口;命中任意一个就归到莞易学
ENTRIES = ("jspx.dgjy.net", "study.dgjy.net", "dgjy.net")
class Guanyixue(Platform):
id = "guanyixue"
label = "莞易学"
# 登录入口 / 学习入口;命中任意一个就归到莞易学
entries = ("jspx.dgjy.net", "study.dgjy.net", "dgjy.net")
def session(self, cookies=None):
return jspx.new_session(cookies)
def dump_cookies(self, s):
return jspx.dump_cookies(s)
def login(self, username, password):
return jspx.login(username, password)
def list_courses(self, s):
return jspx.scrape_courses(s)
def discover(self, s, plan_id, project_id, name=""):
return jspx.discover(s, plan_id, project_id, name)
def renew_session(self, s):
return jspx.ensure_jspx_session(s)
def profile(self, s):
return jspx.whoami(jspx.fetch_admin(s))
def study(self, key, s, d, **opts):
if d.get("generation") == "spa":
return study._study(key, s, d, **opts)
return jspx._study(key, s, d, **opts)
def _profile(s):
return jspx.whoami(jspx.fetch_admin(s))
def _study(key, s, d, **opts):
if d.get("generation") == "spa":
return study._study(key, s, d, **opts)
return jspx._study(key, s, d, **opts)
PLATFORM = registry.register(Platform(
id=ID, label=LABEL, study=_study, entries=ENTRIES,
session=jspx.new_session, dump_cookies=jspx.dump_cookies,
login=jspx.login, list_courses=jspx.scrape_courses, discover=jspx.discover,
renew_session=jspx.ensure_jspx_session, profile=_profile,
))
PLATFORM = registry.register(Guanyixue())