Files
study-tool/src/study_tool/plugins/gdgxk/main.py
T
study-tool aa48145557 feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
2026-11-01 17:47:22 +08:00

305 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""广东公需课: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())