#!/usr/bin/env python3 """ 多账号学习控制台 —— 东莞市教师培训平台 (jspx.dgjy.net) 设计 * 账号、密码、会话 cookie、课程缓存、运行日志、系统设置全部存 **SQLite3**(store.py) 默认库文件 data/study.db,可用 DB_PATH 指定;重启/换机器只搬这一个文件 * 登录后自动抓取 /admin/main.action 的「在学课程」列表 * 勾选课程 -> 并发挂机 -> 自动交反馈表与测验 * 多个账号可同时运行 启动: python app.py 浏览器打开 http://127.0.0.1:8848 (Docker 见 README) """ import json import os import re import shutil import subprocess import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed from html import unescape as html_unescape from urllib.parse import parse_qs, unquote, urljoin, urlsplit import requests import uvicorn from bs4 import BeautifulSoup from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from pydantic import BaseModel import store HERE = os.path.dirname(os.path.abspath(__file__)) DATA = os.path.join(HERE, "data") LOGDIR = os.path.join(DATA, "logs") # 旧版日志目录;只有首次迁移会读它 os.makedirs(LOGDIR, exist_ok=True) def _find_node(): """Locate a node.exe: $NODE_BIN > PATH > common install dirs.""" for c in [os.environ.get("NODE_BIN"), shutil.which("node"), shutil.which("node.exe"), r"D:\Software\devlopSoftware\Node\24.16.0\node.exe", r"C:\Program Files\nodejs\node.exe", r"C:\Program Files (x86)\nodejs\node.exe"]: if c and os.path.exists(c): return c return "node" NODE = _find_node() DESENC = os.path.join(HERE, "desenc.js") UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0") JSPX = "https://jspx.dgjy.net" TPASS = "https://tyyh.dgjy.net/tpass" # 表单提交带上来源头,和浏览器一致(该站有 WAF,字段异常会直接 403) LOGIN_HEADERS = {"Referer": TPASS + "/login", "Origin": "https://tyyh.dgjy.net"} AUTHORIZE = ("https://tyyh.dgjy.net/tpass/oauth2.0/authorize?client_id=wpxtgl&scope=all" "&response_type=code&state=random&redirect_uri=https%3A%2F%2Fdj.dgjy.net%2Fhomepage" "%2Fremote%2Fopenapi%2Fdgwjt%2Flogin%3Furl%3Dhttps%3A%2F%2Fjspx.dgjy.net%2Ftstudy" "%2Fpassport%2Flogin.action") EP_STUDY = JSPX + "/train/trainee/courseware-studyTimeUpdate.action" # 新版培训平台(莞易学/study.dgjy.net)的课程接口。2026 年起的「慕课」课程不再 # 用 jspx 的 tier-tree 页面,而是 Vue SPA + /train/course/front/* 这组 JSON 接口。 TRAIN_AUTHORIZE_PATH = ("/oauth2.0/authorize?client_id=gyx&scope=all&response_type=code" "&state=random&redirect_uri=https%3A%2F%2Fstudy.dgjy.net%2F%23%2Flogin%2Fback") STUDY = os.environ.get("STUDY_BASE", "https://study.dgjy.net").rstrip("/") DEBUG_DIR = os.path.join(DATA, "debug") # 页面快照的兜底目录(正常存 SQLite 的 snapshots 表) os.makedirs(DEBUG_DIR, exist_ok=True) HEX32 = re.compile(r"^[0-9A-Fa-f]{32}$") # OAuth 授权码:tpass 发的是 ST-369516-xxxx-tpass,带连字符,不能只认 [0-9A-Za-z] CODE_RE = re.compile(r"(?:[?#&]|\b)code=([0-9A-Za-z._\-]{6,128})") # 「我的课程」的三个板块 -> 各自的 AJAX 列表接口与 courseType COURSE_SECTIONS = [ ("在学课程", "/train/trainee/studyingCourse.action", "studying"), ("助学课程", "/train/assist/assistanceCourse.action", "assistance"), ("督学课程", "/train/inspect/inspectingCourse.action", "inspecting"), ] # 每个板块的三种状态(页面上是「进行中 / 未开始 / 已结束」三个页签) PLAN_STATUS = [ ("进行中", "TAKING_PLAN"), ("未开始", "NO_BEGAIN_PLAN"), ("已结束", "FINISHED_PLAN"), ] # 只有这两种状态还能进去学;已结束的只能查看 STUDYABLE_STATUS = {"TAKING_PLAN", "NO_BEGAIN_PLAN"} class SessionExpired(RuntimeError): """站点要求重新登录(cookie 已失效)。""" # ------------------------------------------------------------------------ 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=""): 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)) platform = detect_platform(html, r.url) if platform == "jspx": d = parse_jspx_course(html) if d["courseware"] or d["feedback"] or d["test"]: d["platform"] = "jspx" 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 platform != "study": 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"] = "study" # 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 # ------------------------------------------------------- 新版平台:页面识别/提取 def detect_platform(html, page_url=""): """判断课程页是哪一代前端。 jspx 老页:div.tier-tree-one / p.tier3 新版:Vue SPA(
+ static/js/app.*.js)或直接带 /training/ 链接 """ if "tier-tree-one" in html or "tier3" in html: return "jspx" if has_study_marker(html, page_url): return "study" return "unknown" def has_study_marker(html, page_url=""): if page_url and "study.dgjy.net" in page_url: return True for k in ("/training/curriculum", "/training/courseVideo", "/training/comment", "static/js/app.", "study.dgjy.net/training", "study.dgjy.net/#/"): if k in html: return True flat = html.replace('"', "").replace("'", "") return "
" in flat def extract_train_id(page_url, html): """从跳转后的 URL / 页面内链接 / 隐藏域里找新版培训课程 ID(32 位 hex)。""" q = parse_qs(urlsplit(page_url or "").query) for k in ("trainCourseId", "courseId", "id"): for v in q.get(k, []): if HEX32.match(v or ""): return v # 页面里指向 study 的链接 / iframe / JS 跳转 for m in re.finditer(r"(?:https?:)?//[^\s\"'<>]*study\.dgjy\.net/[^\s\"'<>]+", html or ""): u = m.group(0) tail = u.split("#", 1)[1] if "#" in u else u qq = parse_qs(tail.split("?", 1)[1]) if "?" in tail else {} for k in ("trainCourseId", "courseId", "id"): for v in qq.get(k, []): if HEX32.match(v or ""): return v # 相对路径 /training/curriculum?id=... for m in re.finditer(r"/training/(?:curriculum|courseVideo|comment)\?([^\s\"'<>]+)", html or ""): qq = parse_qs(m.group(1).replace("&", "&")) for k in ("trainCourseId", "courseId", "id"): for v in qq.get(k, []): if HEX32.match(v or ""): return v # 隐藏域 try: soup = BeautifulSoup(html or "", "lxml") for i in soup.select("input[type=hidden]"): nm = (i.get("name") or "").lower() val = (i.get("value") or "").strip() if HEX32.match(val) and ("course" in nm or nm in ("id", "trainid")): return val except Exception: pass return "" def extract_study_token(page_url, history=None): """课程页跳转到 study 时,token 直接挂在 URL 上(前端读它写 Middle-Token cookie)。 courseStudyMain.action -> 302 study/openapi/train/front/getById?... -> 302 study/training/trainDetails?id=..&token= """ urls = [page_url or ""] for h in history or []: try: urls.append(h.headers.get("Location") or "") except Exception: pass for u in urls: if not u: continue tail = u.split("#", 1)[1] if "#" in u else u qq = parse_qs(tail.split("?", 1)[1]) if "?" in tail else {} for k in ("token", "access_token", "Middle-Token"): v = qq.get(k) if v and v[0]: return v[0] return "" def save_snapshot(plan, project, url, html): """存下原始页面(进 SQLite),方便站点再次改版时定位选择器。 返回给用户的是取回地址 /api/snapshot/;写库失败才退回文件。 """ safe_url = re.sub(r"([?&])(token|access_token|securityKey|sign)=[^&]*", r"\1\2=***", url or "") try: sid = store.snapshot_add(plan, project, safe_url, html or "") return "/api/snapshot/%d" % sid except Exception: pass name = "course_%s_%s_%s.html" % (re.sub(r"[^\w.-]", "_", str(plan))[:20], re.sub(r"[^\w.-]", "_", str(project))[:20], time.strftime("%H%M%S")) path = os.path.join(DEBUG_DIR, name) try: with open(path, "w", encoding="utf-8") as f: f.write("\n" % safe_url) f.write(html or "") return path except Exception: return "(保存失败)" # ------------------------------------------------------- 新版平台:HTTP 客户端 def _study_token(s): for c in s.cookies: if c.name == "Middle-Token" and "dgjy" in (c.domain or ""): return c.value return "" class StudyClient: """新版平台接口客户端。 API 前缀不写死:课程页可能由 jspx 代理、也可能跳到 study.dgjy.net, 所以按「页面 origin/train」→「study.dgjy.net/train」→「gjt/tp_edu_biz/train」 依次试,哪个能返回 200 就用哪个(结果缓存)。 """ def __init__(self, session, page_url=""): self.s = session self.token = _study_token(session) self.bases = [] origin = "" if page_url: u = urlsplit(page_url) if u.scheme and u.netloc: origin = "%s://%s" % (u.scheme, u.netloc) for b in ([origin + "/train"] if origin else []) + [STUDY + "/train"]: if b not in self.bases: self.bases.append(b) if origin and "gjt.dgjy.net" in origin: for b in (origin + "/tp_edu_biz/train",): if b not in self.bases: self.bases.append(b) self.base = None self.last_errors = [] def _raw(self, method, base, path, params=None, json_body=None): h = {"Accept": "application/json, text/plain, */*", "Referer": STUDY + "/training/curriculum"} if self.token: h["Authorization"] = "Bearer " + self.token r = self.s.request(method, base + path, params=params, json=json_body, headers=h, timeout=40) try: return r.json() except Exception: # 不是 JSON:多半是打到了错误前缀(返回登录页/HTML),标 -1 让 call() 换下一个 base return {"code": -1 if r.status_code < 400 else r.status_code, "msg": "非 JSON 响应:%s" % (r.text or "")[:120]} def call(self, method, path, params=None, json_body=None): bases = ([self.base] if self.base else []) + [b for b in self.bases if b != self.base] self.last_errors = [] saw_auth = False for b in bases: try: j = self._raw(method, b, path, params, json_body) except Exception as e: self.last_errors.append("%s -> %s" % (b, e)) continue code = j.get("code") if code in (401, 403) or code == 404 or code == -1: saw_auth = saw_auth or code in (401, 403) self.last_errors.append("%s -> %s" % (b, j.get("msg") or ("业务码 %s" % code))) if self.base == b: self.base = None continue self.base = b return j if saw_auth and not self.base: raise SessionExpired("新版平台没有登录态(Middle-Token 缺失或已过期):请点「重新登录」。%s" % "; ".join(self.last_errors)[:180]) raise RuntimeError("新版平台接口不可用:%s" % ("; ".join(self.last_errors)[:300] or "无可用地址")) def bootstrap(self): """用现有 SSO 票据强制换一张新的 study.dgjy.net token(旧的可能已过期)。""" try: ensure_study_session(self.s, force=True) except Exception: pass self.token = _study_token(self.s) # ------------------------------------------------------- 新版平台:课程结构 def _flatten_periods(nodes, chapter="", out=None): out = [] if out is None else out for n in nodes or []: if not isinstance(n, dict): continue nm = re.sub(r"\s+", " ", n.get("name") or "").strip() row = dict(n) row["_chapter"] = chapter out.append(row) if isinstance(n.get("children"), list) and n["children"]: _flatten_periods(n["children"], nm or chapter, out) return out def _video_duration(n): f = n.get("file") or {} for v in (f.get("videoDuration"), f.get("duration"), f.get("fileDuration"), n.get("duration"), n.get("videoDuration")): try: iv = int(float(v)) if iv > 0: return iv except Exception: continue return 0 def _period_done(n): sl = n.get("studyLog") or {} if sl.get("completeTime"): return True try: return float(sl.get("progress") or 0) >= 1 except Exception: return False def _int_or_none(v): """平台把次数偶尔给成字符串;取不到就返回 None(页面按「未知」处理)。""" try: return int(v) except (TypeError, ValueError): return None # 视频文件类型 -> 播放器通道(来自课程播放页 chunk-3d0ebfea 的 judgeVideoType) CLOUD_TYPES = {"cloud_video", "middle"} # polyv 云视频:/period/middle/play MOOC_TYPES = {"mooc_video"} # 网易云课堂:走签名/moc 播放 OTHER_TYPES = {"other"} # 普通 mp4:原生