feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""莞易学插件:`main.py` 注册平台,`utils/` 放按阶段拆分的工具,`detect.py` 识别页面代际。"""
|
||||
@@ -0,0 +1,108 @@
|
||||
"""抓包:把页面 XHR/fetch 落成 data/capture-*.json。"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from ... import database
|
||||
from study_tool.runtime import RUNS, log, set_state
|
||||
|
||||
from .config import STUDY
|
||||
|
||||
# ------------------------------------------------------- 抓包(排查平台行为)
|
||||
CAPTURES = {}
|
||||
|
||||
|
||||
def capture_worker(key, cookies, minutes=10):
|
||||
"""开一个有头浏览器,把这期间所有 /train/** 请求和响应记下来。
|
||||
|
||||
用于搞不清「某个状态到底由哪一发请求写成」:手动走一遍(点课程 → 播几秒 → 交评价),
|
||||
再看抓到的接口和响应,就能定位该自动补哪一步。
|
||||
"""
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except Exception as e:
|
||||
log(key, "抓包需要 playwright:%s" % e)
|
||||
return
|
||||
recs = CAPTURES.setdefault(key, [])
|
||||
log(key, "抓包开始:浏览器里手动走一遍(点课程 → 播几秒 → 交评价),完事点「停止抓包」,最多 %d 分钟" % minutes)
|
||||
|
||||
def on_resp(r):
|
||||
try:
|
||||
if r.request.resource_type not in ("xhr", "fetch"):
|
||||
return
|
||||
ct = (r.headers or {}).get("content-type", "")
|
||||
body = ""
|
||||
if "json" in ct:
|
||||
try:
|
||||
body = r.text()[:4000]
|
||||
except Exception:
|
||||
body = ""
|
||||
recs.append(
|
||||
{
|
||||
"t": time.strftime("%H:%M:%S"),
|
||||
"method": r.request.method,
|
||||
"url": r.url,
|
||||
"req": (r.request.post_data or "")[:1000],
|
||||
"status": r.status,
|
||||
"resp": body,
|
||||
}
|
||||
)
|
||||
u = urlsplit(r.url)
|
||||
log(key, " · %s %s%s → %s" % (r.request.method, u.netloc, u.path, r.status))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(headless=False)
|
||||
ctx = b.new_context()
|
||||
for c in cookies:
|
||||
if "dgjy" in (c.get("domain") or ""):
|
||||
try:
|
||||
ctx.add_cookies(
|
||||
[
|
||||
{
|
||||
"name": c["name"],
|
||||
"value": c.get("value") or "",
|
||||
"domain": (c.get("domain") or "").lstrip("."),
|
||||
"path": c.get("path") or "/",
|
||||
}
|
||||
]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 平台点课程/视频是 window.open 新开标签,必须把新页面也挂上监听,否则只抓到第一页
|
||||
hooked = set()
|
||||
|
||||
def hook(page):
|
||||
if id(page) in hooked:
|
||||
return
|
||||
hooked.add(id(page))
|
||||
page.on("response", on_resp)
|
||||
|
||||
ctx.on("page", hook)
|
||||
pg = ctx.new_page()
|
||||
hook(pg)
|
||||
# /training/ 根路径没有路由,会落到 404;直接开「我的培训」列表页
|
||||
pg.goto(STUDY + "/training/myTrain", wait_until="domcontentloaded", timeout=60000)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < minutes * 60:
|
||||
if (RUNS.get(key) or {}).get("capture_stop"):
|
||||
break
|
||||
pg.wait_for_timeout(1000)
|
||||
except Exception as e:
|
||||
log(key, "抓包异常:%s" % str(e)[:150])
|
||||
finally:
|
||||
path = ""
|
||||
if recs:
|
||||
try:
|
||||
path = os.path.join(database.DATA_DIR, "capture-%s-%s.json" % (key, time.strftime("%Y%m%d-%H%M%S")))
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(recs, f, ensure_ascii=False, indent=1)
|
||||
except Exception as e:
|
||||
log(key, "抓包结果保存失败:%s" % e)
|
||||
set_state(key, capturing=False, capture_stop=False, capture_file=path)
|
||||
log(key, "抓包结束:%d 条请求%s" % (len(recs), (",已存 " + path) if path else ""))
|
||||
@@ -0,0 +1,64 @@
|
||||
"""莞易学插件常量:站点地址、登录授权入口、课程板块、页面快照目录。
|
||||
|
||||
只给 `plugins/guanyixue/` 内部用;其它平台各自在自己的插件目录里放常量。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from study_tool.config import DATA
|
||||
|
||||
|
||||
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(os.path.dirname(os.path.abspath(__file__)), "script", "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"}
|
||||
@@ -0,0 +1,39 @@
|
||||
"""页面代际识别。
|
||||
|
||||
莞易学的登录入口(jspx.dgjy.net)和学习入口(study.dgjy.net)指向同一套课程,
|
||||
但课程页有两代前端:旧版 jspx 页面和新版 Vue SPA。这里只根据页面 / URL 判断代际,
|
||||
平台归属由 `Platform.entries`(登录、学习入口)负责。
|
||||
"""
|
||||
|
||||
# 旧版页面骨架
|
||||
LEGACY_MARKERS = ("tier-tree-one", "tier3")
|
||||
|
||||
# 新版 SPA 标记:Vue 入口、培训课程接口路由、study 域名
|
||||
SPA_MARKERS = (
|
||||
"/training/curriculum",
|
||||
"/training/courseVideo",
|
||||
"/training/comment",
|
||||
"static/js/app.",
|
||||
"study.dgjy.net/training",
|
||||
"study.dgjy.net/#/",
|
||||
)
|
||||
SPA_HOST = "study.dgjy.net"
|
||||
|
||||
|
||||
def has_study_marker(html, page_url=""):
|
||||
"""页面是不是新版莞易学(study.dgjy.net / Vue SPA)。"""
|
||||
if page_url and SPA_HOST in page_url:
|
||||
return True
|
||||
if any(k in html for k in SPA_MARKERS):
|
||||
return True
|
||||
flat = html.replace('"', "").replace("'", "")
|
||||
return "<div id=app>" in flat
|
||||
|
||||
|
||||
def detect_generation(html, page_url=""):
|
||||
"""返回课程页代际:legacy(旧版)/ spa(新版)/ ""(无法识别)。"""
|
||||
if any(k in html for k in LEGACY_MARKERS):
|
||||
return "legacy"
|
||||
if has_study_marker(html, page_url):
|
||||
return "spa"
|
||||
return ""
|
||||
@@ -0,0 +1,70 @@
|
||||
"""莞易学平台:jspx.dgjy.net(登录入口)与 study.dgjy.net(学习入口)是同一个平台。
|
||||
|
||||
账号在「添加账号」里选择平台(`accounts.platform`),登录 / 刷新课程 / 发现课程都会
|
||||
走这里的 `login` / `list_courses` / `discover`;学习时 `discover` 会把页面代际写进
|
||||
`course["generation"]`(legacy / spa),由 `study` 分发到对应实现。
|
||||
工具按阶段拆在 `utils/`:http / login / course / feedback / learn / spa;
|
||||
抓包调试在 `capture.py`,测验解析 / 交卷 / AI 答题在 `quiz.py`。
|
||||
新增其他平台时,照本文件写一个 `Platform` 子类并在 `entries` 声明登录 / 学习入口。
|
||||
"""
|
||||
|
||||
from study_tool import runtime
|
||||
from study_tool.platforms import registry
|
||||
from study_tool.platforms.base import Platform
|
||||
|
||||
from . import capture, quiz # noqa: F401 quiz 导入即注册 /api/quiz 路由
|
||||
from .utils import course, http, learn, login, spa
|
||||
|
||||
|
||||
class Guanyixue(Platform):
|
||||
id = "guanyixue"
|
||||
label = "莞易学"
|
||||
# 登录入口 / 学习入口;命中任意一个就归到莞易学
|
||||
entries = ("jspx.dgjy.net", "study.dgjy.net", "dgjy.net")
|
||||
|
||||
def session(self, cookies=None):
|
||||
return http.new_session(cookies)
|
||||
|
||||
def dump_cookies(self, s):
|
||||
return http.dump_cookies(s)
|
||||
|
||||
def login(self, username, password):
|
||||
return login.login(username, password)
|
||||
|
||||
def list_courses(self, s):
|
||||
return course.scrape_courses(s)
|
||||
|
||||
def discover(self, s, plan_id, project_id, name=""):
|
||||
return course.discover(s, plan_id, project_id, name)
|
||||
|
||||
def renew_session(self, s):
|
||||
return login.ensure_jspx_session(s)
|
||||
|
||||
def profile(self, s):
|
||||
return login.whoami(login.fetch_admin(s))
|
||||
|
||||
def quiz(self, s, d, b):
|
||||
return quiz.fetch_quiz(s, d, b)
|
||||
|
||||
def quiz_submit(self, s, b):
|
||||
return quiz.submit_quiz(s, b)
|
||||
|
||||
def study(self, key, s, d, **opts):
|
||||
if d.get("generation") == "spa":
|
||||
page = (d.get("page_url") or "")[:110]
|
||||
runtime.log(key, "新版课程 ID:%s%s" % (d.get("train_id") or "?", (" | 页面:" + page) if page else ""))
|
||||
return spa._study(key, s, d, **opts)
|
||||
return learn._study(key, s, d, **opts)
|
||||
|
||||
# ---------------------------------------------------------- 抓包调试
|
||||
def capture(self, key, cookies, minutes=10):
|
||||
return capture.capture_worker(key, cookies, minutes)
|
||||
|
||||
def captures(self, key):
|
||||
return capture.CAPTURES.get(key) or []
|
||||
|
||||
def capture_reset(self, key):
|
||||
capture.CAPTURES[key] = []
|
||||
|
||||
|
||||
PLATFORM = registry.register(Guanyixue())
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "guanyixue",
|
||||
"title": "莞易学(东莞市教师培训平台)",
|
||||
"name": "guanyixue"
|
||||
}
|
||||
@@ -0,0 +1,797 @@
|
||||
"""测验:题目解析、交卷、AI 答题(流式)。"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from study_tool.config.settings import load_settings
|
||||
from study_tool.llm import llm_chat
|
||||
from study_tool.platforms import registry
|
||||
from study_tool.platforms.errors import SessionExpired
|
||||
from study_tool.runtime import log
|
||||
from study_tool.webapp import app
|
||||
|
||||
from .config import JSPX, STUDY
|
||||
from .utils.course import parse_ids
|
||||
from .utils.http import new_session
|
||||
from .utils.spa import StudyClient
|
||||
|
||||
# ------------------------------------------------------------------ quiz
|
||||
|
||||
|
||||
class QuizIn(BaseModel):
|
||||
cookies: list = []
|
||||
plan_id: str = ""
|
||||
project_id: str = ""
|
||||
name: str = ""
|
||||
platform: str = "guanyixue"
|
||||
# retake=True:交卷后点「再考一次」,让平台生成新答卷(消耗一次机会)
|
||||
retake: bool = False
|
||||
period_id: str = "" # 指定要重考/查看的课时(不传就按原逻辑挑第一个)
|
||||
|
||||
|
||||
class QuizSubmitIn(BaseModel):
|
||||
cookies: list = []
|
||||
platform: str = "" # 交卷按平台分发
|
||||
plan_id: str = ""
|
||||
object_id: str = ""
|
||||
generation: str = "legacy" # legacy / spa,决定走旧版还是新版交卷接口
|
||||
result_id: str = ""
|
||||
answers: dict = {} # {"1": "A", "2": "A#C"}
|
||||
|
||||
|
||||
class QuizAnswerIn(BaseModel):
|
||||
questions: list = [] # [{no,type,title,options:[{key,text}]}]
|
||||
prompt: str = ""
|
||||
account_id: str = "" # 有账号就把日志写进它的「课程日志」,没有就写 key=ai
|
||||
|
||||
|
||||
DEFAULT_ANSWER_PROMPT = (
|
||||
"你是考试答题助手。根据题干和选项判断正确答案。\n"
|
||||
"单选题返回一个选项字母;多选题返回多个字母,用 # 连接(如 A#C);判断题只返回 A 或 B(A=正确,B=错误)。\n"
|
||||
"只输出 JSON:{\"answers\":{\"题号\":\"答案\"}},不要输出解释或多余文字。"
|
||||
)
|
||||
TYPE_LABEL = {"01": "单选", "02": "多选", "03": "判断", "04": "问答"}
|
||||
|
||||
STUDY_QMAP = {
|
||||
"SINGLECHOICE": "01",
|
||||
"MULTIPLECHOICE": "02",
|
||||
"JUDGMENT": "03",
|
||||
"TRUEFALSE": "03",
|
||||
"FILLIN": "04",
|
||||
"SHORTANSWER": "04",
|
||||
"ESSAY": "04",
|
||||
}
|
||||
|
||||
# 判断题在新版平台里没有 metasData 选项(只有一条 sort/content/option 全 null 的占位),
|
||||
# 真正可选项是前端写死的「正确 / 错误」,交卷时 replay 用 0/1 下标表示。
|
||||
# 这里补出 A/B 两个伪选项,页面才有得选,交卷也能把字母换成正确下标。
|
||||
JUDGE_OPTIONS = [{"key": "A", "text": "正确"}, {"key": "B", "text": "错误"}]
|
||||
_ANSWER_YES = {"A", "正确", "对", "是", "√", "T", "TRUE", "Y", "YES"}
|
||||
_ANSWER_NO = {"B", "错误", "错", "否", "×", "X", "F", "FALSE", "N", "NO"}
|
||||
|
||||
|
||||
def answer_tokens(raw, options=None, qtype="01"):
|
||||
"""把答案文本拆成选项字母列表。
|
||||
|
||||
判断题允许模型/用户写「正确 / 错误 / 对 / 错 / √ / × / T / F」,
|
||||
统一收敛到 A(正确)/ B(错误);有选项时只保留合法字母;
|
||||
问答题没有选项,原样保留文本。
|
||||
"""
|
||||
keys = [o.get("key") for o in (options or []) if o.get("key")]
|
||||
parts = [p for p in re.split(r"[#,\s、/]+", str(raw or "").strip().upper()) if p]
|
||||
# 多选常被模型写成 AC / ABC 这种连写,按选项字母拆开
|
||||
if keys:
|
||||
parts = [c for p in parts for c in (list(p) if len(p) > 1 and all(ch in keys for ch in p) else [p])]
|
||||
out, seen = [], set()
|
||||
for p in parts:
|
||||
if p in keys:
|
||||
tok = p
|
||||
elif qtype == "03":
|
||||
tok = "A" if p in _ANSWER_YES else ("B" if p in _ANSWER_NO else "")
|
||||
elif not keys:
|
||||
tok = p
|
||||
else:
|
||||
tok = ""
|
||||
if tok and tok not in seen:
|
||||
seen.add(tok)
|
||||
out.append(tok)
|
||||
return out
|
||||
|
||||
|
||||
def _plain(s):
|
||||
return re.sub(r"\s+", " ", BeautifulSoup(s or "", "lxml").get_text(" ", strip=True)).strip()
|
||||
|
||||
|
||||
def study_parse_paper(data):
|
||||
"""把 /questions/front/testpaper/getById 的 data.questions 拍平成题目列表。
|
||||
|
||||
结构:questions = [{type, data: [{seq, questionType, stem, metasData:[{option,content}],
|
||||
itemResultId, replay}]}]
|
||||
replay 是已保存答案的**0 基选项下标**数组,例如 ["2"] = 第 3 个选项。
|
||||
"""
|
||||
out = []
|
||||
for grp in data.get("questions") or []:
|
||||
for it in grp.get("data") or []:
|
||||
qtype = STUDY_QMAP.get(str(it.get("questionType") or ""), "01")
|
||||
opts = []
|
||||
for m in it.get("metasData") or []:
|
||||
key = str(m.get("option") or "").strip().upper()
|
||||
if key:
|
||||
opts.append({"key": key, "text": _plain(m.get("content"))})
|
||||
opts.sort(key=lambda o: o["key"])
|
||||
# 判断题:平台不给选项,补上 正确/错误,页面才看得见、交卷才好映射 0/1
|
||||
if not opts and qtype == "03":
|
||||
opts = [dict(o) for o in JUDGE_OPTIONS]
|
||||
picked = ""
|
||||
rp = it.get("replay")
|
||||
if rp:
|
||||
try:
|
||||
idx = json.loads(rp) if isinstance(rp, str) else rp
|
||||
picked = "#".join(opts[int(i)]["key"] for i in idx if 0 <= int(i) < len(opts))
|
||||
except Exception:
|
||||
picked = ""
|
||||
out.append(
|
||||
{
|
||||
"no": str(int(it.get("seq") or 0) + 1),
|
||||
"type": qtype,
|
||||
"title": _plain(it.get("stem"))[:600],
|
||||
"options": opts,
|
||||
"item_id": it.get("itemResultId"),
|
||||
"picked": picked,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _idx_letters(raw, opts):
|
||||
"""review 接口里的 replay/answer 是 JSON 字符串形式的 0 基下标数组。"""
|
||||
if raw in (None, "", "null"):
|
||||
return ""
|
||||
try:
|
||||
idx = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
return ""
|
||||
if idx is None:
|
||||
return ""
|
||||
if not isinstance(idx, (list, tuple)):
|
||||
idx = [idx]
|
||||
out = []
|
||||
for i in idx:
|
||||
try:
|
||||
n = int(i)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 0 <= n < len(opts):
|
||||
out.append(opts[n]["key"])
|
||||
return "#".join(out)
|
||||
|
||||
|
||||
def _answer_text(raw):
|
||||
"""填空/问答题的标准答案:可能是 JSON 数组 [{content:…}],也可能就是文本。"""
|
||||
if raw in (None, ""):
|
||||
return ""
|
||||
v = raw
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
v = json.loads(v)
|
||||
except Exception:
|
||||
return _plain(v)
|
||||
if isinstance(v, list):
|
||||
parts = []
|
||||
for x in v:
|
||||
parts.append(_plain(x.get("content") or "") if isinstance(x, dict) else _plain(str(x)))
|
||||
return " / ".join(p for p in parts if p)
|
||||
if isinstance(v, dict):
|
||||
return _plain(v.get("content") or "")
|
||||
return _plain(str(v))
|
||||
|
||||
|
||||
def study_parse_review(data, show_answers=True):
|
||||
"""把 /questions/front/testpaper/teacher/review 的题目拍平,带标准答案/得分/解析。
|
||||
|
||||
review 的 item 与取题接口不同:
|
||||
replay = 学生作答的 0 基下标数组(JSON 字符串)
|
||||
answer = 标准答案:选择题是 0 基下标数组,填空/问答是 [{content:…}] 或文本
|
||||
answerScore / status / analysis = 得分、对错、解析
|
||||
|
||||
show_answers=False 对应测验配置 examAfterWatchAnswer=0:平台不给看答案,
|
||||
这里也不返回 correct/analysis,避免把不该展示的答案泄露到页面。
|
||||
"""
|
||||
out, seq = [], 0
|
||||
for grp in data.get("questions") or []:
|
||||
for it in grp.get("data") or []:
|
||||
seq += 1
|
||||
qtype = STUDY_QMAP.get(str(it.get("questionType") or ""), "01")
|
||||
opts = []
|
||||
for m in it.get("metasData") or []:
|
||||
key = str(m.get("option") or "").strip().upper()
|
||||
if key:
|
||||
opts.append({"key": key, "text": _plain(m.get("content"))})
|
||||
opts.sort(key=lambda o: o["key"])
|
||||
if not opts and qtype == "03":
|
||||
opts = [dict(o) for o in JUDGE_OPTIONS]
|
||||
|
||||
picked = (
|
||||
_idx_letters(it.get("replay"), opts) if qtype in ("01", "02", "03") else _answer_text(it.get("replay"))
|
||||
)
|
||||
correct, is_correct = "", None
|
||||
if show_answers:
|
||||
if qtype in ("01", "02", "03"):
|
||||
correct = _idx_letters(it.get("answer"), opts)
|
||||
if correct:
|
||||
is_correct = (set(picked.split("#")) - {""}) == (set(correct.split("#")) - {""})
|
||||
else:
|
||||
correct = _answer_text(it.get("answer"))
|
||||
try:
|
||||
is_correct = float(it.get("answerScore") or 0) > 0
|
||||
except Exception:
|
||||
is_correct = None
|
||||
try:
|
||||
ascore = float(it.get("answerScore") or 0)
|
||||
except Exception:
|
||||
ascore = None
|
||||
out.append(
|
||||
{
|
||||
"no": str(seq),
|
||||
"type": qtype,
|
||||
"title": _plain(it.get("stem"))[:600],
|
||||
"options": opts,
|
||||
"item_id": it.get("itemResultId"),
|
||||
"picked": picked,
|
||||
"correct": correct,
|
||||
"is_correct": is_correct,
|
||||
"answer_score": ascore,
|
||||
"analysis": (_plain(it.get("analysis"))[:600] if show_answers else ""),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def parse_quiz_html(html):
|
||||
"""解析 jspx 的 showTestQums 页面:div.job-item[questype][quesno][traineeid][qumid]。"""
|
||||
soup = BeautifulSoup(html or "", "lxml")
|
||||
out = []
|
||||
for it in soup.select("div.job-item"):
|
||||
opts = []
|
||||
for sp in it.select(".job-item-a span"):
|
||||
key = (sp.get("data-name") or "").strip()
|
||||
txt = re.sub(r"\s+", " ", sp.get_text(" ", strip=True))
|
||||
if key:
|
||||
opts.append({"key": key.upper(), "text": txt})
|
||||
stem = ""
|
||||
node = (
|
||||
it.select_one(".job-item-t")
|
||||
or it.select_one(".job-item-title")
|
||||
or it.select_one("p.title")
|
||||
or it.select_one("div.title")
|
||||
)
|
||||
if node:
|
||||
stem = re.sub(r"\s+", " ", node.get_text(" ", strip=True))
|
||||
else:
|
||||
full = re.sub(r"\s+", " ", it.get_text(" ", strip=True))
|
||||
for o in opts:
|
||||
if o["text"]:
|
||||
full = full.replace(o["text"], "")
|
||||
stem = re.sub(r"\s+", " ", full).strip(" .、0123456789")
|
||||
# 判断题在页面上通常只有 A/B 两个 radio、没有选项文字,
|
||||
# 照样补出「正确/错误」,免得只看到一个字母不知道对错
|
||||
qtype = (it.get("questype") or "01").strip()
|
||||
if not opts and qtype == "03":
|
||||
opts = [dict(o) for o in JUDGE_OPTIONS]
|
||||
out.append(
|
||||
{
|
||||
"no": (it.get("quesno") or "").strip(),
|
||||
"type": qtype,
|
||||
"title": stem[:600],
|
||||
"options": opts,
|
||||
"traineeid": it.get("traineeid"),
|
||||
"ques_id": it.get("qumid"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_quiz_page(s, plan, obj):
|
||||
url = "%s/train/trainee/showTestQums.action?planId=%s&objectId=%s" % (JSPX, plan, obj)
|
||||
r = s.get(url, timeout=40)
|
||||
html = r.text or ""
|
||||
if "/admin/login.action" in html and len(html) < 2000:
|
||||
raise SessionExpired("登录已失效,请重新登录后再取题")
|
||||
return html
|
||||
|
||||
|
||||
@app.post("/api/quiz")
|
||||
def api_quiz(b: QuizIn):
|
||||
"""取一次测验的题目(含选项),供页面表格展示。"""
|
||||
if not b.cookies:
|
||||
return {"ok": False, "code": "no_cookie", "error": "尚未登录"}
|
||||
pf = registry.get(b.platform)
|
||||
if pf is None or pf.discover is None:
|
||||
return {"ok": False, "error": "平台「%s」还没有实现课程发现" % b.platform}
|
||||
if pf.quiz is None:
|
||||
return {"ok": False, "error": "平台「%s」没有测验能力" % b.platform}
|
||||
s = (pf.session or new_session)(b.cookies)
|
||||
try:
|
||||
d = pf.discover(s, b.plan_id, b.project_id, b.name)
|
||||
except SessionExpired as e:
|
||||
return {"ok": False, "code": "session_expired", "error": str(e)[:300]}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)[:300]}
|
||||
return pf.quiz(s, d, b)
|
||||
|
||||
|
||||
def fetch_quiz(s, d, b):
|
||||
"""莞易学的取题(平台 quiz 能力的实现):新版走接口,旧版解析 showTestQums 页面。"""
|
||||
tests = d.get("test") or []
|
||||
if not tests:
|
||||
return {"ok": False, "error": "这门课没有测验"}
|
||||
if d.get("generation") == "spa":
|
||||
return _quiz_study(s, d, tests, retake=b.retake, period_id=b.period_id)
|
||||
|
||||
t = next((x for x in tests if not x.get("done")), tests[0])
|
||||
url = t["href"] if t["href"].startswith("http") else JSPX + t["href"]
|
||||
plan, _ = parse_ids(url)
|
||||
m = re.search(r"objectId=([0-9A-Fa-f]+)", url)
|
||||
obj = m.group(1) if m else (b.project_id or "")
|
||||
try:
|
||||
html = fetch_quiz_page(s, plan, obj)
|
||||
except SessionExpired as e:
|
||||
return {"ok": False, "code": "session_expired", "error": str(e)[:300]}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "取题失败:%s" % str(e)[:200]}
|
||||
qs = parse_quiz_html(html)
|
||||
if not qs:
|
||||
return {"ok": False, "error": "没解析到题目(页面 %d 字节),站点可能改版" % len(html)}
|
||||
return {
|
||||
"ok": True,
|
||||
"platform": "guanyixue",
|
||||
"generation": "legacy",
|
||||
"plan_id": plan,
|
||||
"object_id": obj,
|
||||
"title": t.get("title") or "测验",
|
||||
"done": bool(t.get("done")),
|
||||
"questions": qs,
|
||||
}
|
||||
|
||||
|
||||
def _quiz_study(s, d, tests, retake=False, period_id=""):
|
||||
"""新版平台取题 / 重考 / 交卷后看答案。
|
||||
|
||||
取题:GET /train/questions/front/testpaper/getById?testpaperResultId=…
|
||||
重考:PUT /train/questions/front/testpaper/start/do
|
||||
{"periodId": <课时 id>, "trainCourseId": <trainCourseId>}
|
||||
-> 新答卷 testpaperResultId(平台有 allowExamCount/surplusExamCount 次数限制)
|
||||
看答案:GET /train/questions/front/testpaper/teacher/review?testpaperResultId=…
|
||||
返回 answer(标准答案下标/文本)、replay(学生答案)、answerScore、analysis。
|
||||
是否展示由测验的 examAfterWatchAnswer 决定,不是所有测验都给看。
|
||||
|
||||
这些和页面上「开始考试 / 查看试卷」发的是同一条请求,所以纯 API 也能完成。
|
||||
"""
|
||||
|
||||
def pick_test():
|
||||
if period_id:
|
||||
for x in tests:
|
||||
if str(x.get("period_id") or "") == str(period_id):
|
||||
return x
|
||||
return (
|
||||
next((x for x in tests if not x.get("done") and x.get("testpaper_result_id")), None)
|
||||
or next((x for x in tests if not x.get("done")), None)
|
||||
or next((x for x in tests if x.get("testpaper_result_id")), None)
|
||||
or (tests[0] if tests else None)
|
||||
)
|
||||
|
||||
t = pick_test()
|
||||
if not t:
|
||||
return {"ok": False, "code": "no_test", "error": "这门课没有测验"}
|
||||
|
||||
client = StudyClient(s, d.get("page_url") or (STUDY + "/training/curriculum"))
|
||||
allow = t.get("allow_exam_count")
|
||||
left = t.get("surplus_exam_count")
|
||||
show_answers = str(t.get("exam_after_watch_answer") or "") == "1"
|
||||
started = False
|
||||
|
||||
def start_paper(pid, err_prefix):
|
||||
try:
|
||||
j0 = client.call(
|
||||
"PUT",
|
||||
"/questions/front/testpaper/start/do",
|
||||
json_body={"periodId": int(pid), "trainCourseId": d.get("train_id") or ""},
|
||||
)
|
||||
except Exception as e:
|
||||
return None, "%s:%s" % (err_prefix, str(e)[:200])
|
||||
if str(j0.get("code")) != "200":
|
||||
return None, "%s:%s" % (err_prefix, str(j0.get("msg") or j0.get("message") or j0)[:200])
|
||||
data0 = j0.get("data") or {}
|
||||
new_rid = str(data0.get("testpaperResultId") or data0.get("resultId") or "")
|
||||
if not new_rid:
|
||||
return None, "%s:平台没有返回 testpaperResultId" % err_prefix
|
||||
return new_rid, ""
|
||||
|
||||
# ---- 重考:已交卷且还有剩余次数,start/do 会生成一份新答卷 ----
|
||||
if retake:
|
||||
if not t.get("done") and t.get("testpaper_result_id"):
|
||||
return {"ok": False, "code": "doing", "error": "还有一份未交卷的答卷,先交卷再重考"}
|
||||
if not (isinstance(left, int) and left > 0):
|
||||
used = (allow - left) if isinstance(allow, int) and isinstance(left, int) else "?"
|
||||
return {
|
||||
"ok": False,
|
||||
"code": "no_attempts",
|
||||
"error": "考试次数已用完(已用 %s/%s 次),不能再考" % (used, allow),
|
||||
}
|
||||
pid = str(t.get("period_id") or "").strip()
|
||||
if not pid:
|
||||
return {"ok": False, "code": "no_period", "error": "这个测验没有课时 ID,无法重考"}
|
||||
new_rid, err = start_paper(pid, "重考失败")
|
||||
if err:
|
||||
return {"ok": False, "error": err}
|
||||
old_rid = str(t.get("testpaper_result_id") or "")
|
||||
if new_rid == old_rid:
|
||||
return {"ok": False, "code": "no_attempts", "error": "平台没有生成新答卷(可能次数已用完或需要老师重置)"}
|
||||
started = True
|
||||
t = dict(t)
|
||||
t.update({"testpaper_result_id": new_rid, "done": False, "result_status": "DOING"})
|
||||
if isinstance(left, int):
|
||||
left = max(0, left - 1) # 先本地减 1;交卷后重新 discover 会校准
|
||||
rid = new_rid
|
||||
else:
|
||||
rid = str(t.get("testpaper_result_id") or "")
|
||||
|
||||
# ---- 第一次取题:还没有答卷就自动创建 ----
|
||||
if not rid:
|
||||
if t.get("done"):
|
||||
return {"ok": False, "code": "no_result", "error": "这个测验已完成,但平台没有返回答卷 ID,无法再看题目"}
|
||||
pid = str(t.get("period_id") or "").strip()
|
||||
if not pid:
|
||||
return {"ok": False, "code": "no_period", "error": "这个测验没有课时 ID,无法自动开始答卷"}
|
||||
rid, err = start_paper(pid, "自动开始答卷失败")
|
||||
if err:
|
||||
return {"ok": False, "error": err}
|
||||
started = True
|
||||
|
||||
# ---- 取当前答卷;已交卷的再调 review 拿标准答案 ----
|
||||
paper = None
|
||||
try:
|
||||
j = client.call("GET", "/questions/front/testpaper/getById", params={"testpaperResultId": rid})
|
||||
if str(j.get("code")) == "200":
|
||||
paper = j.get("data") or {}
|
||||
except Exception:
|
||||
paper = None
|
||||
|
||||
res = (paper or {}).get("result") or {}
|
||||
status = str(res.get("status") or t.get("result_status") or "").upper()
|
||||
done = status in ("FINISHED", "REVIEWED", "REVIEWING") or bool(t.get("done"))
|
||||
qs, review = [], False
|
||||
if done:
|
||||
try:
|
||||
jr = client.call("GET", "/questions/front/testpaper/teacher/review", params={"testpaperResultId": rid})
|
||||
if str(jr.get("code")) == "200":
|
||||
rdata = jr.get("data") or {}
|
||||
qs = study_parse_review(rdata, show_answers=show_answers)
|
||||
review = bool(qs)
|
||||
res = rdata.get("result") or res
|
||||
paper = rdata
|
||||
status = str(res.get("status") or status).upper()
|
||||
except Exception:
|
||||
pass
|
||||
if not qs and paper:
|
||||
qs = study_parse_paper(paper)
|
||||
if not qs:
|
||||
return {"ok": False, "error": "接口返回成功,但没有解析到题目"}
|
||||
|
||||
score = res.get("answerSumScore")
|
||||
if score is None:
|
||||
score = (paper or {}).get("score") if (paper or {}).get("score") is not None else t.get("score")
|
||||
passed_score = (paper or {}).get("passedScore")
|
||||
if passed_score is None:
|
||||
passed_score = t.get("passed_score")
|
||||
return {
|
||||
"ok": True,
|
||||
"platform": "guanyixue",
|
||||
"generation": "spa",
|
||||
"result_id": rid,
|
||||
"period_id": t.get("period_id"),
|
||||
"train_id": d.get("train_id") or "",
|
||||
"started": started, # True = 本次请求创建了新答卷
|
||||
"title": t.get("title") or (paper or {}).get("name") or "测验",
|
||||
"questions": qs,
|
||||
"done": done,
|
||||
"review": review, # True = 已交卷,题目里带标准答案/解析
|
||||
"show_answers": show_answers,
|
||||
"allow_exam_count": allow,
|
||||
"surplus_exam_count": left,
|
||||
"can_retake": bool(done and isinstance(left, int) and left > 0),
|
||||
"exam_after_watch_answer": t.get("exam_after_watch_answer") or "",
|
||||
"result_status": status,
|
||||
"score": score,
|
||||
"passed_score": passed_score,
|
||||
"passed": str(res.get("passedStatus") or "") == "1",
|
||||
"submit_time": res.get("submitTime"),
|
||||
}
|
||||
|
||||
|
||||
def _quiz_study_submit(b):
|
||||
"""新版平台交卷:PUT /train/questions/front/result/submitExam
|
||||
|
||||
{"resultId": "<testpaperResultId>",
|
||||
"resultItemList": [{"fileIds": [], "itemId": "<itemResultId>", "replay": [0,2] | null}]}
|
||||
|
||||
replay 是**0 基选项下标**数组,字母答案要先按选项顺序换成下标。
|
||||
"""
|
||||
if not b.result_id:
|
||||
return {"ok": False, "error": "缺少 resultId,请重新点一次「测验」"}
|
||||
s = new_session(b.cookies)
|
||||
client = StudyClient(s, STUDY + "/training/curriculum")
|
||||
try:
|
||||
j = client.call("GET", "/questions/front/testpaper/getById", params={"testpaperResultId": b.result_id})
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "交卷前取题失败:%s" % str(e)[:200]}
|
||||
if str(j.get("code")) != "200":
|
||||
return {"ok": False, "error": "交卷前取题失败:%s" % str(j.get("msg") or j)[:200]}
|
||||
qs = study_parse_paper(j.get("data") or {})
|
||||
if not qs:
|
||||
return {"ok": False, "error": "交卷前取题失败:没有题目"}
|
||||
res0 = (j.get("data") or {}).get("result") or {}
|
||||
st0 = str(res0.get("status") or "").upper()
|
||||
if st0 in ("FINISHED", "REVIEWED", "REVIEWING"):
|
||||
sc = res0.get("answerSumScore")
|
||||
return {
|
||||
"ok": False,
|
||||
"code": "already_finished",
|
||||
"error": "这份答卷已经交过卷%s;点「再考一次」可以生成新答卷再提交"
|
||||
% ("(得分 %s)" % sc if sc is not None else ""),
|
||||
}
|
||||
|
||||
items, answered = [], 0
|
||||
for q in qs:
|
||||
keys = [o["key"] for o in q["options"]]
|
||||
letters = answer_tokens((b.answers or {}).get(q["no"]), q["options"], q["type"])
|
||||
idx = [keys.index(x) for x in letters if x in keys]
|
||||
if idx:
|
||||
answered += 1
|
||||
items.append({"fileIds": [], "itemId": q["item_id"], "replay": idx or None})
|
||||
|
||||
body = {"resultId": b.result_id, "resultItemList": items}
|
||||
try:
|
||||
r = client.call("PUT", "/questions/front/result/submitExam", json_body=body)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "交卷失败:%s" % str(e)[:200]}
|
||||
if str(r.get("code")) != "200":
|
||||
return {"ok": False, "error": "交卷失败:%s" % str(r.get("msg") or r)[:200]}
|
||||
|
||||
score = None
|
||||
try:
|
||||
j2 = client.call("GET", "/questions/front/testpaper/getById", params={"testpaperResultId": b.result_id})
|
||||
res = (j2.get("data") or {}).get("result") or {}
|
||||
score = res.get("answerSumScore")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "answers": answered, "total": len(items), "score": score, "msg": "交卷成功"}
|
||||
|
||||
|
||||
@app.post("/api/quiz/submit")
|
||||
def api_quiz_submit(b: QuizSubmitIn):
|
||||
"""按平台分发交卷:每个平台自己决定怎么把答案交给站点。"""
|
||||
if not b.cookies:
|
||||
return {"ok": False, "error": "尚未登录"}
|
||||
pf = registry.get(b.platform)
|
||||
if pf is None or pf.quiz_submit is None:
|
||||
return {"ok": False, "error": "平台「%s」没有交卷能力" % b.platform}
|
||||
s = (pf.session or new_session)(b.cookies)
|
||||
try:
|
||||
return pf.quiz_submit(s, b)
|
||||
except SessionExpired as e:
|
||||
return {"ok": False, "code": "session_expired", "error": str(e)[:300]}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)[:200]}
|
||||
|
||||
|
||||
def submit_quiz(s, b):
|
||||
"""莞易学的交卷(平台 quiz_submit 的实现):重新取一次题,保证 traineeid 是当前会话的。"""
|
||||
if b.generation == "spa":
|
||||
return _quiz_study_submit(b)
|
||||
try:
|
||||
html = fetch_quiz_page(s, b.plan_id, b.object_id)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "重新取题失败:%s" % str(e)[:200]}
|
||||
qs = parse_quiz_html(html)
|
||||
if not qs:
|
||||
return {"ok": False, "error": "重新取题失败,无法提交"}
|
||||
|
||||
answers = []
|
||||
for it in qs:
|
||||
ans = str((b.answers or {}).get(it["no"]) or "").strip().upper()
|
||||
if not ans:
|
||||
ans = it["options"][0]["key"] if it["options"] else "A"
|
||||
answers.append(
|
||||
{"id": it["traineeid"], "quesId": it["ques_id"], "quesType": it["type"], "quesNo": it["no"], "answer": ans}
|
||||
)
|
||||
payload = {
|
||||
"planId": b.plan_id,
|
||||
"objectId": b.object_id,
|
||||
"answers": json.dumps(answers, ensure_ascii=False, separators=(",", ":")),
|
||||
}
|
||||
r3 = s.post(JSPX + "/train/trainee/endTest.action", data=payload, timeout=40)
|
||||
try:
|
||||
r4 = s.get("%s/train/trainee/doTest.action?planId=%s&objectId=%s" % (JSPX, b.plan_id, b.object_id), timeout=40)
|
||||
text = re.sub(r"\s+", " ", BeautifulSoup(r4.text, "lxml").get_text(" ", strip=True))
|
||||
m = re.search(r"得分:\s*(\d+)\s*分", text)
|
||||
score = m.group(1) if m else None
|
||||
except Exception:
|
||||
score = None
|
||||
return {
|
||||
"ok": True,
|
||||
"answers": len(answers),
|
||||
"score": score,
|
||||
"msg": "提交成功" if "MSG" not in (r3.text or "") else (r3.text or "")[:80],
|
||||
}
|
||||
|
||||
|
||||
_ANS_PAIR = re.compile(
|
||||
r"""(\d+)\s*(?:题)?\s*["']?\s*[::=、..]\s*["']?\s*([A-F](?:\s*[#、,/,\s]\s*[A-F])*|正确|错误|对|错|√|×)""",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def parse_llm_answers(content, questions):
|
||||
"""优先按 JSON 解析;模型没给 JSON(或被截断)时从「1.A / 第3题:B」里抠。"""
|
||||
|
||||
def _pick(raw):
|
||||
out = {}
|
||||
for q in questions:
|
||||
no = str(q.get("no") or "")
|
||||
v = raw.get(no, raw.get(no.strip()))
|
||||
if isinstance(v, list):
|
||||
v = "#".join(str(x) for x in v)
|
||||
parts = answer_tokens(v, q.get("options"), str(q.get("type") or "01"))
|
||||
if parts:
|
||||
out[no] = "#".join(parts)
|
||||
return out
|
||||
|
||||
txt = re.sub(r"```(?:json)?|```", "", (content or "").strip()).strip()
|
||||
m = re.search(r"\{.*\}", txt, re.DOTALL)
|
||||
if m:
|
||||
try:
|
||||
data = json.loads(m.group(0))
|
||||
except Exception:
|
||||
data = None
|
||||
if isinstance(data, dict):
|
||||
raw = data.get("answers") if isinstance(data.get("answers"), dict) else data
|
||||
if isinstance(raw, dict):
|
||||
out = _pick(raw)
|
||||
if out:
|
||||
return out
|
||||
return _pick({no: v for no, v in _ANS_PAIR.findall(txt)})
|
||||
|
||||
|
||||
QUIZ_BATCH = 5
|
||||
# 这些错误重试也没用(鉴权/路径/参数问题),其它(超时、5xx、空内容)值得再问一次
|
||||
_RETRY_HINT = re.compile(r"timed out|timeout|超时|429|5\d\d|空内容|不是 JSON", re.IGNORECASE)
|
||||
|
||||
|
||||
def _quiz_lines(part):
|
||||
lines = []
|
||||
for q in part:
|
||||
opts = ";".join("%s. %s" % (o.get("key"), o.get("text")) for o in (q.get("options") or []))
|
||||
lines.append(
|
||||
"第%s题(%s)%s\n选项:%s"
|
||||
% (q.get("no"), TYPE_LABEL.get(str(q.get("type")), "单选"), q.get("title") or "", opts or "无")
|
||||
)
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _quiz_log(key, msg):
|
||||
"""AI 答题日志:统一走 runtime.log(控制台 + 内存 + SQLite)。"""
|
||||
log(key or "system", "AI答题 " + msg)
|
||||
|
||||
|
||||
def _quiz_ask(system, part, cfg, strict=False):
|
||||
"""问一批题,返回 (答案, 模型原文)。
|
||||
|
||||
strict:连 response_format 一起关掉,改成口头要求(有的网关不认这个字段)。
|
||||
"""
|
||||
if strict:
|
||||
system += '\n只输出一个 JSON 对象,形如 {"answers":{"1":"A"}};不要解释,不要 markdown。'
|
||||
content = llm_chat(
|
||||
[{"role": "system", "content": system}, {"role": "user", "content": _quiz_lines(part)}],
|
||||
cfg,
|
||||
max_tokens=1600,
|
||||
json_mode=not strict,
|
||||
)
|
||||
return parse_llm_answers(content, part), content
|
||||
|
||||
|
||||
def _sse(obj):
|
||||
return "data: " + json.dumps(obj, ensure_ascii=False) + "\n\n"
|
||||
|
||||
|
||||
def _quiz_stream(system, questions, cfg, key=""):
|
||||
total = len(questions)
|
||||
batches = (total + QUIZ_BATCH - 1) // QUIZ_BATCH
|
||||
_quiz_log(
|
||||
key,
|
||||
"开始:%d 题 / %d 批,模型 %s(%s),超时 %ss"
|
||||
% (
|
||||
total,
|
||||
batches,
|
||||
cfg.get("model") or "?",
|
||||
urlsplit(cfg.get("base_url") or "").hostname or "?",
|
||||
cfg.get("timeout") or 120,
|
||||
),
|
||||
)
|
||||
t0, filled, failed = time.time(), 0, 0
|
||||
for bi, i in enumerate(range(0, total, QUIZ_BATCH), 1):
|
||||
part = questions[i : i + QUIZ_BATCH]
|
||||
answers, err = {}, ""
|
||||
for strict in (False, True):
|
||||
tag = "第 %d/%d 批[%s]%s" % (
|
||||
bi,
|
||||
batches,
|
||||
",".join(str(q.get("no")) for q in part),
|
||||
" 补问" if strict else "",
|
||||
)
|
||||
t1 = time.time()
|
||||
try:
|
||||
got, content = _quiz_ask(system, part, cfg, strict)
|
||||
except Exception as e:
|
||||
err = str(e)[:160]
|
||||
_quiz_log(key, tag + " 失败 %.1fs:%s" % (time.time() - t1, err))
|
||||
if not _RETRY_HINT.search(err):
|
||||
break
|
||||
continue
|
||||
answers.update(got)
|
||||
part = [q for q in part if str(q.get("no")) not in got] # 第二轮回只问没答出来的
|
||||
if not part:
|
||||
_quiz_log(key, tag + " 成功 %.1fs:%d 题" % (time.time() - t1, len(answers)))
|
||||
err = ""
|
||||
break
|
||||
_quiz_log(
|
||||
key,
|
||||
tag
|
||||
+ " 本批只答出 %d 题 %.1fs,模型原文:%s"
|
||||
% (len(answers), time.time() - t1, content[:300].replace("\n", " ")),
|
||||
)
|
||||
err = "还有 %d 题没答出来" % len(part)
|
||||
filled += len(answers)
|
||||
if not answers:
|
||||
failed += 1
|
||||
yield _sse({"answers": answers, "error": "" if answers else err})
|
||||
_quiz_log(key, "结束:已填 %d/%d,失败 %d 批,耗时 %.1fs" % (filled, total, failed, time.time() - t0))
|
||||
yield _sse({"done": True, "total": total, "failed": failed})
|
||||
|
||||
|
||||
def _quiz_err(msg, total=0):
|
||||
return StreamingResponse(
|
||||
iter([_sse({"done": True, "total": total, "failed": 0, "error": msg})]),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/quiz/answer")
|
||||
def api_quiz_answer(b: QuizAnswerIn):
|
||||
"""分批问模型,边算边把结果推回去(text/event-stream,每批一行)。
|
||||
|
||||
整段同步返回时,20 题要串行等好几分钟,浏览器/反代先断,前面算好的答案也拿不到;
|
||||
流式之后连接一直有数据,单批失败只影响这一批 5 题。
|
||||
"""
|
||||
cfg = load_settings()["llm"]
|
||||
key = b.account_id or ""
|
||||
if not cfg.get("api_key") or not cfg.get("base_url"):
|
||||
msg = "还没配置大模型:右上角「设置」→ 填 base_url / api_key / model"
|
||||
_quiz_log(key, msg)
|
||||
return _quiz_err(msg, len(b.questions))
|
||||
if not b.questions:
|
||||
_quiz_log(key, "没有题目")
|
||||
return _quiz_err("没有题目")
|
||||
system = b.prompt or cfg.get("prompt") or DEFAULT_ANSWER_PROMPT
|
||||
return StreamingResponse(
|
||||
_quiz_stream(system, b.questions, cfg, key),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
@@ -0,0 +1,867 @@
|
||||
/**
|
||||
* DES加密解密
|
||||
* @Copyright Copyright (c) 2006
|
||||
* @author Guapo
|
||||
* @see DESCore
|
||||
*/
|
||||
|
||||
/*
|
||||
* encrypt the string to string made up of hex
|
||||
* return the encrypted string
|
||||
*/
|
||||
function strEnc(data,firstKey,secondKey,thirdKey){
|
||||
|
||||
var leng = data.length;
|
||||
var encData = "";
|
||||
var firstKeyBt,secondKeyBt,thirdKeyBt,firstLength,secondLength,thirdLength;
|
||||
if(firstKey != null && firstKey != ""){
|
||||
firstKeyBt = getKeyBytes(firstKey);
|
||||
firstLength = firstKeyBt.length;
|
||||
}
|
||||
if(secondKey != null && secondKey != ""){
|
||||
secondKeyBt = getKeyBytes(secondKey);
|
||||
secondLength = secondKeyBt.length;
|
||||
}
|
||||
if(thirdKey != null && thirdKey != ""){
|
||||
thirdKeyBt = getKeyBytes(thirdKey);
|
||||
thirdLength = thirdKeyBt.length;
|
||||
}
|
||||
|
||||
if(leng > 0){
|
||||
if(leng < 4){
|
||||
var bt = strToBt(data);
|
||||
var encByte ;
|
||||
if(firstKey != null && firstKey !="" && secondKey != null && secondKey != "" && thirdKey != null && thirdKey != ""){
|
||||
var tempBt;
|
||||
var x,y,z;
|
||||
tempBt = bt;
|
||||
for(x = 0;x < firstLength ;x ++){
|
||||
tempBt = enc(tempBt,firstKeyBt[x]);
|
||||
}
|
||||
for(y = 0;y < secondLength ;y ++){
|
||||
tempBt = enc(tempBt,secondKeyBt[y]);
|
||||
}
|
||||
for(z = 0;z < thirdLength ;z ++){
|
||||
tempBt = enc(tempBt,thirdKeyBt[z]);
|
||||
}
|
||||
encByte = tempBt;
|
||||
}else{
|
||||
if(firstKey != null && firstKey !="" && secondKey != null && secondKey != ""){
|
||||
var tempBt;
|
||||
var x,y;
|
||||
tempBt = bt;
|
||||
for(x = 0;x < firstLength ;x ++){
|
||||
tempBt = enc(tempBt,firstKeyBt[x]);
|
||||
}
|
||||
for(y = 0;y < secondLength ;y ++){
|
||||
tempBt = enc(tempBt,secondKeyBt[y]);
|
||||
}
|
||||
encByte = tempBt;
|
||||
}else{
|
||||
if(firstKey != null && firstKey !=""){
|
||||
var tempBt;
|
||||
var x = 0;
|
||||
tempBt = bt;
|
||||
for(x = 0;x < firstLength ;x ++){
|
||||
tempBt = enc(tempBt,firstKeyBt[x]);
|
||||
}
|
||||
encByte = tempBt;
|
||||
}
|
||||
}
|
||||
}
|
||||
encData = bt64ToHex(encByte);
|
||||
}else{
|
||||
var iterator = parseInt(leng/4);
|
||||
var remainder = leng%4;
|
||||
var i=0;
|
||||
for(i = 0;i < iterator;i++){
|
||||
var tempData = data.substring(i*4+0,i*4+4);
|
||||
var tempByte = strToBt(tempData);
|
||||
var encByte ;
|
||||
if(firstKey != null && firstKey !="" && secondKey != null && secondKey != "" && thirdKey != null && thirdKey != ""){
|
||||
var tempBt;
|
||||
var x,y,z;
|
||||
tempBt = tempByte;
|
||||
for(x = 0;x < firstLength ;x ++){
|
||||
tempBt = enc(tempBt,firstKeyBt[x]);
|
||||
}
|
||||
for(y = 0;y < secondLength ;y ++){
|
||||
tempBt = enc(tempBt,secondKeyBt[y]);
|
||||
}
|
||||
for(z = 0;z < thirdLength ;z ++){
|
||||
tempBt = enc(tempBt,thirdKeyBt[z]);
|
||||
}
|
||||
encByte = tempBt;
|
||||
}else{
|
||||
if(firstKey != null && firstKey !="" && secondKey != null && secondKey != ""){
|
||||
var tempBt;
|
||||
var x,y;
|
||||
tempBt = tempByte;
|
||||
for(x = 0;x < firstLength ;x ++){
|
||||
tempBt = enc(tempBt,firstKeyBt[x]);
|
||||
}
|
||||
for(y = 0;y < secondLength ;y ++){
|
||||
tempBt = enc(tempBt,secondKeyBt[y]);
|
||||
}
|
||||
encByte = tempBt;
|
||||
}else{
|
||||
if(firstKey != null && firstKey !=""){
|
||||
var tempBt;
|
||||
var x;
|
||||
tempBt = tempByte;
|
||||
for(x = 0;x < firstLength ;x ++){
|
||||
tempBt = enc(tempBt,firstKeyBt[x]);
|
||||
}
|
||||
encByte = tempBt;
|
||||
}
|
||||
}
|
||||
}
|
||||
encData += bt64ToHex(encByte);
|
||||
}
|
||||
if(remainder > 0){
|
||||
var remainderData = data.substring(iterator*4+0,leng);
|
||||
var tempByte = strToBt(remainderData);
|
||||
var encByte ;
|
||||
if(firstKey != null && firstKey !="" && secondKey != null && secondKey != "" && thirdKey != null && thirdKey != ""){
|
||||
var tempBt;
|
||||
var x,y,z;
|
||||
tempBt = tempByte;
|
||||
for(x = 0;x < firstLength ;x ++){
|
||||
tempBt = enc(tempBt,firstKeyBt[x]);
|
||||
}
|
||||
for(y = 0;y < secondLength ;y ++){
|
||||
tempBt = enc(tempBt,secondKeyBt[y]);
|
||||
}
|
||||
for(z = 0;z < thirdLength ;z ++){
|
||||
tempBt = enc(tempBt,thirdKeyBt[z]);
|
||||
}
|
||||
encByte = tempBt;
|
||||
}else{
|
||||
if(firstKey != null && firstKey !="" && secondKey != null && secondKey != ""){
|
||||
var tempBt;
|
||||
var x,y;
|
||||
tempBt = tempByte;
|
||||
for(x = 0;x < firstLength ;x ++){
|
||||
tempBt = enc(tempBt,firstKeyBt[x]);
|
||||
}
|
||||
for(y = 0;y < secondLength ;y ++){
|
||||
tempBt = enc(tempBt,secondKeyBt[y]);
|
||||
}
|
||||
encByte = tempBt;
|
||||
}else{
|
||||
if(firstKey != null && firstKey !=""){
|
||||
var tempBt;
|
||||
var x;
|
||||
tempBt = tempByte;
|
||||
for(x = 0;x < firstLength ;x ++){
|
||||
tempBt = enc(tempBt,firstKeyBt[x]);
|
||||
}
|
||||
encByte = tempBt;
|
||||
}
|
||||
}
|
||||
}
|
||||
encData += bt64ToHex(encByte);
|
||||
}
|
||||
}
|
||||
}
|
||||
return encData;
|
||||
}
|
||||
|
||||
/*
|
||||
* decrypt the encrypted string to the original string
|
||||
*
|
||||
* return the original string
|
||||
*/
|
||||
function strDec(data,firstKey,secondKey,thirdKey){
|
||||
var leng = data.length;
|
||||
var decStr = "";
|
||||
var firstKeyBt,secondKeyBt,thirdKeyBt,firstLength,secondLength,thirdLength;
|
||||
if(firstKey != null && firstKey != ""){
|
||||
firstKeyBt = getKeyBytes(firstKey);
|
||||
firstLength = firstKeyBt.length;
|
||||
}
|
||||
if(secondKey != null && secondKey != ""){
|
||||
secondKeyBt = getKeyBytes(secondKey);
|
||||
secondLength = secondKeyBt.length;
|
||||
}
|
||||
if(thirdKey != null && thirdKey != ""){
|
||||
thirdKeyBt = getKeyBytes(thirdKey);
|
||||
thirdLength = thirdKeyBt.length;
|
||||
}
|
||||
|
||||
var iterator = parseInt(leng/16);
|
||||
var i=0;
|
||||
for(i = 0;i < iterator;i++){
|
||||
var tempData = data.substring(i*16+0,i*16+16);
|
||||
var strByte = hexToBt64(tempData);
|
||||
var intByte = new Array(64);
|
||||
var j = 0;
|
||||
for(j = 0;j < 64; j++){
|
||||
intByte[j] = parseInt(strByte.substring(j,j+1));
|
||||
}
|
||||
var decByte;
|
||||
if(firstKey != null && firstKey !="" && secondKey != null && secondKey != "" && thirdKey != null && thirdKey != ""){
|
||||
var tempBt;
|
||||
var x,y,z;
|
||||
tempBt = intByte;
|
||||
for(x = thirdLength - 1;x >= 0;x --){
|
||||
tempBt = dec(tempBt,thirdKeyBt[x]);
|
||||
}
|
||||
for(y = secondLength - 1;y >= 0;y --){
|
||||
tempBt = dec(tempBt,secondKeyBt[y]);
|
||||
}
|
||||
for(z = firstLength - 1;z >= 0 ;z --){
|
||||
tempBt = dec(tempBt,firstKeyBt[z]);
|
||||
}
|
||||
decByte = tempBt;
|
||||
}else{
|
||||
if(firstKey != null && firstKey !="" && secondKey != null && secondKey != ""){
|
||||
var tempBt;
|
||||
var x,y,z;
|
||||
tempBt = intByte;
|
||||
for(x = secondLength - 1;x >= 0 ;x --){
|
||||
tempBt = dec(tempBt,secondKeyBt[x]);
|
||||
}
|
||||
for(y = firstLength - 1;y >= 0 ;y --){
|
||||
tempBt = dec(tempBt,firstKeyBt[y]);
|
||||
}
|
||||
decByte = tempBt;
|
||||
}else{
|
||||
if(firstKey != null && firstKey !=""){
|
||||
var tempBt;
|
||||
var x,y,z;
|
||||
tempBt = intByte;
|
||||
for(x = firstLength - 1;x >= 0 ;x --){
|
||||
tempBt = dec(tempBt,firstKeyBt[x]);
|
||||
}
|
||||
decByte = tempBt;
|
||||
}
|
||||
}
|
||||
}
|
||||
decStr += byteToString(decByte);
|
||||
}
|
||||
return decStr;
|
||||
}
|
||||
/*
|
||||
* chang the string into the bit array
|
||||
*
|
||||
* return bit array(it's length % 64 = 0)
|
||||
*/
|
||||
function getKeyBytes(key){
|
||||
var keyBytes = new Array();
|
||||
var leng = key.length;
|
||||
var iterator = parseInt(leng/4);
|
||||
var remainder = leng%4;
|
||||
var i = 0;
|
||||
for(i = 0;i < iterator; i ++){
|
||||
keyBytes[i] = strToBt(key.substring(i*4+0,i*4+4));
|
||||
}
|
||||
if(remainder > 0){
|
||||
keyBytes[i] = strToBt(key.substring(i*4+0,leng));
|
||||
}
|
||||
return keyBytes;
|
||||
}
|
||||
|
||||
/*
|
||||
* chang the string(it's length <= 4) into the bit array
|
||||
*
|
||||
* return bit array(it's length = 64)
|
||||
*/
|
||||
function strToBt(str){
|
||||
var leng = str.length;
|
||||
var bt = new Array(64);
|
||||
if(leng < 4){
|
||||
var i=0,j=0,p=0,q=0;
|
||||
for(i = 0;i<leng;i++){
|
||||
var k = str.charCodeAt(i);
|
||||
for(j=0;j<16;j++){
|
||||
var pow=1,m=0;
|
||||
for(m=15;m>j;m--){
|
||||
pow *= 2;
|
||||
}
|
||||
bt[16*i+j]=parseInt(k/pow)%2;
|
||||
}
|
||||
}
|
||||
for(p = leng;p<4;p++){
|
||||
var k = 0;
|
||||
for(q=0;q<16;q++){
|
||||
var pow=1,m=0;
|
||||
for(m=15;m>q;m--){
|
||||
pow *= 2;
|
||||
}
|
||||
bt[16*p+q]=parseInt(k/pow)%2;
|
||||
}
|
||||
}
|
||||
}else{
|
||||
for(i = 0;i<4;i++){
|
||||
var k = str.charCodeAt(i);
|
||||
for(j=0;j<16;j++){
|
||||
var pow=1;
|
||||
for(m=15;m>j;m--){
|
||||
pow *= 2;
|
||||
}
|
||||
bt[16*i+j]=parseInt(k/pow)%2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bt;
|
||||
}
|
||||
|
||||
/*
|
||||
* chang the bit(it's length = 4) into the hex
|
||||
*
|
||||
* return hex
|
||||
*/
|
||||
function bt4ToHex(binary) {
|
||||
var hex;
|
||||
switch (binary) {
|
||||
case "0000" : hex = "0"; break;
|
||||
case "0001" : hex = "1"; break;
|
||||
case "0010" : hex = "2"; break;
|
||||
case "0011" : hex = "3"; break;
|
||||
case "0100" : hex = "4"; break;
|
||||
case "0101" : hex = "5"; break;
|
||||
case "0110" : hex = "6"; break;
|
||||
case "0111" : hex = "7"; break;
|
||||
case "1000" : hex = "8"; break;
|
||||
case "1001" : hex = "9"; break;
|
||||
case "1010" : hex = "A"; break;
|
||||
case "1011" : hex = "B"; break;
|
||||
case "1100" : hex = "C"; break;
|
||||
case "1101" : hex = "D"; break;
|
||||
case "1110" : hex = "E"; break;
|
||||
case "1111" : hex = "F"; break;
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/*
|
||||
* chang the hex into the bit(it's length = 4)
|
||||
*
|
||||
* return the bit(it's length = 4)
|
||||
*/
|
||||
function hexToBt4(hex) {
|
||||
var binary;
|
||||
switch (hex) {
|
||||
case "0" : binary = "0000"; break;
|
||||
case "1" : binary = "0001"; break;
|
||||
case "2" : binary = "0010"; break;
|
||||
case "3" : binary = "0011"; break;
|
||||
case "4" : binary = "0100"; break;
|
||||
case "5" : binary = "0101"; break;
|
||||
case "6" : binary = "0110"; break;
|
||||
case "7" : binary = "0111"; break;
|
||||
case "8" : binary = "1000"; break;
|
||||
case "9" : binary = "1001"; break;
|
||||
case "A" : binary = "1010"; break;
|
||||
case "B" : binary = "1011"; break;
|
||||
case "C" : binary = "1100"; break;
|
||||
case "D" : binary = "1101"; break;
|
||||
case "E" : binary = "1110"; break;
|
||||
case "F" : binary = "1111"; break;
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
/*
|
||||
* chang the bit(it's length = 64) into the string
|
||||
*
|
||||
* return string
|
||||
*/
|
||||
function byteToString(byteData){
|
||||
var str="";
|
||||
for(i = 0;i<4;i++){
|
||||
var count=0;
|
||||
for(j=0;j<16;j++){
|
||||
var pow=1;
|
||||
for(m=15;m>j;m--){
|
||||
pow*=2;
|
||||
}
|
||||
count+=byteData[16*i+j]*pow;
|
||||
}
|
||||
if(count != 0){
|
||||
str+=String.fromCharCode(count);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function bt64ToHex(byteData){
|
||||
var hex = "";
|
||||
for(i = 0;i<16;i++){
|
||||
var bt = "";
|
||||
for(j=0;j<4;j++){
|
||||
bt += byteData[i*4+j];
|
||||
}
|
||||
hex+=bt4ToHex(bt);
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function hexToBt64(hex){
|
||||
var binary = "";
|
||||
for(i = 0;i<16;i++){
|
||||
binary+=hexToBt4(hex.substring(i,i+1));
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
/*
|
||||
* the 64 bit des core arithmetic
|
||||
*/
|
||||
|
||||
function enc(dataByte,keyByte){
|
||||
var keys = generateKeys(keyByte);
|
||||
var ipByte = initPermute(dataByte);
|
||||
var ipLeft = new Array(32);
|
||||
var ipRight = new Array(32);
|
||||
var tempLeft = new Array(32);
|
||||
var i = 0,j = 0,k = 0,m = 0, n = 0;
|
||||
for(k = 0;k < 32;k ++){
|
||||
ipLeft[k] = ipByte[k];
|
||||
ipRight[k] = ipByte[32+k];
|
||||
}
|
||||
for(i = 0;i < 16;i ++){
|
||||
for(j = 0;j < 32;j ++){
|
||||
tempLeft[j] = ipLeft[j];
|
||||
ipLeft[j] = ipRight[j];
|
||||
}
|
||||
var key = new Array(48);
|
||||
for(m = 0;m < 48;m ++){
|
||||
key[m] = keys[i][m];
|
||||
}
|
||||
var tempRight = xor(pPermute(sBoxPermute(xor(expandPermute(ipRight),key))), tempLeft);
|
||||
for(n = 0;n < 32;n ++){
|
||||
ipRight[n] = tempRight[n];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
var finalData =new Array(64);
|
||||
for(i = 0;i < 32;i ++){
|
||||
finalData[i] = ipRight[i];
|
||||
finalData[32+i] = ipLeft[i];
|
||||
}
|
||||
return finallyPermute(finalData);
|
||||
}
|
||||
|
||||
function dec(dataByte,keyByte){
|
||||
var keys = generateKeys(keyByte);
|
||||
var ipByte = initPermute(dataByte);
|
||||
var ipLeft = new Array(32);
|
||||
var ipRight = new Array(32);
|
||||
var tempLeft = new Array(32);
|
||||
var i = 0,j = 0,k = 0,m = 0, n = 0;
|
||||
for(k = 0;k < 32;k ++){
|
||||
ipLeft[k] = ipByte[k];
|
||||
ipRight[k] = ipByte[32+k];
|
||||
}
|
||||
for(i = 15;i >= 0;i --){
|
||||
for(j = 0;j < 32;j ++){
|
||||
tempLeft[j] = ipLeft[j];
|
||||
ipLeft[j] = ipRight[j];
|
||||
}
|
||||
var key = new Array(48);
|
||||
for(m = 0;m < 48;m ++){
|
||||
key[m] = keys[i][m];
|
||||
}
|
||||
|
||||
var tempRight = xor(pPermute(sBoxPermute(xor(expandPermute(ipRight),key))), tempLeft);
|
||||
for(n = 0;n < 32;n ++){
|
||||
ipRight[n] = tempRight[n];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var finalData =new Array(64);
|
||||
for(i = 0;i < 32;i ++){
|
||||
finalData[i] = ipRight[i];
|
||||
finalData[32+i] = ipLeft[i];
|
||||
}
|
||||
return finallyPermute(finalData);
|
||||
}
|
||||
|
||||
function initPermute(originalData){
|
||||
var ipByte = new Array(64);
|
||||
for (i = 0, m = 1, n = 0; i < 4; i++, m += 2, n += 2) {
|
||||
for (j = 7, k = 0; j >= 0; j--, k++) {
|
||||
ipByte[i * 8 + k] = originalData[j * 8 + m];
|
||||
ipByte[i * 8 + k + 32] = originalData[j * 8 + n];
|
||||
}
|
||||
}
|
||||
return ipByte;
|
||||
}
|
||||
|
||||
function expandPermute(rightData){
|
||||
var epByte = new Array(48);
|
||||
for (i = 0; i < 8; i++) {
|
||||
if (i == 0) {
|
||||
epByte[i * 6 + 0] = rightData[31];
|
||||
} else {
|
||||
epByte[i * 6 + 0] = rightData[i * 4 - 1];
|
||||
}
|
||||
epByte[i * 6 + 1] = rightData[i * 4 + 0];
|
||||
epByte[i * 6 + 2] = rightData[i * 4 + 1];
|
||||
epByte[i * 6 + 3] = rightData[i * 4 + 2];
|
||||
epByte[i * 6 + 4] = rightData[i * 4 + 3];
|
||||
if (i == 7) {
|
||||
epByte[i * 6 + 5] = rightData[0];
|
||||
} else {
|
||||
epByte[i * 6 + 5] = rightData[i * 4 + 4];
|
||||
}
|
||||
}
|
||||
return epByte;
|
||||
}
|
||||
|
||||
function xor(byteOne,byteTwo){
|
||||
var xorByte = new Array(byteOne.length);
|
||||
for(i = 0;i < byteOne.length; i ++){
|
||||
xorByte[i] = byteOne[i] ^ byteTwo[i];
|
||||
}
|
||||
return xorByte;
|
||||
}
|
||||
|
||||
function sBoxPermute(expandByte){
|
||||
|
||||
var sBoxByte = new Array(32);
|
||||
var binary = "";
|
||||
var s1 = [
|
||||
[14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7],
|
||||
[0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8],
|
||||
[4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0],
|
||||
[15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13 ]];
|
||||
|
||||
/* Table - s2 */
|
||||
var s2 = [
|
||||
[15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10],
|
||||
[3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5],
|
||||
[0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15],
|
||||
[13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9 ]];
|
||||
|
||||
/* Table - s3 */
|
||||
var s3= [
|
||||
[10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8],
|
||||
[13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1],
|
||||
[13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7],
|
||||
[1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12 ]];
|
||||
/* Table - s4 */
|
||||
var s4 = [
|
||||
[7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15],
|
||||
[13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9],
|
||||
[10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4],
|
||||
[3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14 ]];
|
||||
|
||||
/* Table - s5 */
|
||||
var s5 = [
|
||||
[2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9],
|
||||
[14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6],
|
||||
[4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14],
|
||||
[11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3 ]];
|
||||
|
||||
/* Table - s6 */
|
||||
var s6 = [
|
||||
[12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11],
|
||||
[10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8],
|
||||
[9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6],
|
||||
[4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13 ]];
|
||||
|
||||
/* Table - s7 */
|
||||
var s7 = [
|
||||
[4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1],
|
||||
[13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6],
|
||||
[1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2],
|
||||
[6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12]];
|
||||
|
||||
/* Table - s8 */
|
||||
var s8 = [
|
||||
[13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7],
|
||||
[1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2],
|
||||
[7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8],
|
||||
[2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11]];
|
||||
|
||||
for(m=0;m<8;m++){
|
||||
var i=0,j=0;
|
||||
i = expandByte[m*6+0]*2+expandByte[m*6+5];
|
||||
j = expandByte[m * 6 + 1] * 2 * 2 * 2
|
||||
+ expandByte[m * 6 + 2] * 2* 2
|
||||
+ expandByte[m * 6 + 3] * 2
|
||||
+ expandByte[m * 6 + 4];
|
||||
switch (m) {
|
||||
case 0 :
|
||||
binary = getBoxBinary(s1[i][j]);
|
||||
break;
|
||||
case 1 :
|
||||
binary = getBoxBinary(s2[i][j]);
|
||||
break;
|
||||
case 2 :
|
||||
binary = getBoxBinary(s3[i][j]);
|
||||
break;
|
||||
case 3 :
|
||||
binary = getBoxBinary(s4[i][j]);
|
||||
break;
|
||||
case 4 :
|
||||
binary = getBoxBinary(s5[i][j]);
|
||||
break;
|
||||
case 5 :
|
||||
binary = getBoxBinary(s6[i][j]);
|
||||
break;
|
||||
case 6 :
|
||||
binary = getBoxBinary(s7[i][j]);
|
||||
break;
|
||||
case 7 :
|
||||
binary = getBoxBinary(s8[i][j]);
|
||||
break;
|
||||
}
|
||||
sBoxByte[m*4+0] = parseInt(binary.substring(0,1));
|
||||
sBoxByte[m*4+1] = parseInt(binary.substring(1,2));
|
||||
sBoxByte[m*4+2] = parseInt(binary.substring(2,3));
|
||||
sBoxByte[m*4+3] = parseInt(binary.substring(3,4));
|
||||
}
|
||||
return sBoxByte;
|
||||
}
|
||||
|
||||
function pPermute(sBoxByte){
|
||||
var pBoxPermute = new Array(32);
|
||||
pBoxPermute[ 0] = sBoxByte[15];
|
||||
pBoxPermute[ 1] = sBoxByte[ 6];
|
||||
pBoxPermute[ 2] = sBoxByte[19];
|
||||
pBoxPermute[ 3] = sBoxByte[20];
|
||||
pBoxPermute[ 4] = sBoxByte[28];
|
||||
pBoxPermute[ 5] = sBoxByte[11];
|
||||
pBoxPermute[ 6] = sBoxByte[27];
|
||||
pBoxPermute[ 7] = sBoxByte[16];
|
||||
pBoxPermute[ 8] = sBoxByte[ 0];
|
||||
pBoxPermute[ 9] = sBoxByte[14];
|
||||
pBoxPermute[10] = sBoxByte[22];
|
||||
pBoxPermute[11] = sBoxByte[25];
|
||||
pBoxPermute[12] = sBoxByte[ 4];
|
||||
pBoxPermute[13] = sBoxByte[17];
|
||||
pBoxPermute[14] = sBoxByte[30];
|
||||
pBoxPermute[15] = sBoxByte[ 9];
|
||||
pBoxPermute[16] = sBoxByte[ 1];
|
||||
pBoxPermute[17] = sBoxByte[ 7];
|
||||
pBoxPermute[18] = sBoxByte[23];
|
||||
pBoxPermute[19] = sBoxByte[13];
|
||||
pBoxPermute[20] = sBoxByte[31];
|
||||
pBoxPermute[21] = sBoxByte[26];
|
||||
pBoxPermute[22] = sBoxByte[ 2];
|
||||
pBoxPermute[23] = sBoxByte[ 8];
|
||||
pBoxPermute[24] = sBoxByte[18];
|
||||
pBoxPermute[25] = sBoxByte[12];
|
||||
pBoxPermute[26] = sBoxByte[29];
|
||||
pBoxPermute[27] = sBoxByte[ 5];
|
||||
pBoxPermute[28] = sBoxByte[21];
|
||||
pBoxPermute[29] = sBoxByte[10];
|
||||
pBoxPermute[30] = sBoxByte[ 3];
|
||||
pBoxPermute[31] = sBoxByte[24];
|
||||
return pBoxPermute;
|
||||
}
|
||||
|
||||
function finallyPermute(endByte){
|
||||
var fpByte = new Array(64);
|
||||
fpByte[ 0] = endByte[39];
|
||||
fpByte[ 1] = endByte[ 7];
|
||||
fpByte[ 2] = endByte[47];
|
||||
fpByte[ 3] = endByte[15];
|
||||
fpByte[ 4] = endByte[55];
|
||||
fpByte[ 5] = endByte[23];
|
||||
fpByte[ 6] = endByte[63];
|
||||
fpByte[ 7] = endByte[31];
|
||||
fpByte[ 8] = endByte[38];
|
||||
fpByte[ 9] = endByte[ 6];
|
||||
fpByte[10] = endByte[46];
|
||||
fpByte[11] = endByte[14];
|
||||
fpByte[12] = endByte[54];
|
||||
fpByte[13] = endByte[22];
|
||||
fpByte[14] = endByte[62];
|
||||
fpByte[15] = endByte[30];
|
||||
fpByte[16] = endByte[37];
|
||||
fpByte[17] = endByte[ 5];
|
||||
fpByte[18] = endByte[45];
|
||||
fpByte[19] = endByte[13];
|
||||
fpByte[20] = endByte[53];
|
||||
fpByte[21] = endByte[21];
|
||||
fpByte[22] = endByte[61];
|
||||
fpByte[23] = endByte[29];
|
||||
fpByte[24] = endByte[36];
|
||||
fpByte[25] = endByte[ 4];
|
||||
fpByte[26] = endByte[44];
|
||||
fpByte[27] = endByte[12];
|
||||
fpByte[28] = endByte[52];
|
||||
fpByte[29] = endByte[20];
|
||||
fpByte[30] = endByte[60];
|
||||
fpByte[31] = endByte[28];
|
||||
fpByte[32] = endByte[35];
|
||||
fpByte[33] = endByte[ 3];
|
||||
fpByte[34] = endByte[43];
|
||||
fpByte[35] = endByte[11];
|
||||
fpByte[36] = endByte[51];
|
||||
fpByte[37] = endByte[19];
|
||||
fpByte[38] = endByte[59];
|
||||
fpByte[39] = endByte[27];
|
||||
fpByte[40] = endByte[34];
|
||||
fpByte[41] = endByte[ 2];
|
||||
fpByte[42] = endByte[42];
|
||||
fpByte[43] = endByte[10];
|
||||
fpByte[44] = endByte[50];
|
||||
fpByte[45] = endByte[18];
|
||||
fpByte[46] = endByte[58];
|
||||
fpByte[47] = endByte[26];
|
||||
fpByte[48] = endByte[33];
|
||||
fpByte[49] = endByte[ 1];
|
||||
fpByte[50] = endByte[41];
|
||||
fpByte[51] = endByte[ 9];
|
||||
fpByte[52] = endByte[49];
|
||||
fpByte[53] = endByte[17];
|
||||
fpByte[54] = endByte[57];
|
||||
fpByte[55] = endByte[25];
|
||||
fpByte[56] = endByte[32];
|
||||
fpByte[57] = endByte[ 0];
|
||||
fpByte[58] = endByte[40];
|
||||
fpByte[59] = endByte[ 8];
|
||||
fpByte[60] = endByte[48];
|
||||
fpByte[61] = endByte[16];
|
||||
fpByte[62] = endByte[56];
|
||||
fpByte[63] = endByte[24];
|
||||
return fpByte;
|
||||
}
|
||||
|
||||
function getBoxBinary(i) {
|
||||
var binary = "";
|
||||
switch (i) {
|
||||
case 0 :binary = "0000";break;
|
||||
case 1 :binary = "0001";break;
|
||||
case 2 :binary = "0010";break;
|
||||
case 3 :binary = "0011";break;
|
||||
case 4 :binary = "0100";break;
|
||||
case 5 :binary = "0101";break;
|
||||
case 6 :binary = "0110";break;
|
||||
case 7 :binary = "0111";break;
|
||||
case 8 :binary = "1000";break;
|
||||
case 9 :binary = "1001";break;
|
||||
case 10 :binary = "1010";break;
|
||||
case 11 :binary = "1011";break;
|
||||
case 12 :binary = "1100";break;
|
||||
case 13 :binary = "1101";break;
|
||||
case 14 :binary = "1110";break;
|
||||
case 15 :binary = "1111";break;
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
/*
|
||||
* generate 16 keys for xor
|
||||
*
|
||||
*/
|
||||
function generateKeys(keyByte){
|
||||
var key = new Array(56);
|
||||
var keys = new Array();
|
||||
|
||||
keys[ 0] = new Array();
|
||||
keys[ 1] = new Array();
|
||||
keys[ 2] = new Array();
|
||||
keys[ 3] = new Array();
|
||||
keys[ 4] = new Array();
|
||||
keys[ 5] = new Array();
|
||||
keys[ 6] = new Array();
|
||||
keys[ 7] = new Array();
|
||||
keys[ 8] = new Array();
|
||||
keys[ 9] = new Array();
|
||||
keys[10] = new Array();
|
||||
keys[11] = new Array();
|
||||
keys[12] = new Array();
|
||||
keys[13] = new Array();
|
||||
keys[14] = new Array();
|
||||
keys[15] = new Array();
|
||||
var loop = [1,1,2,2,2,2,2,2,1,2,2,2,2,2,2,1];
|
||||
|
||||
for(i=0;i<7;i++){
|
||||
for(j=0,k=7;j<8;j++,k--){
|
||||
key[i*8+j]=keyByte[8*k+i];
|
||||
}
|
||||
}
|
||||
|
||||
var i = 0;
|
||||
for(i = 0;i < 16;i ++){
|
||||
var tempLeft=0;
|
||||
var tempRight=0;
|
||||
for(j = 0; j < loop[i];j ++){
|
||||
tempLeft = key[0];
|
||||
tempRight = key[28];
|
||||
for(k = 0;k < 27 ;k ++){
|
||||
key[k] = key[k+1];
|
||||
key[28+k] = key[29+k];
|
||||
}
|
||||
key[27]=tempLeft;
|
||||
key[55]=tempRight;
|
||||
}
|
||||
var tempKey = new Array(48);
|
||||
tempKey[ 0] = key[13];
|
||||
tempKey[ 1] = key[16];
|
||||
tempKey[ 2] = key[10];
|
||||
tempKey[ 3] = key[23];
|
||||
tempKey[ 4] = key[ 0];
|
||||
tempKey[ 5] = key[ 4];
|
||||
tempKey[ 6] = key[ 2];
|
||||
tempKey[ 7] = key[27];
|
||||
tempKey[ 8] = key[14];
|
||||
tempKey[ 9] = key[ 5];
|
||||
tempKey[10] = key[20];
|
||||
tempKey[11] = key[ 9];
|
||||
tempKey[12] = key[22];
|
||||
tempKey[13] = key[18];
|
||||
tempKey[14] = key[11];
|
||||
tempKey[15] = key[ 3];
|
||||
tempKey[16] = key[25];
|
||||
tempKey[17] = key[ 7];
|
||||
tempKey[18] = key[15];
|
||||
tempKey[19] = key[ 6];
|
||||
tempKey[20] = key[26];
|
||||
tempKey[21] = key[19];
|
||||
tempKey[22] = key[12];
|
||||
tempKey[23] = key[ 1];
|
||||
tempKey[24] = key[40];
|
||||
tempKey[25] = key[51];
|
||||
tempKey[26] = key[30];
|
||||
tempKey[27] = key[36];
|
||||
tempKey[28] = key[46];
|
||||
tempKey[29] = key[54];
|
||||
tempKey[30] = key[29];
|
||||
tempKey[31] = key[39];
|
||||
tempKey[32] = key[50];
|
||||
tempKey[33] = key[44];
|
||||
tempKey[34] = key[32];
|
||||
tempKey[35] = key[47];
|
||||
tempKey[36] = key[43];
|
||||
tempKey[37] = key[48];
|
||||
tempKey[38] = key[38];
|
||||
tempKey[39] = key[55];
|
||||
tempKey[40] = key[33];
|
||||
tempKey[41] = key[52];
|
||||
tempKey[42] = key[45];
|
||||
tempKey[43] = key[41];
|
||||
tempKey[44] = key[49];
|
||||
tempKey[45] = key[35];
|
||||
tempKey[46] = key[28];
|
||||
tempKey[47] = key[31];
|
||||
switch(i){
|
||||
case 0: for(m=0;m < 48 ;m++){ keys[ 0][m] = tempKey[m]; } break;
|
||||
case 1: for(m=0;m < 48 ;m++){ keys[ 1][m] = tempKey[m]; } break;
|
||||
case 2: for(m=0;m < 48 ;m++){ keys[ 2][m] = tempKey[m]; } break;
|
||||
case 3: for(m=0;m < 48 ;m++){ keys[ 3][m] = tempKey[m]; } break;
|
||||
case 4: for(m=0;m < 48 ;m++){ keys[ 4][m] = tempKey[m]; } break;
|
||||
case 5: for(m=0;m < 48 ;m++){ keys[ 5][m] = tempKey[m]; } break;
|
||||
case 6: for(m=0;m < 48 ;m++){ keys[ 6][m] = tempKey[m]; } break;
|
||||
case 7: for(m=0;m < 48 ;m++){ keys[ 7][m] = tempKey[m]; } break;
|
||||
case 8: for(m=0;m < 48 ;m++){ keys[ 8][m] = tempKey[m]; } break;
|
||||
case 9: for(m=0;m < 48 ;m++){ keys[ 9][m] = tempKey[m]; } break;
|
||||
case 10: for(m=0;m < 48 ;m++){ keys[10][m] = tempKey[m]; } break;
|
||||
case 11: for(m=0;m < 48 ;m++){ keys[11][m] = tempKey[m]; } break;
|
||||
case 12: for(m=0;m < 48 ;m++){ keys[12][m] = tempKey[m]; } break;
|
||||
case 13: for(m=0;m < 48 ;m++){ keys[13][m] = tempKey[m]; } break;
|
||||
case 14: for(m=0;m < 48 ;m++){ keys[14][m] = tempKey[m]; } break;
|
||||
case 15: for(m=0;m < 48 ;m++){ keys[15][m] = tempKey[m]; } break;
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Compute the site's strEnc(data, k1, k2, k3) using the original des.js.
|
||||
// usage: node script/desenc.js <data> [k1] [k2] [k3]
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const desPath = path.join(__dirname, 'des.js');
|
||||
const code = fs.readFileSync(desPath, 'utf8');
|
||||
const ctx = { console };
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(code, ctx);
|
||||
|
||||
const [data, k1 = '1', k2 = '2', k3 = '3'] = process.argv.slice(2);
|
||||
if (data === undefined) {
|
||||
console.error('usage: node desenc.js <data> [k1] [k2] [k3]');
|
||||
process.exit(2);
|
||||
}
|
||||
const out = ctx.strEnc(data, k1, k2, k3);
|
||||
process.stdout.write(String(out));
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
study.dgjy.net(莞易学 / 东莞教师培训)视频课时 —— 自动完成 + 完成度核验
|
||||
|
||||
机制(逆向结论)
|
||||
* 播放器是网易云课堂 EduPlayer,进度上报接口:
|
||||
POST /train/course/front/period/study/log
|
||||
body {"periodId":4133,"isComplete":0|1,"lastProgressTime":"<秒>"}
|
||||
* onTimeupdate 时若 currentTime > maxTime 才上报(isComplete=0)
|
||||
* 视频播到结尾触发 onPlayEnd -> POST {"periodId":x,"isComplete":1} ← 这才是"完成"
|
||||
* onSeek 守卫:仅当 n.newData !== duration 且 allowVideoDrag === "0" 且 newData > maxTime
|
||||
时才会重新拉签名把进度拉回去。所以「拖到正好结尾」不会被拦。
|
||||
* 上报后 10 秒自动跳到下一课时。
|
||||
|
||||
完成度核验(唯一可信来源)
|
||||
GET /train/course/front/period/list/tree/studyLog?courseId=<MOOC课程ID>&isFront=1
|
||||
每个课时节点里的 studyLog.completeTime 非空 = 平台真的记为完成。
|
||||
|
||||
用法(在仓库根目录执行)
|
||||
python src/study_tool/plugins/guanyixue/script/study_video.py verify <trainCourseId> # 只核验,不动数据
|
||||
python src/study_tool/plugins/guanyixue/script/study_video.py run <trainCourseId> # 完成所有未完成的视频课时
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
# 脚本在 src/study_tool/plugins/guanyixue/script/,往上 4 层就是 src;命令行运行时自己把它加进 sys.path
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(HERE, "..", "..", "..", "..")))
|
||||
|
||||
from study_tool import database as store
|
||||
|
||||
BASE = "https://study.dgjy.net"
|
||||
KV_SESSION = "study_session" # 会话存在 SQLite 的 kv 表(和网页端同一份库)
|
||||
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"
|
||||
)
|
||||
CDP = "http://127.0.0.1:9222"
|
||||
|
||||
|
||||
def load_session():
|
||||
"""从 SQLite 读上次抓到的会话;没有返回 None。"""
|
||||
sess = store.kv_get(KV_SESSION)
|
||||
return sess if isinstance(sess, dict) and sess.get("token") else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 会话 / 认证
|
||||
|
||||
|
||||
def capture_session():
|
||||
"""从已登录的浏览器里取 Middle-Token 与 cookie。"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.connect_over_cdp(CDP)
|
||||
cookies = b.contexts[0].cookies()
|
||||
token = ""
|
||||
for c in cookies:
|
||||
if c["name"] == "Middle-Token" and "study.dgjy.net" in (c.get("domain") or ""):
|
||||
token = c["value"]
|
||||
if not token:
|
||||
raise SystemExit("没找到 Middle-Token —— 请先在浏览器里登录 study.dgjy.net")
|
||||
sess = {
|
||||
"token": token,
|
||||
"cookies": [
|
||||
{"name": c["name"], "value": c["value"], "domain": c["domain"], "path": c["path"]} for c in cookies
|
||||
],
|
||||
}
|
||||
store.kv_set(KV_SESSION, sess)
|
||||
return sess
|
||||
|
||||
|
||||
def api(sess=None):
|
||||
if sess is None:
|
||||
sess = load_session() or capture_session()
|
||||
s = requests.Session()
|
||||
s.headers.update(
|
||||
{
|
||||
"User-Agent": UA,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + sess["token"],
|
||||
"Referer": BASE + "/training/courseVideo",
|
||||
}
|
||||
)
|
||||
for c in sess["cookies"]:
|
||||
if "dgjy.net" in (c.get("domain") or ""):
|
||||
s.cookies.set(
|
||||
c["name"], c["value"] or "", domain=(c.get("domain") or "").lstrip("."), path=c.get("path") or "/"
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def check_auth(s):
|
||||
r = s.get(BASE + "/train/getInfo", timeout=30)
|
||||
try:
|
||||
return r.json().get("code") == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 课程结构
|
||||
|
||||
|
||||
def course_info(s, train_id):
|
||||
return s.get(BASE + "/train/course/front/trainCourse/getById?id=%s" % train_id, timeout=30).json().get("data") or {}
|
||||
|
||||
|
||||
def period_tree(s, course_id, is_front="1"):
|
||||
r = s.get(
|
||||
BASE + "/train/course/front/period/list/tree/studyLog?courseId=%s&isFront=%s" % (course_id, is_front),
|
||||
timeout=30,
|
||||
)
|
||||
return r.json().get("data") or []
|
||||
|
||||
|
||||
def flatten(nodes, out=None, parent=""):
|
||||
out = [] if out is None else out
|
||||
for n in nodes or []:
|
||||
sl = n.get("studyLog") or {}
|
||||
out.append(
|
||||
{
|
||||
"periodId": n.get("id"),
|
||||
"name": n.get("name") or "",
|
||||
"parent": parent,
|
||||
"periodType": str(n.get("periodType") or ""),
|
||||
"duration": n.get("duration"),
|
||||
"periodScore": n.get("periodScore"),
|
||||
"allowVideoDrag": n.get("allowVideoDrag"),
|
||||
"completeLimit": n.get("completeLimit"),
|
||||
"kcdm": n.get("kcdm"),
|
||||
"completeTime": sl.get("completeTime"),
|
||||
"logPeriodScore": sl.get("logPeriodScore"),
|
||||
"lastProgressTime": sl.get("lastProgressTime"),
|
||||
}
|
||||
)
|
||||
flatten(n.get("children"), out, n.get("name") or parent)
|
||||
return out
|
||||
|
||||
|
||||
def video_periods(s, train_id):
|
||||
"""返回 (培训课程信息, MOOC课程ID, 视频课时列表)"""
|
||||
info = course_info(s, train_id)
|
||||
top = flatten(period_tree(s, train_id, ""))
|
||||
mooc = ""
|
||||
for t in top:
|
||||
if t["kcdm"]:
|
||||
mooc = t["kcdm"]
|
||||
break
|
||||
vids = []
|
||||
if mooc:
|
||||
vids = [p for p in flatten(period_tree(s, mooc, "1")) if p["periodType"] == "2"]
|
||||
return info, mooc, vids
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 核验
|
||||
|
||||
|
||||
def report(info, vids):
|
||||
print()
|
||||
print("课程:%s" % info.get("courseName", "?"))
|
||||
st = info.get("study") or {}
|
||||
print(
|
||||
"进度:%s/%s 课时 规则 completeRule=%s"
|
||||
% (st.get("periodStudy"), st.get("periodTotal"), info.get("completeRule"))
|
||||
)
|
||||
print()
|
||||
print("%-8s %-40s %-8s %-6s %-20s %s" % ("periodId", "课时名", "时长(s)", "拖拽", "completeTime", "判定"))
|
||||
done = 0
|
||||
for v in vids:
|
||||
ok = bool(v["completeTime"])
|
||||
done += ok
|
||||
print(
|
||||
"%-8s %-40s %-8s %-6s %-20s %s"
|
||||
% (
|
||||
v["periodId"],
|
||||
v["name"][:40],
|
||||
v["duration"],
|
||||
v["allowVideoDrag"],
|
||||
v["completeTime"] or "-",
|
||||
"✅ 已完成" if ok else "❌ 未完成",
|
||||
)
|
||||
)
|
||||
print()
|
||||
print("视频课时:%d/%d 已完成" % (done, len(vids)))
|
||||
return done, len(vids)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 自动完成
|
||||
|
||||
|
||||
def complete_one(pg, train_id, train_course, period_id, timeout=180):
|
||||
"""打开视频页 -> 拖到结尾 -> 播放 -> 等 isComplete=1 上报。返回 True/False。"""
|
||||
url = "%s/training/courseVideo?classhour=%d&isJoin=1&needJoin=1&courseId=%s&isFront=1&trainCourseId=%s" % (
|
||||
BASE,
|
||||
period_id,
|
||||
train_course,
|
||||
train_id,
|
||||
)
|
||||
posted = {"done": False}
|
||||
|
||||
def on_req(r):
|
||||
try:
|
||||
if "study/log" in r.url and "detail" not in r.url:
|
||||
body = r.post_data or ""
|
||||
if '"isComplete":1' in body.replace(" ", ""):
|
||||
posted["done"] = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pg.on("request", on_req)
|
||||
pg.goto(url, wait_until="domcontentloaded", timeout=60000)
|
||||
|
||||
dur = None
|
||||
for _ in range(40):
|
||||
time.sleep(1.5)
|
||||
st = pg.evaluate("""() => {
|
||||
const v = Array.from(document.querySelectorAll('video')).filter(x=>x.duration>100)[0];
|
||||
return v ? {dur: v.duration, ct: v.currentTime} : null;
|
||||
}""")
|
||||
if st and st["dur"] > 100:
|
||||
dur = st["dur"]
|
||||
break
|
||||
if not dur:
|
||||
print(" 播放器未就绪")
|
||||
return False
|
||||
|
||||
# 拖到结尾前 4 秒(绕开 onSeek 守卫,等价于"拖到正好结尾")
|
||||
pg.evaluate("""() => {
|
||||
const v = Array.from(document.querySelectorAll('video')).filter(x=>x.duration>100)[0];
|
||||
v.muted = true;
|
||||
v.currentTime = Math.max(0, v.duration - 4);
|
||||
}""")
|
||||
time.sleep(1)
|
||||
|
||||
btn = pg.query_selector(".j-bigplaybtn") or pg.query_selector(".j-playbtn")
|
||||
if btn and btn.bounding_box():
|
||||
bx = btn.bounding_box()
|
||||
pg.mouse.click(bx["x"] + bx["width"] / 2, bx["y"] + bx["height"] / 2)
|
||||
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
time.sleep(2)
|
||||
if posted["done"]:
|
||||
return True
|
||||
st = pg.evaluate("""() => {
|
||||
const v = Array.from(document.querySelectorAll('video')).filter(x=>x.duration>100)[0];
|
||||
return v ? {ct: Math.round(v.currentTime), ended: v.ended, paused: v.paused} : null;
|
||||
}""")
|
||||
# 防挂机弹窗「是否继续观看视频?」
|
||||
try:
|
||||
ok = pg.query_selector(".el-message-box__btns .el-button--primary")
|
||||
if ok:
|
||||
ok.click()
|
||||
except Exception:
|
||||
pass
|
||||
# 播放结束但还没上报 -> 再补一次播放
|
||||
if st and st["ended"] and not posted["done"]:
|
||||
btn = pg.query_selector(".j-bigplaybtn") or pg.query_selector(".j-playbtn")
|
||||
if btn and btn.bounding_box():
|
||||
bx = btn.bounding_box()
|
||||
pg.mouse.click(bx["x"] + bx["width"] / 2, bx["y"] + bx["height"] / 2)
|
||||
return posted["done"]
|
||||
|
||||
|
||||
def run(train_id, only_missing=True):
|
||||
sess = load_session() or capture_session()
|
||||
s = api(sess)
|
||||
if not check_auth(s):
|
||||
print("会话失效,重新抓取…")
|
||||
sess = capture_session()
|
||||
s = api(sess)
|
||||
if not check_auth(s):
|
||||
raise SystemExit("认证失败:请先在浏览器登录 study.dgjy.net")
|
||||
|
||||
info, mooc, vids = video_periods(s, train_id)
|
||||
print("=== 处理前核验 ===")
|
||||
report(info, vids)
|
||||
|
||||
todo = [v for v in vids if not v["completeTime"]]
|
||||
if not todo:
|
||||
print("\n所有视频课时均已完成,无需处理。")
|
||||
return
|
||||
|
||||
print("\n待完成 %d 个课时:" % len(todo))
|
||||
for v in todo:
|
||||
print(" %s %s (%ss)" % (v["periodId"], v["name"][:40], v["duration"]))
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.connect_over_cdp(CDP)
|
||||
ctx = b.contexts[0]
|
||||
pg = ctx.new_page()
|
||||
for i, v in enumerate(todo, 1):
|
||||
print("\n[%d/%d] %s %s" % (i, len(todo), v["periodId"], v["name"][:40]))
|
||||
try:
|
||||
ok = complete_one(pg, train_id, train_id, v["periodId"])
|
||||
except Exception as e:
|
||||
print(" 异常:", e)
|
||||
ok = False
|
||||
print(" 上报 isComplete=1 :", "✅" if ok else "❌")
|
||||
time.sleep(4)
|
||||
try:
|
||||
pg.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("\n=== 处理后核验(以平台数据为准)===")
|
||||
time.sleep(3)
|
||||
info2, _, vids2 = video_periods(s, train_id)
|
||||
report(info2, vids2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
mode, tid = sys.argv[1], sys.argv[2]
|
||||
if mode == "verify":
|
||||
sess = load_session() or capture_session()
|
||||
s = api(sess)
|
||||
info, mooc, vids = video_periods(s, tid)
|
||||
print("MOOC 课程ID:", mooc)
|
||||
report(info, vids)
|
||||
elif mode == "run":
|
||||
run(tid)
|
||||
else:
|
||||
print(__doc__)
|
||||
@@ -0,0 +1 @@
|
||||
"""莞易学工具模块:http / login / course / feedback / learn / spa。"""
|
||||
@@ -0,0 +1,238 @@
|
||||
"""旧版课程页:课程列表、结构解析与页面代际分发。"""
|
||||
|
||||
import re
|
||||
from urllib.parse import unquote, urljoin, urlsplit
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from study_tool.platforms import registry
|
||||
from study_tool.platforms.errors import SessionExpired
|
||||
from study_tool.plugins.guanyixue.detect import detect_generation, has_study_marker
|
||||
|
||||
from ..config import COURSE_SECTIONS, JSPX, PLAN_STATUS, STUDY, STUDYABLE_STATUS
|
||||
from .http import whoami
|
||||
from .login import _jspx_call, ensure_jspx_session, fetch_admin
|
||||
from .spa import (StudyClient, discover_study, extract_study_token,
|
||||
extract_train_id, save_snapshot, study_resolve_train_id)
|
||||
|
||||
|
||||
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 _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=""):
|
||||
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
|
||||
@@ -0,0 +1,107 @@
|
||||
"""旧版反馈表与测验提交。"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from study_tool.runtime import log
|
||||
|
||||
from ..config import EP_STUDY, JSPX
|
||||
from .course import parse_ids
|
||||
from .http import new_session
|
||||
|
||||
|
||||
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 "?"))
|
||||
@@ -0,0 +1,39 @@
|
||||
"""莞易学请求底座:会话、cookie、身份与新版平台 token。"""
|
||||
|
||||
import re
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from ..config import UA
|
||||
|
||||
|
||||
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 study_token(s):
|
||||
for c in s.cookies:
|
||||
if c.name == "Middle-Token" and "dgjy" in (c.domain or ""):
|
||||
return c.value
|
||||
return ""
|
||||
@@ -0,0 +1,96 @@
|
||||
"""旧版学习流程:反馈表、测验与课件心跳。"""
|
||||
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from study_tool.runtime import RUNS, RUNS_LOCK, log, set_state
|
||||
|
||||
from .course import discover
|
||||
from .feedback import CwItem, submit_feedback, submit_quiz
|
||||
from .http import dump_cookies
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,372 @@
|
||||
"""登录与 SSO:账号密码登录、CASTGC 续期、新版平台 token 换取。"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from html import unescape as html_unescape
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
from study_tool.platforms.errors import SessionExpired
|
||||
|
||||
from ..config import (AUTHORIZE, CODE_RE, DESENC, JSPX, LOGIN_HEADERS, NODE, STUDY, TPASS,
|
||||
TRAIN_AUTHORIZE_PATH)
|
||||
from .http import new_session, study_token, whoami
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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。
|
||||
"""
|
||||
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。"""
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user