"""广东公需课: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): """课程目录里的考核活动:考核是独立活动页(//study/course/),题目只在它上面。""" 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 # 左上角头像区:姓名 m = re.search(r'class="name"[^>]*>([^<]{1,20})', 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())