From f999e4fa4d152632fbf6450a21d11237d2e024d3 Mon Sep 17 00:00:00 2001 From: study-tool Date: Sat, 31 Oct 2026 15:02:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor(platforms):=20=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E6=94=B9=E4=B8=BA=E5=AD=90=E7=B1=BB=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=EF=BC=8C=E6=B3=A8=E5=86=8C=E4=B8=8D=E5=86=8D=E9=80=90?= =?UTF-8?q?=E9=A1=B9=E7=BB=91=E5=AE=9A=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 4 +- docs/OPTIMIZATION.md | 22 ++ docs/architecture.md | 59 +++-- src/study_tool/platforms/__init__.py | 5 +- src/study_tool/platforms/base.py | 60 +++-- src/study_tool/platforms/gdgxk.py | 327 +++++++++++++------------- src/study_tool/platforms/guanyixue.py | 57 +++-- 7 files changed, 293 insertions(+), 241 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 478abee..ab45f30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,8 +31,8 @@ ## 代码结构 / 平台插件 - 业务代码都在 `src/study_tool/` 包里;根目录只放启动壳 `app.py`、`pyproject.toml`、`static/`、`data/`、`script/` 这类运行时资源。散落的 JS 脚本统一收进 `script/`,不参与运行的参考脚本放 `script/reference/`。 -- 平台代码只能放在 `src/study_tool/platforms/` 下;一个平台只注册一次(`Platform` + `registry.register`),并在 `Platform.entries` 里声明登录 / 学习入口。同一平台的不同页面代际可以拆成实现模块(如莞易学的 `jspx.py` / `study.py`),由平台模块按 `course["generation"]` 分发。 -- 新增平台的标准步骤:写 `platforms/<名字>.py`(实现 `study(key, s, d, **opts)`、声明 `entries`,需要登录 / 课程列表 / 发现时再实现对应可选入口)→ 在 `platforms/__init__.py` 的 `load_builtin()` 里 import → 结束。不要改 `engine.py`、`routes/`、前端。 +- 平台代码只能放在 `src/study_tool/platforms/` 下;一个平台 = 一个 `Platform` 子类(类属性 `id` / `label` / `entries`),能力只能通过「在子类里实现同名方法」绑定,禁止再写 `Platform(id=..., study=..., session=...)` 这种逐项传函数的注册;模块末尾 `PLATFORM = registry.register(<子类>())`,一个平台只注册一次。同一平台的不同页面代际可以拆成实现模块(如莞易学的 `jspx.py` / `study.py`),由平台模块按 `course["generation"]` 分发。 +- 新增平台的标准步骤:写 `platforms/<名字>.py`(`class X(Platform)`,声明 `id` / `label` / `entries`,实现 `study(self, key, s, d, **opts)`,需要登录 / 课程列表 / 发现时再实现 `session` / `dump_cookies` / `login` / `list_courses` / `discover` / `renew_session` / `profile` 对应方法,没有的阶段不写)→ 在 `platforms/__init__.py` 的 `load_builtin()` 里 import → 结束。不要改 `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/docs/OPTIMIZATION.md b/docs/OPTIMIZATION.md index 9a396d6..b020e95 100644 --- a/docs/OPTIMIZATION.md +++ b/docs/OPTIMIZATION.md @@ -2,6 +2,28 @@ 已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。 +## 平台契约改为子类实现(注册方式调整) + +**状态**:已实现,待实测确认。 + +### 做了什么 + +- `Platform` 从 dataclass「注册时逐项传函数」改成基类:`id` / `label` / `entries` 是类属性, + 能力是子类方法;没实现的阶段保持基类的 `None`,`capabilities()` 与 `/api/platforms` 不变。 +- `platforms/gdgxk.py`:`session`(原 `new_session`)/ `login` / `list_courses` / `discover` / + `study` 等收进 `class Gdgxk(Platform)`;解析 / 上报类 helper 仍是模块级函数。 +- `platforms/guanyixue.py`:改成 `class Guanyixue(Platform)`,方法转调 `jspx` / `study`。 +- 删掉 `src/study_tool/pugins/` 草稿目录:它和 `platforms/gdgxk.py` 内容重复, + `pugin.json` 里「按名字声明 `study` / `entries`」的绑定方式作废,元数据改为类属性。 +- 引擎 / 路由 / `quiz.py` / 前端 / `platforms/contract.py` 均未改动。 + +### 怎么测 + +1. `GET /api/platforms` 仍返回 `gdgxk` 与 `guanyixue`,`capabilities` 与改动前一致 + (广东公需课:`login` / `session` / `renew` / `profile` / `courses` / `discover` / `study`)。 +2. 莞易学:刷新课程 → 发现课程 → 开始学习照常(旧版 / 新版都跑一次)。 +3. 广东公需课:登录 → 刷新课程 → 开始学习照常。 + ## 平台流程下沉(契约第二步) **状态**:已实现,待实测确认。 diff --git a/docs/architecture.md b/docs/architecture.md index f87f840..4accf58 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,24 +61,24 @@ pf = registry.get(d.get("platform")) pf.study(key, s, d, workers=..., cap=..., enable_quiz=..., ...) ``` -`Platform` 契约(见 `platforms/base.py`): +`Platform` 契约(见 `platforms/base.py`):一个平台 = 一个 `Platform` 子类,类属性放元数据, +自己确实有的阶段实现成方法;注册时只实例化子类,不再逐项绑定函数。 ```python -@dataclass class Platform: - id: str # course["platform"] 的取值 - label: str # 日志 / 界面展示名,如「莞易学」 - study: Callable # (key, s, d, **opts) -> None - entries: tuple[str, ...] # 登录入口 / 学习入口的 URL 片段 - # 账号级能力:添加账号时选中的平台决定登录 / 刷新课程 / 发现课程走哪套实现 - session: Callable # (cookies) -> requests.Session - dump_cookies: Callable # (session) -> [cookie] - login: Callable # (username, password) -> (session, user) - list_courses: Callable # (session) -> [course] - discover: Callable # (session, plan_id, project_id, name) -> 课程描述 - renew_session: Callable # (session) -> bool,无密码续期 - profile: Callable # (session) -> 显示名 - # 槽位为 None = 该平台没有这项能力;capabilities() 按非空槽位推导 + 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) -> 显示名 + # capabilities() 按非空槽位推导能力名 ``` `registry.detect(url, html)` 按 `entries` 命中平台;「添加账号」时选择的平台写进 @@ -130,19 +130,28 @@ quiz_key / cookies / plan / project / name`。插件可以只取自己需要的 from study_tool.platforms import registry from study_tool.platforms.base import Platform - def _study(key, s, d, **opts): - # 用 s(requests.Session,带该账号 cookie)把课程活动跑完 - # 日志一律 runtime.log(key, ...) - ... + class MyPlatform(Platform): + id = "<平台 id>" + label = "展示名" + entries = ("<登录入口域名>", "<学习入口域名>") - PLATFORM = registry.register(Platform( - id="<平台 id>", label="展示名", study=_study, - entries=("<登录入口域名>", "<学习入口域名>"), - session=, dump_cookies=, login=, - list_courses=, discover=, - )) + def session(self, cookies=None): ... + def dump_cookies(self, s): ... + def login(self, username, password): ... + def list_courses(self, s): ... + def discover(self, s, plan_id, project_id, name=""): ... + def profile(self, s): ... + + def study(self, key, s, d, **opts): + # 用 s(requests.Session,带该账号 cookie)把课程活动跑完 + # 日志一律 runtime.log(key, ...) + ... + + PLATFORM = registry.register(MyPlatform()) ``` + 平台确实没有的阶段不要写,保持基类的 `None`,引擎按能力判断走不走那个分支。 + 2. 在 `platforms/__init__.py` 的 `load_builtin()` 里加一行 `from study_tool.platforms import <名字>`。 3. 平台注册后会自动出现在「添加账号」的平台下拉框;账号选了该平台,登录 / 刷新课程 / diff --git a/src/study_tool/platforms/__init__.py b/src/study_tool/platforms/__init__.py index 5454420..d32806b 100644 --- a/src/study_tool/platforms/__init__.py +++ b/src/study_tool/platforms/__init__.py @@ -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"]`。 """ diff --git a/src/study_tool/platforms/base.py b/src/study_tool/platforms/base.py index e9f112d..8f267a4 100644 --- a/src/study_tool/platforms/base.py +++ b/src/study_tool/platforms/base.py @@ -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=""): """登录入口或学习入口命中任意片段,就认为页面属于本平台。""" diff --git a/src/study_tool/platforms/gdgxk.py b/src/study_tool/platforms/gdgxk.py index 112d5d2..3d5d9f6 100644 --- a/src/study_tool/platforms/gdgxk.py +++ b/src/study_tool/platforms/gdgxk.py @@ -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()) diff --git a/src/study_tool/platforms/guanyixue.py b/src/study_tool/platforms/guanyixue.py index eeae652..9069645 100644 --- a/src/study_tool/platforms/guanyixue.py +++ b/src/study_tool/platforms/guanyixue.py @@ -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())