"""旧版平台(jspx.dgjy.net):会话 / cookie / 登录 / 课程抓取。 对外:new_session / login / scrape_courses / discover。 """ import json import re import subprocess import time from concurrent.futures import ThreadPoolExecutor from html import unescape as html_unescape from urllib.parse import unquote, urljoin, urlsplit import requests from bs4 import BeautifulSoup from study_tool.config import * from study_tool.platforms import registry from study_tool.platforms.detect import detect_generation, has_study_marker from study_tool.runtime import RUNS, RUNS_LOCK, log, set_state # ------------------------------------------------------------------------ crypto def str_enc(data: str) -> str: r = subprocess.run([NODE, DESENC, data, "1", "2", "3"], capture_output=True, text=True, timeout=30) if r.returncode != 0: raise RuntimeError("strEnc failed: %s" % (r.stderr or "")[:200]) return r.stdout.strip() # ------------------------------------------------------------------- http client 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 "dgjy" 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 whoami(html): soup = BeautifulSoup(html, "lxml") hdr = soup.select_one("#header") if hdr: m = re.search(r"你好[,,]?\s*(\S+)\s*老师", re.sub(r"\s+", " ", hdr.get_text(" ", strip=True))) if m: return m.group(1) return "" def parse_ids(text): plan = project = "" m = re.search(r"planId=([0-9A-Fa-f]{16,})", text or "") if m: plan = m.group(1) m = re.search(r"projectId=([0-9A-Fa-f]{16,})", text or "") if m: project = m.group(1) return plan, project def _follow_js_login(s, page): """补上浏览器才会执行的两跳,否则只拿得到 CASTGC、拿不到 jspx 的会话。 `oauth2.0/authorize` 链的最后一跳**不是 302**,而是 dj.dgjy.net 上一个 1486 字节的 JS 中转页,里面动态插 旧代码直接把它当正常页面解析 -> 一个表格都没有 -> 静默返回空列表, 于是「会话失效」「站点改版」「账号确实没有课」被压成同一条提示。 """ r = s.get(JSPX + "/admin/main.action", timeout=40) html = r.text or "" if "/admin/login.action" in html and "table-results" not in html: # CASTGC 还有效的话,这里能无密码把 jspx 会话补回来 if ensure_jspx_session(s): r = s.get(JSPX + "/admin/main.action", timeout=40) html = r.text or "" if "/admin/login.action" in html and "table-results" not in html: raise SessionExpired("登录已失效:站点把请求跳回了 /admin/login.action(请重新登录)") if "table-results" not in html: raise RuntimeError("课程页结构异常:未找到 table-results 表格(站点可能已改版,页面长度 %d)" % len(html)) return html def _parse_course_rows(html, section=""): """从「我的课程」列表片段里抽出课程行。 行结构(各板块一致):
在学课程

名字

班期:..分数:82成绩:合格

