feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8

体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
study-tool
2026-11-01 17:47:22 +08:00
parent 491fd08c1e
commit aa48145557
114 changed files with 7779 additions and 3302 deletions
+304
View File
@@ -0,0 +1,304 @@
"""广东公需课:jsglpt 登录 → jsxx 学习。
登录在 `jsglpt.gds.edu.cn`(账号 / 密码都是 base64),成功后用
`ssoItlmsGxkTicket.action` 换 ITLMS 票,带票进 `jsxx.gds.edu.cn` 拿学习会话;
两个域名的 cookie 都留在同一个 requests.Session 里。
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度靠 `updateViewTime1`
心跳上报(`lastViewTime` 是已看秒数,页面上「要求观看视频 N 分钟」才是完成门槛)。
平台按真实流逝时间放行(实测一次报 +300 只记 +60),所以只能 1 倍速慢慢刷。
本文件只留 `Platform` 子类与注册,工具按阶段拆到 `utils/`(登录 / 课程解析 / 心跳)。
"""
import re
import requests
from study_tool import database, notify, runtime
from study_tool.platforms import registry
from study_tool.platforms.base import Platform
from .utils import course, exam, http, learn, login, register
MAX_ROUNDS = 5 # 最多「刷一轮 → 重取进度页」的轮数
MAX_STALL = 3 # 连续这么多轮平台完成数没增加就放弃,避免非视频任务点死循环
ROUND_WAIT = 60 # 两轮之间等平台刷新完成状态的秒数
EXAM_MARKS = ("考核", "考试") # 课程目录里这两类活动是试卷
def _exam_activity(courseware):
"""课程目录里的考核活动:考核是独立活动页(/<aid>/study/course/<cid>),题目只在它上面。"""
return next((c for c in courseware or [] if any(k in (c.get("title") or "") for k in EXAM_MARKS)), None)
def _courseware(cid, acts):
"""进度页的活动清单 → 引擎用的 courseware 条目。"""
return [
{
"aid": a["aid"],
"title": a["title"],
"done": a["done"],
"href": http.X + "/" + a["aid"] + "/study/course/" + cid,
"total": 0,
"viewTime": 0,
}
for a in acts
]
class Gdgxk(Platform):
"""广东公需课:jsglpt 登录(账号 / 密码都是 base64 + 图形验证码),jsxx 学习。
账号级能力都实现在这里;反馈表 / 测验这类平台没有的阶段不实现,保持基类的 None。
"""
id = http.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": http.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(http.G + "/login.jsp", timeout=30)
err = ""
for _ in range(5):
data = {
"loginUser.userName": login.b64(username),
"loginUser.loginType": "normal",
"loginUser.password": login.b64(password),
"captcha": login.ocr().classification(login.captcha(s)),
}
r = s.post(http.G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
if r.status_code == 302 and "/default.jsp" in (r.headers.get("location") or ""):
login.sso(s)
return s, self.profile(s) or username
err = login.login_error(s)
if "验证码" not in err: # 不是验证码的问题,重试没意义
break
raise RuntimeError("登录失败:%s" % (err or "账号或密码不对"))
def renew_session(self, s):
try:
login.sso(s)
return True
except Exception:
return False
def profile(self, s):
"""个人中心里平台显示的用户名:学员是打码姓名(如「*栋」),老师是完整姓名。"""
html = s.get(http.G + "/teacher/index.do", timeout=30).text
# 左上角头像区:<a href="javascript:;" class="name" ...>姓名</a>
m = re.search(r'class="name"[^>]*>([^<]{1,20})</a>', html)
if m and m.group(1).strip():
return m.group(1).strip()
m = re.search(r"你好[,,]?\s*([^\s<]+)\s*老师", re.sub(r"\s+", " ", html))
return m.group(1) if m else ""
def list_courses(self, s):
html = s.get(http.X + "/uc/store/courseRegister", timeout=30).text
out = course.parse_courses(html)
if not out and "logout" not in html:
raise http.expired("学习平台会话已失效,请重新登录")
# 没有目标年度的课:去选课超市选一门,再重新读列表
if not any(register.config()[0] in c["section"] for c in out) and register.ensure(s):
out = course.parse_courses(s.get(http.X + "/uc/store/courseRegister", timeout=30).text)
return out
def discover(self, s, plan_id, project_id, name=""):
cid = plan_id
page = http.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 http.expired("学习平台会话已失效,请重新登录")
# 一门课是「章 / 节 / 活动」的目录,不是单个视频;活动清单和完成情况都从进度页取
pr = course.progress(s, cid)
if not pr["acts"]:
raise RuntimeError("进度页里没有找到活动列表,页面结构可能变了")
courseware = _courseware(cid, pr["acts"])
return {
"user": self.profile(s),
"course": name or "公需课程",
"progress": "已完成 %d/%d 个活动" % (pr["done"], pr["total"]),
"info": page,
"courseware": courseware,
"feedback": [],
"test": [],
"platform": http.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"]
cws = d["courseware"]
runtime.set_state(key, status="studying", total=len(cws), done=0)
best, stall, pr, remain = -1, 0, {"done": 0, "total": len(cws)}, []
for rnd in range(1, MAX_ROUNDS + 1):
todo = [c for c in cws if not c.get("done")]
base = len(cws) - len(todo)
for i, cw in enumerate(todo, 1):
if learn.stopped(key):
runtime.log(key, "已按要求停止")
return
for attempt in (1, 2):
try:
learn.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")
break
runtime.set_state(key, done=base + i)
# 以平台进度页为准:不管本轮刷了几个(包括一个都不用刷),都重取任务点状态
s, pr = self._progress(key, s, cid)
cws = _courseware(cid, pr["acts"])
remain = [c["title"] for c in cws if not c["done"]]
runtime.set_state(key, done=pr["done"], total=pr["total"])
if pr["done"] >= pr["total"] and not remain:
break
stall = 0 if pr["done"] > best else stall + 1
best = pr["done"]
if stall >= MAX_STALL:
break
runtime.log(
key,
"第 %d 轮结束,平台进度 %d/%d,仍有 %d 个任务点未完成,稍后自动重新学习:%s"
% (rnd, pr["done"], pr["total"], len(remain), "、".join(remain) or "(进度页未列出具体任务点)"),
level="warn",
)
notify.retry(
key,
"未完成任务点自动重学",
"平台进度 %d/%d,第 %d 轮仍有 %d 个任务点未完成" % (pr["done"], pr["total"], rnd, len(remain)),
)
if not learn.wait(key, ROUND_WAIT):
runtime.log(key, "已按要求停止")
return
if remain: # 视频都刷完了还有缺口,多半是课程考核;交完卷再按进度页复核一次
cw = _exam_activity(cws)
if cw:
s = self._exam(key, s, cw["href"], opts)
s, pr = self._progress(key, s, cid)
remain = [c["title"] for c in _courseware(cid, pr["acts"]) if not c["done"]]
runtime.set_state(key, status="done", done=pr["done"], total=pr["total"])
if pr["done"] >= pr["total"] and not remain:
runtime.log(key, "平台进度:%d/%d 个活动已完成" % (pr["done"], pr["total"]))
else:
runtime.log(
key,
"平台进度:%d/%d 个活动已完成,自动重学后仍未完成,需人工处理%s"
% (pr["done"], pr["total"], ("(" + "、".join(remain) + ")") if remain else ""),
level="warn",
)
def _exam(self, key, s, url, opts):
"""课程考核:题库优先、大模型兜底;题库没覆盖全部题目就不交卷。掉线重登后重试一次。"""
auto = opts.get("exam_auto")
auto = True if auto is None else bool(auto)
try:
s, msg = exam.run(key, s, url, auto)
except Exception as e:
if e.__class__.__name__ != "SessionExpired":
runtime.log(key, "考核处理失败:%s" % e, level="warn")
return s
s = self._relogin(key)
try:
s, msg = exam.run(key, s, url, auto)
except Exception as e2:
runtime.log(key, "考核处理失败:%s" % e2, level="warn")
return s
if msg:
runtime.set_state(key, status="exam", total=1, done=1)
return s
def quiz(self, s, d, b):
"""课程考核的题目:考核是独立活动页,解析它上面的 testForm。已交卷也返回题目,方便回看。"""
cw = _exam_activity(d.get("courseware"))
if not cw:
return {"ok": False, "error": "这门课没有考核"}
cid = d["plan_id"]
html = s.get(cw["href"], timeout=30).text
done = exam.finished(html)
ex = exam.parse(html)
questions = [
{
"no": str(q["no"]),
"type": exam.TYPE_CODE.get(q["type"], "01"),
"title": q["title"],
"options": [{"key": o["key"], "text": o["text"]} for o in q["options"]],
}
for q in (ex or {}).get("questions") or []
]
if not done and not questions:
return {"ok": False, "error": "考核页里没有找到题目(页面结构可能变了)"}
return {
"ok": True,
"platform": http.PLATFORM_ID,
"plan_id": cid,
"object_id": cw["aid"],
"title": "课程考核",
"done": done,
"questions": questions,
}
def quiz_submit(self, s, b):
"""弹窗里点交卷:按用户选的字母答案提交,返回得分。"""
if not b.object_id:
return {"ok": False, "error": "缺少考核活动 ID,请重新打开测验"}
url = http.X + "/" + b.object_id + "/study/course/" + b.plan_id
return exam.submit_letters(s, url, b.answers)
def _progress(self, key, s, cid):
"""取进度页;掉线就重登后再取一次,返回 (会话, 进度)。"""
try:
return s, course.progress(s, cid)
except Exception as e:
if e.__class__.__name__ != "SessionExpired":
raise
notify.retry(key, "会话掉线重登", "取进度页时会话失效,自动重新登录")
s = self._relogin(key)
return s, course.progress(s, cid)
def _relogin(self, key):
"""掉线后按库里存的账号密码重登,返回新会话。"""
acc = database.account_get(key) or {}
pw = database.account_password(key)
if not pw:
raise http.expired("会话掉线,且没有保存密码,无法自动重连")
runtime.log(key, "会话掉线,正在用账号密码自动重新登录…", level="warn")
s, _ = self.login(acc.get("username") or "", pw)
# 新会话要写回账号和运行状态:否则库里留的还是刚失效的那份,
# 下次「刷新课程」立刻又判过期,反复触发重登。
cookies = self.dump_cookies(s)
database.account_save_cookies(key, cookies)
runtime.set_state(key, cookies=cookies)
runtime.log(key, "重新登录成功,继续学习")
return s
PLATFORM = registry.register(Gdgxk())