Files
study-tool/src/study_tool/pugins/guanyixue/jspx.py
T
study-tool 8e8d6b83f1 fix(env): 本机启动读取根目录 .env,监听地址兼容 BIND_ADDR
- study_tool/__init__.py 用 python-dotenv 加载根目录 .env,位置早于
  store / config 读环境变量;已有环境变量优先,Docker 注入的不受影响
- app.py 监听地址改为 HOST -> BIND_ADDR -> 127.0.0.1 依次回退
- 新增依赖 python-dotenv>=1.0(pyproject.toml / requirements.txt / uv.lock)
- README 环境变量表补充 .env 说明与 BIND_ADDR / TZ 两行
- CHANGELOG / docs/OPTIMIZATION.md 记录本次修复,版本号 0.0.6 -> 0.0.7
2026-10-31 16:12:52 +08:00

826 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""旧版平台(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 中转页,里面动态插 <script>:
scriptLogin.src = 'https://passport.dgjy.net/scriptLogin?action=simpleLogin&d1=...&d2=...';
scriptLogin 返回的是 JS,不是跳转:
var loginError=null;
var verifyURL="https://dj.dgjy.net/fpf/login/verify.action?ticket=...&auth=...&sid=...";
location.href=verifyURL;
verify.action 之后才会 302 → loginCall → jspx/tstudy/passport/login.action,
到这一步 jspx.dgjy.net 的 JSESSIONID 才真正下发。
requests 不执行 JS,所以这两跳必须手工走一遍。
"""
m = re.search(r"scriptLogin\.src\s*=\s*'([^']+)'", page or "")
if not m:
return False
r = s.get(html_unescape(m.group(1)), timeout=40)
body = r.text or ""
if re.search(r"loginError\s*=\s*(?!null)", body):
raise RuntimeError("SSO 校验未通过:%s" % body.strip()[:200])
m2 = re.search(r'verifyURL\s*=\s*"([^"]+)"', body)
if not m2:
return False
s.get(html_unescape(m2.group(1)).replace("\\/", "/"), timeout=40, allow_redirects=True)
return True
def _js_redirect_candidates(page):
"""从一个不执行 JS 的响应体里,按出现顺序抽出下一跳 URL。"""
out = []
for pat in (r"scriptLogin\.src\s*=\s*'([^']+)'",
r'scriptLogin\.src\s*=\s*"([^"]+)"',
r'verifyURL\s*=\s*"([^"]+)"',
r"verifyURL\s*=\s*'([^']+)'",
r"location\.href\s*=\s*['\"]([^'\"]+)['\"]",
r"window\.location\.href\s*=\s*['\"]([^'\"]+)['\"]"):
out.extend(re.findall(pat, page or ""))
return [html_unescape(u).replace("\\/", "/") for u in out if u]
def follow_js_hops(s, start_url, stop=None, max_hops=6):
"""通用 SSO 补跳:页面给的不是 302 而是 JS/scriptLogin 时,手工走完。
study.dgjy.net 的授权链和 jspx 不是同一套页面模板,所以这里按「下一跳 URL」
逐个 GET,直到 stop() 为真(例如 Middle-Token 已经下发)或没有下一跳。
"""
cur, seen = start_url, set()
for _ in range(max_hops):
if cur in seen:
break
seen.add(cur)
try:
r = s.get(cur, timeout=40, allow_redirects=True)
except Exception:
break
if stop and stop():
return True
nxt = _js_redirect_candidates(r.text or "")
if not nxt:
break
cur = urljoin(r.url, nxt[0])
return bool(stop and stop())
def _login_error(html, status=200):
"""从 /tpass/login 的响应里取出服务端提示。
提示藏在 <script type="text/html" id="login_template"> 里的
<span id="errormsghide" style="display:none">,BeautifulSoup 的 get_text()
看不见 script 内容,所以只能按原文正则取;锁号是另一张 403 提示页。
"""
text = html or ""
m = re.search(r"验证失败次数过多[^<]*", text)
if m:
return m.group(0).strip()
m = re.search(r'<span[^>]*id="errormsghide"[^>]*>(.*?)</span>', text, re.S)
if m:
msg = re.sub(r"<[^>]+>", " ", m.group(1))
msg = re.sub(r"\s+", " ", html_unescape(msg).replace("\xa0", " ")).strip()
if msg:
return msg
m = re.search(r"(用户名|密码|账号|账户)[^<>]{0,40}(错误|不存在|不正确|锁定)", text)
if m:
return m.group(0).strip()
if status == 403:
return "账号已被锁定(403),约 10 分钟后解锁"
return ""
def parse_accountlist(html):
"""多身份账号:登录响应里内联 `var accountlist = '<json>';`。
每个元素带 USERNAME(身份证)、CODENAME(教师/家长/学生)、UNIT_NAME,
以及该身份自己的 LOGINUSERNAME / LOGINPASSWORD —— 选身份那一步是服务端
把可登录的账号密码回给前端,再带 choosenumber 提交一次。
"""
m = re.search(r"var\s+accountlist\s*=\s*'(.*?)';", html or "", re.S)
if not m:
return []
try:
data = json.loads(html_unescape(m.group(1)))
except Exception:
return []
return data if isinstance(data, list) else []
def _login_as_identity(s, un, lt, ex, it):
"""按 login_dgjy.js 的选身份分支提交:rsa 用该身份的 LOGINUSERNAME+LOGINPASSWORD。"""
name = it.get("LOGINUSERNAME") or ""
pwd = it.get("LOGINPASSWORD") or ""
payload = {"un": un, "ul": str(len(name)), "pl": str(len(pwd)),
"rsa": str_enc(name + pwd + lt), "lt": lt,
"choosenumber": it.get("USERNAME") or "", "_eventId": "submit"}
if ex:
payload["execution"] = ex
return s.post(TPASS + "/login", data=payload, timeout=30, allow_redirects=True,
headers=LOGIN_HEADERS)
def ensure_jspx_session(s):
"""用 CASTGC 走老平台(jspx)的授权链,建立 jspx.dgjy.net 的会话;成功返回 True。
必须先把 jspx 的 JSESSIONID / route 清掉:这两个是粘性会话 cookie,
浏览器里带过来的那份常常指向已经下线的后端节点(route 对不上号),
留着就永远跳 /admin/login.action,看起来像「登录失效」。
"""
try:
r = s.get(JSPX + "/admin/main.action", timeout=30)
if "table-results" in (r.text or ""):
return True
except Exception:
pass
for c in list(s.cookies):
if "jspx" in (c.domain or ""):
s.cookies.clear(c.domain, c.path, c.name)
try:
r = s.get(AUTHORIZE, timeout=40, allow_redirects=True)
_follow_js_login(s, r.text or "")
r2 = s.get(JSPX + "/admin/main.action", timeout=30)
return "table-results" in (r2.text or "")
except Exception:
return False
def _jspx_call(s, fn):
"""跑一个 jspx 请求;会话失效且手上还有 CASTGC 时,清掉粘性 cookie 重新授权后重试一次。
浏览器 / IndexedDB 里存着的 jspx JSESSIONID+route 经常指向已经下线的后端节点,
fetch_admin 看到的就是「跳回 /admin/login.action」,看着像登录过期,
实际 CASTGC 还有效 —— 这种不该让用户重新输密码。
"""
try:
return fn()
except SessionExpired:
if not ensure_jspx_session(s):
raise
return fn()
def login(username: str, password: str):
"""SSO login -> (session, user)。"""
s = new_session()
r = s.get(TPASS + "/login", timeout=30)
lt = re.search(r'id="lt"[^>]*value="([^"]*)"', r.text)
lt = lt.group(1) if lt else ""
ex = re.search(r'name="execution"[^>]*value="([^"]*)"', r.text)
payload = {
"un": username, "pd": password,
"ul": str(len(username)), "pl": str(len(password)),
"rsa": str_enc(username + password + lt),
"lt": lt, "choosenumber": "", "_eventId": "submit",
}
if ex:
payload["execution"] = ex.group(1)
r2 = s.post(TPASS + "/login", data=payload, timeout=30, allow_redirects=True,
headers=LOGIN_HEADERS)
if not any(c.name == "CASTGC" for c in s.cookies):
accts = parse_accountlist(r2.text)
if accts:
# 一个身份证下挂多个身份时,第一次提交只回账号选择页,不会给票据。
# 这里取教师身份(本平台主要场景),没有就取第一个,然后用该身份重提。
pick = next((x for x in accts if "教师" in (x.get("CODENAME") or "")), accts[0])
m = re.search(r'id="lt"[^>]*value="([^"]*)"', r2.text)
ex2 = re.search(r'name="execution"[^>]*value="([^"]*)"', r2.text)
r2 = _login_as_identity(s, username, (m.group(1) if m else lt),
(ex2.group(1) if ex2 else (ex.group(1) if ex else "")), pick)
if not any(c.name == "CASTGC" for c in s.cookies):
msg = _login_error(r2.text, r2.status_code)
raise RuntimeError((msg or "登录失败:未获得登录票据(账号或密码错误?)")[:200])
# 老平台:authorize 链的终点是 JS 中转页,必须手工补两跳才建得起 jspx 会话
if not ensure_jspx_session(s):
raise RuntimeError("SSO 登录未完成:jspx.dgjy.net 没有建立会话(登录链路可能已改版)")
html = fetch_admin(s)
# 同一张 CASTGC 再授权一次给新版平台(client_id=gyx),拿 Middle-Token
ensure_study_session(s)
return s, whoami(html)
def _next_hop_urls(page):
"""不执行 JS 时,从响应体里找出所有可能的下一跳(SSO 中转页/脚本/表单)。"""
out = _js_redirect_candidates(page)
for m in re.finditer(r'(?:src|href)\s*=\s*["\']([^"\']*(?:login|callback|authorize|verify|code)[^"\']*)["\']',
page or "", re.I):
out.append(html_unescape(m.group(1)))
for m in re.finditer(r'<form[^>]+action=["\']([^"\']+)["\']', page or "", re.I):
out.append(html_unescape(m.group(1)))
seen, uniq = set(), []
for u in out:
u = u.replace("\\/", "/")
if u.startswith("http") or u.startswith("/") or "?" in u:
if u not in seen:
seen.add(u)
uniq.append(u)
return uniq
def ensure_study_session(s, force=False):
"""用现有 SSO 票把 study.dgjy.net 的会话补上。
新版平台的登录是 OAuth 授权码换 token(前端 SPA 源码里写死了这几个地址):
GET tyyh.dgjy.net/tpass/oauth2.0/authorize?client_id=gyx
&redirect_uri=https://study.dgjy.net/#/login/back&response_type=code
... -> study.dgjy.net/#/login/back?code=ST-xxxx-tpass
POST study.dgjy.net/mod/gjt/login/<CODE> # 换出前端会话(JSESSIONID)
GET study.dgjy.net/mod/education/gyx/train/front/login
-> {"result":{"url":".../training","token":"<jwt>"}} # 这才是 Middle-Token
成功标准只有一个:cookie 里出现 study.dgjy.net 的 Middle-Token。
"""
from study_tool.platforms.study import _study_token
if force:
for c in list(s.cookies):
if c.name == "Middle-Token":
try:
s.cookies.clear(c.domain, c.path, c.name)
except Exception:
pass
if _study_token(s):
return True
try:
r = s.get(TPASS + TRAIN_AUTHORIZE_PATH, timeout=40, allow_redirects=True)
except Exception:
return False
cur_url, body = r.url or "", r.text or ""
tried = set()
for _ in range(6):
m = CODE_RE.search(body) or CODE_RE.search(cur_url)
if m and m.group(1) not in tried:
tried.add(m.group(1))
if _exchange_code(s, m.group(1)):
return True
hops = _next_hop_urls(body)
if not hops:
break
nxt = urljoin(cur_url or STUDY, hops[0])
if "code=" in nxt:
code = (CODE_RE.search(nxt) or [None, ""])[1]
if code and code not in tried and _exchange_code(s, code):
return True
try:
r = s.get(nxt, timeout=40, allow_redirects=True)
except Exception:
break
cur_url, body = r.url or "", r.text or ""
return bool(_study_token(s))
def _fetch_train_token(s):
"""SSO 登录后,再用 gyx 会话换培训系统的 token(前端 `_51` 那个接口)。
返回体形如:
{"result":{"url":"https://study.dgjy.net/training","token":"<jwt>"},"resultCode":200}
这个 token 就是培训系统的 `Middle-Token`(前端把它写进 cookie 再发 Bearer)。
"""
try:
r = s.get(STUDY + "/mod/education/gyx/train/front/login", timeout=40,
headers={"Accept": "application/json, text/plain, */*"})
j = r.json()
except Exception:
return False
data = j.get("result") if isinstance(j, dict) else None
if not isinstance(data, dict):
data = j.get("data") if isinstance(j, dict) else None
tok = ""
if isinstance(data, dict):
for k in ("token", "accessToken", "middleToken"):
if data.get(k):
tok = str(data[k])
break
if not tok:
return False
s.cookies.set("Middle-Token", tok, domain=urlsplit(STUDY).hostname, path="/")
return True
def _exchange_code(s, code):
"""code -> Middle-Token。前端是 POST /mod/gjt/login/<code>,再取 gyx 培训 token。"""
from study_tool.platforms.study import _study_token
paths = ("/mod/gjt/login/%s", "/train/mod/gjt/login/%s")
for path in paths:
try:
rr = s.post(STUDY + (path % code), timeout=40,
headers={"Accept": "application/json, text/plain, */*"})
except Exception:
continue
if _study_token(s):
return True
try:
j = rr.json()
except Exception:
j = {}
ok = isinstance(j, dict) and str(j.get("resultCode")) == "200"
if ok:
# SSO 换票成功后,再换培训系统的 Bearer token(Middle-Token)
if _fetch_train_token(s):
return True
data = j.get("result") or {}
if isinstance(data, dict):
for k in ("token", "accessToken", "middleToken"):
if data.get(k):
s.cookies.set("Middle-Token", str(data[k]),
domain=urlsplit(STUDY).hostname, path="/")
return True
return False
def fetch_admin(s):
"""GET /admin/main.action。
站点会话失效时**不返回 302**,而是 200 + 55 字节的 JS 跳转存根:
<script>location.href="/admin/login.action";</script>
旧代码直接把它当正常页面解析 -> 一个表格都没有 -> 静默返回空列表,
于是「会话失效」「站点改版」「账号确实没有课」被压成同一条提示。
"""
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=""):
"""从「我的课程」列表片段里抽出课程行。
行结构(各板块一致):
<table class="table-results"><tr><th>在学课程</th></tr>
<tr><td><p class="course-tt"><a href="courseStudyMain.action?planId=..&projectId=..">名字</a></p>
<p class="course-dd"><span>班期:..</span><span>分数:82</span><span>成绩:合格</span></p>
</td><td><a class="abtn-orange-big">查看</a></td></tr>
</table>
注意 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)