查看
注意 href 是**不带前导斜杠的相对路径**(`courseStudyMain.action?...`), 相对 `/train/trainee/` 解析,不能直接 `JSPX + href`。 """ soup = BeautifulSoup(html, "lxml") out = [] for table in soup.select("table.table-results"): th = table.select_one("th") sec = section or (re.sub(r"\s+", " ", th.get_text(" ", strip=True)) if th else "") # 只按「有没有课程行」判断,不能用 "暂无" in table.get_text(): # 表格里任何一个单元格出现「暂无」都会把整张表(含全部课程)丢掉。 rows = [tr for tr in table.select("tr") if tr.select_one("p.course-tt a")] for tr in rows: a = tr.select_one("p.course-tt a") href = unquote(a.get("href") or "") plan, project = parse_ids(href) if not plan or not project: continue dd = tr.select_one("p.course-dd") out.append({ "section": sec, "name": a.get_text(" ", strip=True), "info": re.sub(r"\s+", " ", dd.get_text(" ", strip=True)) if dd else "", "plan_id": plan, "project_id": project, "url": urljoin(JSPX + "/train/trainee/", href), "progress": "", }) return out def scrape_courses(s, with_progress=True): """把「我的课程」下三个板块 × 三种状态**全部**拉出来,含已学完的。 站点把列表拆成了 AJAX 接口,每个组合一次请求: /train/trainee/studyingCourse.action?courseType=studying&planStatus=TAKING_PLAN /train/assist/assistanceCourse.action?courseType=assistance&planStatus=FINISHED_PLAN /train/inspect/inspectingCourse.action?courseType=inspecting&planStatus=NO_BEGAIN_PLAN ... 只看 /admin/main.action 是看不到「已结束」课程的——那个首页只渲染在学/助学/督学 三张「进行中」的概览表,课一学完就从首页消失了。 返回空列表只代表「三个板块九种状态都没课」; 会话失效或页面改版一律抛异常,让调用方能给出准确提示。 """ # 先用首页校验会话,避免九个接口各自失败还看不出来 _jspx_call(s, lambda: fetch_admin(s)) out, seen = [], set() for section, path, ctype in COURSE_SECTIONS: for label, status in PLAN_STATUS: url = "%s%s?courseType=%s&planStatus=%s" % (JSPX, path, ctype, status) try: html = s.get(url, timeout=40).text except Exception: continue for c in _parse_course_rows(html, section): key = (c["plan_id"], c["project_id"]) if key in seen: continue seen.add(key) c["course_type"] = ctype c["status"] = status c["status_label"] = label c["in_study"] = status in STUDYABLE_STATUS out.append(c) # 进度只在「进行中/未开始」的课上查,已结束的动辄几十门,逐个请求太慢 if with_progress: for c in out: if not c["in_study"]: continue try: rr = s.get(c["url"], timeout=30) p = BeautifulSoup(rr.text, "lxml").select_one("p.loading font") c["progress"] = p.get_text(strip=True) if p else "" except Exception: pass return out def parse_jspx_course(html): """老版 jspx 课程页(tier-tree)解析。""" soup = BeautifulSoup(html, "lxml") course = soup.select_one("h1") prog = soup.select_one("p.loading font") info = soup.select_one("div.results-info p.info") cw, fb, ts = [], [], [] for one in soup.select("div.tier-tree-one"): ns = one.select_one("p.tier1.has-children span.name") chapter = re.sub(r"\s+", " ", ns.get_text(" ", strip=True)) if ns else "?" for p in one.select("p.tier3"): a = p.select_one("a") if not a: continue href = a.get("href") or "" txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) name = p.select_one("span.name") title = re.sub(r"\s+", " ", name.get_text(" ", strip=True)) if name else txt[:40] m = re.search(r"(\d+)分钟", txt) done = "已完成" in txt if "courseware.action" in href: cw.append({"chapter": chapter, "title": title, "href": href, "done": done, "duration": int(m.group(1)) if m else None}) elif "doFeedback.action" in href: fb.append({"title": title, "href": href, "done": done}) elif "doTest.action" in href: ts.append({"title": title, "href": href, "done": done}) return { "user": whoami(html), "course": course.get_text(" ", strip=True) if course else "?", "progress": prog.get_text(strip=True) if prog else "?", "info": re.sub(r"\s+", " ", info.get_text(" ", strip=True)) if info else "?", "courseware": cw, "feedback": fb, "test": ts, } def discover(s, plan, project, name=""): """打开课程页并解析;会话失效时先用 CASTGC 补一次 jspx 授权再重试。""" return _jspx_call(s, lambda: _discover_once(s, plan, project, name)) def _discover_once(s, plan, project, name=""): # study.py 反向 import 本模块,模块级 import 会成环,用到时再取 from study_tool.platforms.study import (StudyClient, discover_study, extract_study_token, extract_train_id, save_snapshot, study_resolve_train_id) url = "%s/train/trainee/courseStudyMain.action?planId=%s&projectId=%s&trainRole=0" % (JSPX, plan, project) r = s.get(url, timeout=40) html = r.text or "" if "/admin/login.action" in html and len(html) < 2000: # 课程页也走同一个判断:CASTGC 有效就无密码续期 if ensure_jspx_session(s): r = s.get(url, timeout=40) html = r.text or "" if "没有权限" in html: raise RuntimeError("该账号对此课程没有权限") if "/admin/login.action" in html and len(html) < 2000: raise SessionExpired("登录已失效:课程页把请求跳回了 /admin/login.action(请重新登录)") # 课程页 302 到 study 时,token 在跳转 URL 里;哪怕 SPA 外壳返回空也要当成学过的新平台 study_url = "study.dgjy.net" in (r.url or "") tok = extract_study_token(r.url, r.history) if tok: host = urlsplit(r.url).hostname or urlsplit(STUDY).hostname try: s.cookies.set("Middle-Token", tok, domain=host, path="/") except Exception: pass if len(html) < 3000 and not (study_url or tok or has_study_marker(html, r.url)): raise RuntimeError("课程页返回异常(页面长度仅 %d,登录可能已失效)" % len(html)) generation = detect_generation(html, r.url) # 按登录 / 学习入口识别平台;以后新增平台时这里不用改,靠 Platform.entries 命中 pf = registry.detect(r.url, html) platform_id = pf.id if pf else "guanyixue" if generation == "legacy": d = parse_jspx_course(html) if d["courseware"] or d["feedback"] or d["test"]: d["platform"] = platform_id d["generation"] = "legacy" return d # 老结构但一个活动都没有:多半是「慕课」外链页(页面还在 jspx,课时在 study 平台) if not has_study_marker(html, r.url): snap = save_snapshot(plan, project, r.url, html) raise RuntimeError("课程页能打开,但没有任何课件/反馈表/测验(页面已存 %s)。" "若是慕课课程,请确认新平台登录态是否有效" % snap) elif generation != "spa": snap = save_snapshot(plan, project, r.url, html) raise RuntimeError("无法识别课程页结构(站点可能又改版):页面里既没有 jspx 的 " "tier-tree,也没有新版 SPA 标记。页面已存 %s,请把它发我" % snap) # ---- 新版平台(Vue SPA + /train/course/front/* JSON 接口)---- tid = extract_train_id(r.url, html) user = whoami(html) client = StudyClient(s, r.url) def _load(): nonlocal tid if not tid: tid = study_resolve_train_id(client, name) if not tid: snap = save_snapshot(plan, project, r.url, html) raise RuntimeError("新版课程页未带 trainCourseId,且按课程名也匹配不到(课程名:%s)。" "页面已存 %s" % (name or "?", snap)) return discover_study(client, tid, name, user) try: d = _load() except SessionExpired: client.bootstrap() tid = extract_train_id(r.url, html) d = _load() d["platform"] = platform_id d["generation"] = "spa" # URL 里可能带 token(凭据),日志/状态里一律打码;鉴权已经写进 cookie d["page_url"] = re.sub(r"([?&])(token|access_token|securityKey|sign)=[^&]*", r"\1\2=***", r.url or "") d["plan_id"] = plan d["project_id"] = project return d # ------------------------------------------------------------ 旧版学习流程 class CwItem: def __init__(self, d, cookies): self.title = d["title"] href = d["href"] self.url = href if href.startswith("http") else JSPX + href self.s = new_session(cookies) self.rec = None self.status = "done" if d.get("done") else "pending" self.last = None def resolve(self): try: r = self.s.get(self.url, timeout=40) except Exception as e: return "exc:%s" % e m = re.search(r"'id':'([0-9A-F]{32})'", r.text) if not m: return "no-id" self.rec = m.group(1) return "ok" def start(self): try: r = self.s.post(EP_STUDY, data={"id": self.rec, "order": "server_start", "updatePassPerc": "true"}, timeout=40) return r.text.strip()[:40] except Exception as e: return "EXC:%s" % e def beat(self): try: r = self.s.post(EP_STUDY, data={"id": self.rec, "order": "server_time", "updatePassPerc": "true", "videoSchedule": 30}, timeout=40) return r.text.strip()[:60] except Exception as e: return "EXC:%s" % e def submit_feedback(s, href, key): url = href if href.startswith("http") else JSPX + href r = s.get(url, timeout=40) soup = BeautifulSoup(r.text, "lxml") fields = {i.get("name"): i.get("value") for i in soup.select("form input[type=hidden]") if i.get("name")} if fields.get("allowStudy") == "false": log(key, "反馈表:allowStudy=false,跳过") return r2 = s.post(JSPX + "/train/trainee/doFeedbackList.action", data=fields, timeout=40) qs = BeautifulSoup(r2.text, "lxml") answers = [] for it in qs.select("div.job-item"): qtype = it.get("questype") opts = [sp.get("data-name") for sp in it.select(".job-item-a span")] ans = ("课程内容充实,讲师讲解清晰,对我的教学实践很有帮助,希望今后能增加更多一线教学案例。" if qtype == "04" else (opts[0] if opts else "A")) answers.append({"id": it.get("traineeid"), "quesId": it.get("qumid"), "quesType": qtype, "quesNo": it.get("quesno"), "answer": ans}) payload = {"planId": fields.get("planId"), "objectId": fields.get("objectId"), "answers": json.dumps(answers, ensure_ascii=False, separators=(",", ":")), "submitStatus": "finish"} rr = s.post(JSPX + "/train/trainee/saveFeedback.action", data=payload, timeout=40) log(key, "反馈表:%d 题,%s" % (len(answers), "提交成功" if "MSG" not in rr.text else "返回 " + rr.text[:80])) def submit_quiz(s, href, key, answer_key=None): url = href if href.startswith("http") else JSPX + href plan, _ = parse_ids(url) m = re.search(r"objectId=([0-9A-Fa-f]+)", url) obj = m.group(1) if m else "" rr = s.get("%s/train/trainee/showTestQums.action?planId=%s&objectId=%s" % (JSPX, plan, obj), timeout=40) qs = BeautifulSoup(rr.text, "lxml") singles = (answer_key or {}).get("singles", {}) multis = (answer_key or {}).get("multis", {}) answers = [] for it in qs.select("div.job-item"): t, qno = it.get("questype"), it.get("quesno") opts = [sp.get("data-name") for sp in it.select(".job-item-a span")] if t == "02": ans = (multis.get(qno) or ("#".join(opts) if opts else "A")) else: ans = singles.get(qno) or (opts[0] if opts else "A") answers.append({"id": it.get("traineeid"), "quesId": it.get("qumid"), "quesType": t, "quesNo": qno, "answer": ans}) if not answers: log(key, "测验:未取到题目,跳过") return payload = {"planId": plan, "objectId": obj, "answers": json.dumps(answers, ensure_ascii=False, separators=(",", ":"))} r3 = s.post(JSPX + "/train/trainee/endTest.action", data=payload, timeout=40) r4 = s.get("%s/train/trainee/doTest.action?planId=%s&objectId=%s" % (JSPX, plan, obj), timeout=40) sc = re.search(r"得分:\s*(\d+)\s*分", re.sub(r"\s+", " ", BeautifulSoup(r4.text, "lxml").get_text(" ", strip=True))) log(key, "测验:%d 题,%s,得分 %s" % (len(answers), "提交成功" if "MSG" not in r3.text else r3.text[:60], sc.group(1) if sc else "?")) def _study_builtin(key, s, d, workers=1, cap=30, enable_quiz=False, enable_feedback=False, mode="fast", parallel=1, quiz_key="", cookies=None, plan="", project="", name=""): """旧版 jspx 的内置流程:反馈表 + 测验 + 刷课件。""" if enable_feedback: for fb in d["feedback"]: if fb["done"]: log(key, "反馈表已完成,跳过") continue try: submit_feedback(s, fb["href"], key) except Exception as e: log(key, "反馈表异常:%s" % e) else: for fb in d["feedback"]: log(key, "反馈表:已按设置跳过(%s)" % ("已完成" if fb["done"] else "未完成")) if enable_quiz: for t in d["test"]: if t["done"]: log(key, "测验已完成,跳过") continue try: submit_quiz(s, t["href"], key, quiz_key) except Exception as e: log(key, "测验异常:%s" % e) else: for t in d["test"]: if t["done"]: log(key, "测验:已完成,跳过") else: log(key, "测验:⚠ 已按设置跳过答题(避免因正确率不足卡住「完成」)," "需要时请在页面上手动作答,或打开「自动交测验」开关") objs = [CwItem(c, cookies) for c in d["courseware"]] todo = [o for o in objs if o.status != "done"] done0 = len(objs) - len(todo) set_state(key, status="studying", total=len(objs), done=done0) if todo: log(key, "解析 %d 个学习记录…" % len(todo)) with ThreadPoolExecutor(max_workers=min(workers, len(todo))) as ex: list(ex.map(lambda x: x.resolve(), todo)) live = [o for o in todo if o.rec] log(key, "已解析 %d/%d,并发启动会话" % (len(live), len(todo))) with ThreadPoolExecutor(max_workers=min(workers, len(live))) as ex: list(ex.map(lambda x: x.start(), live)) t0 = time.time() while time.time() - t0 < cap * 60: with RUNS_LOCK: if (RUNS.get(key) or {}).get("status") == "stopped": log(key, "已按要求停止") return pending = [o for o in live if o.status != "done"] if not pending: break with ThreadPoolExecutor(max_workers=min(workers, len(pending))) as ex: resps = list(ex.map(lambda x: x.beat(), pending)) newly = [] for o, resp in zip(pending, resps): o.last = resp if resp == "study_over": o.status = "done" newly.append(o) el = int(time.time() - t0) dc = len([o for o in live if o.status == "done"]) + done0 set_state(key, done=dc, elapsed=el) log(key, "第 %d 轮 %dm%02ds 完成 %d/%d" % (el // 60 + 1, el // 60, el % 60, dc, len(objs))) for o in newly: log(key, " ✓ %s" % o.title[:40]) time.sleep(60) else: log(key, "所有课件均已完成") try: d2 = discover(s, plan, project, name) set_state(key, progress=d2["progress"], info=d2["info"], cookies=dump_cookies(s)) log(key, "课程结束:%s | %s" % (d2["progress"], d2["info"])) except Exception as e: log(key, "复查失败:%s" % e) # ---------------------------------------------------------------- 平台注册 def _study(key, s, d, **opts): """旧版页面:反馈表 + 测验 + 刷课件。""" return _study_builtin(key, s, d, **opts)