feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user