#!/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:原生