feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8

体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
study-tool
2026-11-01 17:47:22 +08:00
parent 491fd08c1e
commit aa48145557
114 changed files with 7779 additions and 3302 deletions
+2 -1
View File
@@ -1,10 +1,11 @@
"""多账号学习控制台(东莞市教师培训平台)。"""
import os
from dotenv import load_dotenv
# 仓库根目录的 .env:本机启动(python app.py / uv run app.py)也按它配置。
# 必须在任何子模块之前加载 —— store / config 在 import 时就读环境变量;
# 必须在任何子模块之前加载 —— database / config 在 import 时就读环境变量;
# 已经在环境里的变量优先(Docker 由 compose 注入,不会被 .env 覆盖)。
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
load_dotenv(os.path.join(_ROOT, ".env"))
+1
View File
@@ -1,4 +1,5 @@
"""`python -m study_tool` 入口(与根目录 app.py 等价)。"""
from study_tool.app import main
if __name__ == "__main__":
+64 -48
View File
@@ -1,22 +1,19 @@
"""多账号学习控制台 —— 入口。
代码结构
config 常量 / 环境变量
config 常量 / 环境变量 / 系统设置(config.settings)
database SQLite 存储(SQLAlchemy ORM:模型 + 读写函数)
runtime 运行状态 + 统一日志(控制台 / 内存 / SQLite)
platform_jspx 旧版平台登录与课程抓取
platform_study 新版平台 API
platforms 平台运行时(registry / base / contract / errors)
plugins 平台插件(guanyixue / gdgxk …);平台实现、抓包、测验都在插件目录
engine 学习引擎(worker / 播放 / 提交)
capture 抓包
models HTTP 请求模型
settings_store 系统设置
llm 大模型调用
quiz 测验解析 / 交卷 / AI 答题
accounts 账号与登录接口
routes_misc 课程 / 学习 / 抓包 / 快照接口
log_routes 日志与运行状态接口
models HTTP 请求模型
routes 账号 / 设置 / 日志 / 课程等接口
日志只走 runtime.log(),不要自己 print。
"""
import json
import os
import threading
@@ -26,17 +23,16 @@ from fastapi import Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from study_tool import store
from study_tool.config import *
from study_tool import database
from study_tool.config import STATIC_DIR
from study_tool.platforms import load_builtin
from study_tool.routes import load_routes
from study_tool.runtime import RUNS, RUNS_LOCK, log
from study_tool.webapp import app
# 导入即把各路由模块的接口注册到 app 上(必须在 main() 之前)
from study_tool import llm, quiz, settings_store # noqa: E402,F401
from study_tool.routes import accounts, logs, misc # noqa: E402,F401
load_builtin() # 注册内置平台插件(jspx / study)
load_routes() # 扫描 routes/ 并导入全部路由模块,接口注册到 app 上(必须在 main() 之前)
load_builtin() # 扫 plugins/ 目录加载全部插件(目录为空也能正常启动)
APP_ID = "dgjy-study-console"
@@ -49,6 +45,7 @@ if os.path.isdir(_ASSETS_DIR):
# 不设置 = 不校验(本机使用,或前面已经有反代做鉴权)。/api/health 永远放行,供健康检查用。
AUTH_TOKEN = (os.environ.get("AUTH_TOKEN") or "").strip()
AUTH_COOKIE = "dgjy_auth"
_PUBLIC_PATHS = {"/query", "/api/query", "/favicon.svg"}
_AUTH_PAGE = """<!doctype html><meta charset="utf-8"><title>需要访问令牌</title>
<style>body{font:14px/1.7 system-ui,"Microsoft YaHei",sans-serif;background:#f5f6f8;color:#1f2430;
@@ -65,30 +62,34 @@ background:#2b6cf6;color:#fff;font-size:14px;cursor:pointer}code{background:#f1f
@app.middleware("http")
async def auth_gate(request: Request, call_next):
if not AUTH_TOKEN:
# 公开查询页(/query + /api/query)及其静态资源不需要 token
if not AUTH_TOKEN or request.url.path in _PUBLIC_PATHS or request.url.path.startswith("/assets/"):
return await call_next(request)
tok = (request.query_params.get("token")
or request.cookies.get(AUTH_COOKIE)
or request.headers.get("x-auth-token") or "")
tok = (
request.query_params.get("token")
or request.cookies.get(AUTH_COOKIE)
or request.headers.get("x-auth-token")
or ""
)
if tok == AUTH_TOKEN:
resp = await call_next(request)
if request.query_params.get("token"):
# 带上 ?token= 访问后下发 cookie,页面里所有同源 fetch 会自动带上
resp.set_cookie(AUTH_COOKIE, AUTH_TOKEN, max_age=30 * 24 * 3600,
httponly=True, samesite="lax")
resp.set_cookie(AUTH_COOKIE, AUTH_TOKEN, max_age=30 * 24 * 3600, httponly=True, samesite="lax")
return resp
if request.url.path == "/api/health":
return await call_next(request)
if request.url.path.startswith("/api/"):
return JSONResponse({"ok": False, "code": "unauthorized",
"error": "缺少访问令牌(AUTH_TOKEN)"}, status_code=401)
return JSONResponse(
{"ok": False, "code": "unauthorized", "error": "缺少访问令牌(AUTH_TOKEN)"}, status_code=401
)
return HTMLResponse(_AUTH_PAGE, status_code=401)
def hydrate_runs():
"""进程重启后把上次的运行状态读回来;上次没跑完的标记成「已中断」。"""
try:
saved = store.run_state_all()
saved = database.run_state_all()
except Exception:
return
with RUNS_LOCK:
@@ -101,22 +102,26 @@ def hydrate_runs():
st["status"] = "interrupted"
st["error"] = "服务重启,上次运行已中断"
try:
store.run_state_set(k, st) # 落库,别让库里一直挂着 studying
database.run_state_set(k, st) # 落库,别让库里一直挂着 studying
except Exception:
pass
RUNS[k] = st
@app.get("/query", response_class=HTMLResponse) # 公开查询页:同一个前端入口,由前端按路径渲染
@app.get("/", response_class=HTMLResponse)
def index():
# 页面必须禁止缓存:后端重启或换端口后,旧标签页会拿着缓存里的 HTML 继续跑,
# 页面照常显示、所有接口全 ERR_CONNECTION_REFUSED,看起来就像"登录失败"。
html = open(os.path.join(STATIC_DIR, "index.html"), encoding="utf-8").read()
return HTMLResponse(html, headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
})
return HTMLResponse(
html,
headers={
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
},
)
@app.get("/favicon.svg")
@@ -129,13 +134,20 @@ def favicon():
@app.get("/api/health")
def api_health():
return {"ok": True, "app": APP_ID, "pid": os.getpid(),
"port": int(os.environ.get("PORT", "8848")), "runs": len(RUNS),
"db": store.db_info(), "auth": bool(AUTH_TOKEN)}
return {
"ok": True,
"app": APP_ID,
"pid": os.getpid(),
"port": int(os.environ.get("PORT", "8848")),
"runs": len(RUNS),
"db": database.db_info(),
"auth": bool(AUTH_TOKEN),
}
def _port_open(port, host="127.0.0.1", timeout=0.6):
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
return s.connect_ex((host, port)) == 0
@@ -146,6 +158,7 @@ def _lan_ip():
if os.path.exists("/.dockerenv"):
return ""
import socket
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("223.5.5.5", 80))
@@ -157,6 +170,7 @@ def _lan_ip():
def _probe_self(port):
"""端口已被占用时判断是不是本程序自己在跑,避免重复启动直接报错。"""
import urllib.request
try:
with urllib.request.urlopen("http://127.0.0.1:%d/api/health" % port, timeout=1.5) as r:
return json.loads(r.read().decode("utf-8"))
@@ -169,38 +183,40 @@ def main():
# 默认只监听本机;Docker / 远程管理用 HOST=0.0.0.0(.env 里写 BIND_ADDR 同样生效)
host = os.environ.get("HOST") or os.environ.get("BIND_ADDR") or "127.0.0.1"
url = "http://127.0.0.1:%d" % port
# 设了访问令牌时,打印出来的地址一律带上 ?token=:本机访问同样要校验令牌
suffix = ("/?token=%s" % AUTH_TOKEN) if AUTH_TOKEN else ""
# 设了访问令牌时,日志里只提示「需要令牌」,绝不打印令牌本身:日志同时进控制台、
# 内存和 SQLite,前端「系统日志」弹窗里能直接看到,明文等于把钥匙贴在墙上。
suffix = "(需要访问令牌:见 .env 的 AUTH_TOKEN)" if AUTH_TOKEN else ""
store.init() # 建库建表 + 导入旧的 settings.json / logs/*.log
hydrate_runs() # 上次的运行状态读回来(没跑完的标成已中断)
log("system", "SQLite: %s" % store.DB_PATH)
database.init() # 建库建表 + 导入旧的 settings.json / logs/*.log
hydrate_runs() # 上次的运行状态读回来(没跑完的标成已中断)
log("system", "SQLite: %s" % database.DB_PATH)
if _port_open(port):
info = _probe_self(port)
if info and info.get("app") == APP_ID:
log("system", "服务已在运行 -> %s%s(PID %s)" % (url, suffix, info.get("pid")))
return
log("system", "端口 %d 被其它程序占用(不是本程序):netstat -ano | findstr :%d,"
"换端口用 PORT=8849 python app.py" % (port, port), level="error")
log(
"system",
"端口 %d 被其它程序占用(不是本程序):netstat -ano | findstr :%d,"
"换端口用 PORT=8849 python app.py" % (port, port),
level="error",
)
return
log("system", "控制台启动 -> %s%s" % (url, suffix))
if AUTH_TOKEN:
log("system", "IPv6 同步监听 http://[::1]:%d;远程访问 http://%s:%d" % (
port, _lan_ip() or "<服务器IP>", port))
log("system", "IPv6 同步监听 http://[::1]:%d;远程访问 http://%s:%d" % (port, _lan_ip() or "<服务器IP>", port))
elif host == "0.0.0.0":
log("system", "已监听 0.0.0.0 但没设 AUTH_TOKEN:同网段谁都能打开,远程部署请设置", level="warn")
srv4 = uvicorn.Server(uvicorn.Config(app, host=host, port=port,
log_level="warning", access_log=False))
srv4 = uvicorn.Server(uvicorn.Config(app, host=host, port=port, log_level="warning", access_log=False))
def _serve_v6():
# Windows 上 localhost 优先解析到 ::1。只绑 127.0.0.1 时,
# 浏览器打开 http://localhost:8848 就是 ERR_CONNECTION_REFUSED。
try:
uvicorn.Server(uvicorn.Config(app, host="::1", port=port,
log_level="warning", access_log=False)).run()
uvicorn.Server(uvicorn.Config(app, host="::1", port=port, log_level="warning", access_log=False)).run()
except Exception:
pass
+122
View File
@@ -0,0 +1,122 @@
"""题库匹配 + 大模型兜底,与平台无关。
插件负责两头:把平台的题目转成统一格式、把答案填回平台;中间「这题选什么」由这里决定:
1. 题库命中(题干归一化后:精确 -> 互相包含 -> 相似度)就直接用;
2. 没命中的题交给设置里配置的大模型;没配大模型就留空,由插件决定怎么处理。
题目格式:{"no": 题号, "type": 题型名(单选/多选/判断),"title": 题干, "options": [{"key": "A", "text": "..."}]}
题库格式(插件目录里的 *.json,可放多个):[{"q": 题干, "a": "B"}],多选写 "ABC"。
返回:{题号: {"answer": ["A", "C"], "source": "bank" | "llm"}},没答出的题不在结果里。
"""
import difflib
import json
import re
from pathlib import Path
from study_tool import runtime
from study_tool.config.settings import load_settings
from study_tool.llm import llm_chat
BATCH = 5 # 大模型每批问几题,单批失败只影响这一批
FUZZY = 0.9 # 相似度命中的下限
MIN_CONTAIN = 8 # 互相包含匹配时,两边都至少要有这么多字,免得短题干乱命中
SYSTEM = (
"你是考试答题助手。根据题干和选项判断正确答案。\n"
"单选题返回一个选项字母;多选题返回全部正确的选项字母连写(如 AC);判断题返回 A(正确)或 B(错误)。\n"
'只输出 JSON:{"answers":{"题号":"答案"}},不要输出解释或多余文字。'
)
def norm(s):
"""去掉空白和中英文标点、统一大小写,题干才能跨页面格式比较。"""
return re.sub(r"[\W_]+", "", str(s or "")).lower()
def letters(raw, options=None):
"""把 "A#C" / "AC" / ["A", "C"] 收敛成选项字母列表;有选项时只留合法字母,不是字母答案返回空。"""
keys = [o.get("key") for o in options or [] if o.get("key")]
if isinstance(raw, (list, tuple)):
raw = "".join(map(str, raw))
s = re.sub(r"[#,,、/\s]+", "", str(raw or "")).upper()
if not re.fullmatch(r"[A-F]+", s):
return []
return [c for c in dict.fromkeys(s) if not keys or c in keys]
class Bank:
def __init__(self, items):
self.items = [(norm(i["q"]), i["a"]) for i in items if i.get("q") and i.get("a")]
self.exact = dict(self.items)
def find(self, title):
"""返回题库里的答案原文,没命中返回空串。"""
t = norm(title)
if not t:
return ""
if t in self.exact:
return self.exact[t]
for q, a in self.items:
if min(len(q), len(t)) >= MIN_CONTAIN and (q in t or t in q):
return a
q, a = max(self.items, key=lambda x: difflib.SequenceMatcher(None, t, x[0]).ratio(), default=("", ""))
return a if q and difflib.SequenceMatcher(None, t, q).ratio() >= FUZZY else ""
def load(folder):
"""读目录下全部 *.json 合并成一个题库;目录不存在就是空题库。"""
items = []
for f in sorted(Path(folder).glob("*.json")):
items += json.loads(f.read_text(encoding="utf-8"))
return Bank(items)
def ask(part, cfg):
"""问大模型一批题,返回 {题号: 原始答案}。"""
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"), q.get("type") or "单选", q.get("title") or "", opts or "无"))
content = llm_chat(
[{"role": "system", "content": cfg.get("prompt") or SYSTEM}, {"role": "user", "content": "\n\n".join(lines)}],
cfg,
max_tokens=1600,
)
m = re.search(r"\{.*\}", content.replace("```json", "").replace("```", ""), re.S)
data = json.loads(m.group(0)) if m else {}
data = data.get("answers") if isinstance(data.get("answers"), dict) else data
return {str(k): v for k, v in data.items()}
def llm_fill(key, miss, out):
cfg = load_settings()["llm"]
if not (cfg.get("api_key") and cfg.get("base_url")):
runtime.log(key, "题库没命中 %d 题,且还没配置大模型(右上角「设置」),这些题留空" % len(miss), level="warn")
return
before = len(out)
for i in range(0, len(miss), BATCH):
part = miss[i : i + BATCH]
try:
got = ask(part, cfg)
except Exception as e:
runtime.log(key, "AI 答题第 %d 批失败:%s" % (i // BATCH + 1, str(e)[:160]), level="warn")
continue
for q in part:
a = letters(got.get(str(q.get("no"))), q.get("options"))
if a:
out[str(q.get("no"))] = {"answer": a, "source": "llm"}
runtime.log(key, "AI 补答 %d/%d 题(答案仅供参考,未核对)" % (len(out) - before, len(miss)))
def answer(key, questions, bank=None, use_llm=True):
"""题库优先,没命中的走大模型。日志统一走 runtime.log(key, ...)。"""
out, miss = {}, []
for q in questions:
a = letters(bank.find(q.get("title")), q.get("options")) if bank else []
if a:
out[str(q.get("no"))] = {"answer": a, "source": "bank"}
else:
miss.append(q)
runtime.log(key, "题库命中 %d/%d 题" % (len(out), len(questions)))
if miss and use_llm:
llm_fill(key, miss, out)
return out
+12
View File
@@ -0,0 +1,12 @@
"""全局路径常量:仓库根、data、static。
平台专属的地址 / 常量放在各自的插件目录(如 plugins/guanyixue/config.py)。
"""
import os
# 包内路径 -> 项目根目录:data / static / script 这些运行时资源都留在仓库根
HERE = os.path.dirname(os.path.abspath(__file__)) # .../study_tool/config
PKG = os.path.dirname(HERE) # .../study_tool
ROOT = os.path.dirname(os.path.dirname(PKG)) # 仓库根
DATA = os.path.join(ROOT, "data")
STATIC_DIR = os.path.join(ROOT, "static")
+73
View File
@@ -0,0 +1,73 @@
"""系统设置(大模型 + 学习方式)读写。"""
from study_tool import database
# ------------------------------------------------------------------ settings
SETTINGS_NAME = "llm"
DEFAULT_LLM = {
"base_url": "https://api.openai.com/v1",
"api_key": "",
"model": "gpt-4o-mini",
"temperature": 0.2,
"timeout": 120,
"prompt": "",
}
# 学习方式:fast = 只调 study/log 接口,不开浏览器;normal = 真播视频(可多路并发)
DEFAULT_STUDY = {
"mode": "fast",
"parallel": 2,
}
# 故障推送:渠道插件(plugins/*/plugin.json 里 kind=notify)读这段配置
DEFAULT_NOTIFY = {
"enabled": True,
"base_url": "https://corn.hanhan.ltd",
"app_key": "",
"app_secret": "",
"channel_name": "",
"on_error": True, # 运行 / 课程异常
"on_login": True, # 登录失败 / 会话失效且无法续期
"on_progress": True, # 学习进度异常
"on_retry": True, # 同一原因反复重试
"retry_threshold": 3, # 重试几次算故障
"cooldown_min": 10, # 同一账号同一类型的推送冷却
}
def load_settings():
data = database.get_setting(SETTINGS_NAME) or {}
llm = dict(DEFAULT_LLM)
study = dict(DEFAULT_STUDY)
notify = dict(DEFAULT_NOTIFY)
if isinstance(data, dict):
raw = data.get("llm")
if isinstance(raw, dict):
llm.update({k: v for k, v in raw.items() if k in DEFAULT_LLM and v is not None})
st = data.get("study")
if isinstance(st, dict):
study.update({k: v for k, v in st.items() if v is not None})
nt = data.get("notify")
if isinstance(nt, dict):
notify.update({k: v for k, v in nt.items() if k in DEFAULT_NOTIFY and v is not None})
study["mode"] = study.get("mode") if study.get("mode") in ("fast", "normal") else "fast"
try:
study["parallel"] = max(1, min(int(study.get("parallel") or 2), 5))
except (TypeError, ValueError):
study["parallel"] = 2
notify["enabled"] = bool(notify.get("enabled"))
for flag in ("on_error", "on_login", "on_progress", "on_retry"):
notify[flag] = bool(notify.get(flag))
try:
notify["retry_threshold"] = max(2, min(int(notify.get("retry_threshold") or 3), 10))
except (TypeError, ValueError):
notify["retry_threshold"] = 3
try:
notify["cooldown_min"] = max(1, min(int(notify.get("cooldown_min") or 10), 1440))
except (TypeError, ValueError):
notify["cooldown_min"] = 10
return {"llm": llm, "study": study, "notify": notify}
def save_settings(data):
database.set_setting(SETTINGS_NAME, {"llm": data.get("llm") or {},
"study": data.get("study") or {},
"notify": data.get("notify") or {}})
+387
View File
@@ -0,0 +1,387 @@
"""SQLite 存储层(SQLAlchemy ORM)。
表
settings 系统配置(name -> JSON),目前只有 llm
kv 通用缓存(任意 key -> JSON),例如 StudyClient 试出来的接口前缀
accounts 账号 / 密码 / 会话 cookie / 课程缓存 / 勾选 / 开关
login_log 每次登录尝试(平台原话)
run_log 每个账号(key)的运行日志
run_state 运行状态快照,进程重启后 /api/runs 还能看到上次跑到哪
snapshots 课程页原始 HTML(站点改版时定位选择器用)
并发模型
* WAL + busy_timeout=15s:读写互不阻塞,写锁竞争自动重试
* scoped_session:每个线程一条 Session,ThreadPoolExecutor 里多线程写日志不会串
* 本进程内的写操作再用一把 RLock 串起来,避免多线程 commit 抖动(读不加锁)
"""
import os
import threading
import time
from sqlalchemy import create_engine, delete, event, func, select
from sqlalchemy.orm import scoped_session, sessionmaker
from study_tool.config import DATA
from study_tool.database.models import (Account, Base, Kv, LoginLog, RunLog,
RunState, Setting, Snapshot)
DATA_DIR = os.environ.get("DATA_DIR") or DATA # 仓库根目录的 data/,路径只在 config 里算一次
DB_PATH = os.environ.get("DB_PATH") or os.path.join(DATA_DIR, "study.db")
# 每个账号保留的日志行数,超出就丢最旧的(远程长期挂机,不能无限涨)
RUN_LOG_KEEP = int(os.environ.get("RUN_LOG_KEEP", "2000") or 2000)
# 页面快照保留条数
SNAPSHOT_KEEP = int(os.environ.get("SNAPSHOT_KEEP", "200") or 200)
os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True)
engine = create_engine("sqlite:///" + os.path.abspath(DB_PATH),
connect_args={"check_same_thread": False, "timeout": 15})
@event.listens_for(engine, "connect")
def _sqlite_pragmas(dbapi_conn, _record):
cur = dbapi_conn.cursor()
cur.execute("PRAGMA journal_mode=WAL")
cur.execute("PRAGMA synchronous=NORMAL")
cur.execute("PRAGMA busy_timeout=15000")
cur.close()
Session = scoped_session(sessionmaker(bind=engine, expire_on_commit=False))
_lock = threading.RLock() # 只保护“写”
_write_count = {}
# ------------------------------------------------------------------ 基础
def init():
"""建库建表(幂等,启动时调用一次)。"""
Base.metadata.create_all(engine)
def db_info():
try:
size = os.path.getsize(DB_PATH) if os.path.exists(DB_PATH) else 0
except OSError:
size = 0
models = {"accounts": Account, "run_log": RunLog, "login_log": LoginLog,
"snapshots": Snapshot, "run_state": RunState, "kv": Kv, "settings": Setting}
counts = {}
with Session() as s:
for name, model in models.items():
counts[name] = s.scalar(select(func.count()).select_from(model))
return {"path": DB_PATH, "size": size, "counts": counts}
init()
# ------------------------------------------------------------------ settings
def get_setting(name, default=None):
with Session() as s:
row = s.get(Setting, name)
return row.value if row is not None else default
def set_setting(name, value):
with _lock, Session() as s:
row = s.get(Setting, name)
if row is None:
row = Setting(name=name)
s.add(row)
row.value = value
row.updated = time.time()
s.commit()
# ------------------------------------------------------------------ kv 缓存
def kv_get(key, default=None):
with Session() as s:
row = s.get(Kv, key)
return row.value if row is not None else default
def kv_set(key, value):
with _lock, Session() as s:
row = s.get(Kv, key)
if row is None:
row = Kv(key=key)
s.add(row)
row.value = value
row.updated = time.time()
s.commit()
def kv_del(key):
with _lock, Session() as s:
s.execute(delete(Kv).where(Kv.key == key))
s.commit()
def kv_all():
with Session() as s:
return {row.key: row.value for row in s.scalars(select(Kv))}
# ------------------------------------------------------------------ accounts
def _acc_row(row):
"""ORM 对象 -> 前端用的记录(不含密码)。"""
return {
"id": row.id,
"username": row.username or "",
"platform": row.platform or "",
"note": row.note or "",
"user": row.user or "",
"cookies": row.cookies or [],
"courses": row.courses or [],
"selected": row.selected or [],
"status": row.status or "idle",
"lastError": row.last_error or "",
"lastErrorAt": row.last_error_at or 0,
"optQuiz": bool(row.opt_quiz),
"optFeedback": bool(row.opt_feedback),
"loginAt": row.login_at or 0,
"created": row.created or 0,
"updated": row.updated or 0,
"hasPassword": bool(row.password),
}
def account_list():
with Session() as s:
rows = s.scalars(select(Account).order_by(Account.created, Account.id)).all()
return [_acc_row(row) for row in rows]
def account_get(account_id):
if not account_id:
return None
with Session() as s:
row = s.get(Account, str(account_id))
return _acc_row(row) if row is not None else None
def account_password(account_id):
if not account_id:
return ""
with Session() as s:
row = s.get(Account, str(account_id))
return (row.password or "") if row is not None else ""
def account_exists(account_id):
if not account_id:
return False
with Session() as s:
return s.get(Account, str(account_id)) is not None
def account_upsert(rec):
"""新增/更新账号。rec['password'] 为空(None)时保留库里原有密码。
明文密码要留在库里:会话失效时后台要能自己重登,不能等用户回来输密码。
"""
now = time.time()
aid = str(rec.get("id") or "").strip()
if not aid:
aid = "a%s%04d" % (int(now * 1000), int(time.time() * 10000) % 10000)
with _lock, Session() as s:
row = s.get(Account, aid)
if row is None:
row = Account(id=aid, created=float(rec.get("created") or now))
s.add(row)
row.username = rec.get("username") or ""
platform = (rec.get("platform") or "").strip()
if platform:
row.platform = platform
pwd = rec.get("password")
if pwd is not None:
row.password = str(pwd)
row.note = rec.get("note") or ""
row.user = rec.get("user") or ""
row.cookies = rec.get("cookies") or []
row.courses = rec.get("courses") or []
row.selected = rec.get("selected") or []
row.status = rec.get("status") or "idle"
row.last_error = rec.get("lastError") or ""
row.last_error_at = float(rec.get("lastErrorAt") or 0)
row.opt_quiz = 1 if rec.get("optQuiz") is True else 0
row.opt_feedback = 0 if rec.get("optFeedback") is False else 1
row.login_at = float(rec.get("loginAt") or 0)
row.created = float(rec.get("created") or row.created or now)
row.updated = now
s.commit()
return aid
def account_save_login(account_id, user, cookies, courses):
"""登录成功后把新的会话写回账号(服务端自己记,不依赖前端回传)。"""
rec = account_get(account_id)
if not rec:
return
courses = courses or []
plans = {c.get("plan_id") for c in courses}
rec.update({
"user": user or rec.get("user") or "",
"cookies": cookies or [],
"courses": courses,
"selected": [p for p in (rec.get("selected") or []) if p in plans],
"status": "logged_in",
"lastError": "",
"lastErrorAt": 0,
"loginAt": time.time(),
})
rec.pop("hasPassword", None)
account_upsert(rec)
def account_save_cookies(account_id, cookies):
"""只更新会话:学习中掉线重登后写回新会话,不动课程缓存和勾选。"""
rec = account_get(account_id)
if not rec:
return
rec.update({"cookies": cookies or [], "status": "logged_in", "lastError": "", "lastErrorAt": 0})
rec.pop("hasPassword", None)
account_upsert(rec)
def account_delete(account_id):
"""删账号,连同它的运行状态、课程日志、登录记录一起删。
登录日志的 key 和 login_log.username 记的是用户名(手机号),不是账号 id,
两边都要清,否则删完账号旧日志还留在页面上。
"""
aid = str(account_id or "")
acc = account_get(aid) or {}
uname = str(acc.get("username") or "")
with _lock, Session() as s:
s.execute(delete(Account).where(Account.id == aid))
s.execute(delete(RunState).where(RunState.key == aid))
for key in (aid, uname):
if key:
s.execute(delete(RunLog).where(RunLog.key == key))
s.execute(delete(LoginLog).where(LoginLog.username == key))
s.commit()
# ------------------------------------------------------------------ 登录记录
def login_log_add(username, ok, code, elapsed, stage="", error="", ts=None):
with _lock, Session() as s:
s.add(LoginLog(ts=ts or time.strftime("%Y-%m-%d %H:%M:%S"),
username=username, ok=1 if ok else 0, code=str(code or ""),
elapsed=float(elapsed or 0), stage=str(stage or ""),
error=str(error or "").replace("\n", " ")[:500]))
s.commit()
def login_log_tail(n=40):
"""返回旧版文本行格式(ts\\t用户名\\tOK\\tcode\\t耗时\\tstage\\t错误),前端不用改。"""
n = max(1, min(int(n or 40), 500))
with Session() as s:
rows = s.scalars(select(LoginLog).order_by(LoginLog.id.desc()).limit(n)).all()
return ["%s\t%s\t%s\t%s\t%.1fs\t%s\t%s" % (
r.ts, r.username, "OK" if r.ok else "FAIL", r.code,
r.elapsed or 0, r.stage or "", r.error or "") for r in reversed(rows)]
# ------------------------------------------------------------------ 运行日志
def run_log_add(key, line, ts=None):
key = str(key or "")
with _lock, Session() as s:
s.add(RunLog(key=key, ts=ts or time.time(), line=line))
s.commit()
n = _write_count.get("run_log", 0) + 1
_write_count["run_log"] = n
if n % 200 == 0: # 每 200 行清一次,避免每行都跑一遍 DELETE
keep = (select(RunLog.id).where(RunLog.key == key)
.order_by(RunLog.id.desc()).limit(RUN_LOG_KEEP))
s.execute(delete(RunLog).where(RunLog.key == key, RunLog.id.not_in(keep)))
s.commit()
def run_log_tail(key, n=200):
n = max(1, min(int(n or 200), RUN_LOG_KEEP))
with Session() as s:
rows = s.scalars(select(RunLog.line).where(RunLog.key == str(key))
.order_by(RunLog.id.desc()).limit(n)).all()
return list(reversed(rows))
def run_log_keys():
with Session() as s:
rows = s.execute(select(RunLog.key, func.count(RunLog.id), func.max(RunLog.ts))
.group_by(RunLog.key)).all()
return [{"key": key, "lines": count, "updated": updated} for key, count, updated in rows]
def run_log_clear(key):
with _lock, Session() as s:
s.execute(delete(RunLog).where(RunLog.key == str(key)))
s.commit()
# ------------------------------------------------------------------ 运行状态
def run_state_set(key, state):
st = dict(state or {})
st.pop("log", None) # 日志单独进 run_log 表,不在这里重复一份
with _lock, Session() as s:
row = s.get(RunState, str(key))
if row is None:
row = RunState(key=str(key))
s.add(row)
row.state = st
row.updated = time.time()
s.commit()
def run_state_all():
with Session() as s:
return {row.key: row.state for row in s.scalars(select(RunState))
if isinstance(row.state, dict)}
def run_state_delete(key):
with _lock, Session() as s:
s.execute(delete(RunState).where(RunState.key == str(key or "")))
s.commit()
# ------------------------------------------------------------------ 页面快照
def snapshot_add(plan_id, project_id, url, html):
with _lock, Session() as s:
row = Snapshot(plan_id=str(plan_id or ""), project_id=str(project_id or ""),
url=url or "", html=html or "", created=time.time())
s.add(row)
s.flush()
sid = row.id
keep = select(Snapshot.id).order_by(Snapshot.id.desc()).limit(SNAPSHOT_KEEP)
s.execute(delete(Snapshot).where(Snapshot.id.not_in(keep)))
s.commit()
return sid
def snapshot_get(sid):
with Session() as s:
row = s.get(Snapshot, int(sid))
if row is None:
return None
return {c.name: getattr(row, c.name) for c in row.__table__.columns}
def snapshot_list(limit=50):
limit = max(1, min(int(limit or 50), 500))
with Session() as s:
rows = s.execute(select(Snapshot.id, Snapshot.plan_id, Snapshot.project_id,
Snapshot.url, Snapshot.created,
func.length(Snapshot.html).label("size"))
.order_by(Snapshot.id.desc()).limit(limit)).all()
return [{"id": r.id, "plan_id": r.plan_id, "project_id": r.project_id,
"url": r.url, "created": r.created, "size": r.size} for r in rows]
+102
View File
@@ -0,0 +1,102 @@
"""SQLAlchemy ORM 模型:账号 / 设置 / 登录记录 / 运行日志 / 运行状态 / 快照。"""
from sqlalchemy import Float, Index, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.types import JSON
class Base(DeclarativeBase):
pass
class Setting(Base):
"""系统配置(name -> JSON),目前只有 llm。"""
__tablename__ = "settings"
name: Mapped[str] = mapped_column(String, primary_key=True)
value: Mapped[dict] = mapped_column(JSON, nullable=False)
updated: Mapped[float] = mapped_column(Float, nullable=False)
class Kv(Base):
"""通用缓存(任意 key -> JSON),例如 StudyClient 试出来的接口前缀。"""
__tablename__ = "kv"
key: Mapped[str] = mapped_column(String, primary_key=True)
value: Mapped[dict] = mapped_column(JSON)
updated: Mapped[float] = mapped_column(Float, nullable=False)
class Account(Base):
"""账号 / 密码 / 会话 cookie / 课程缓存 / 勾选 / 开关。"""
__tablename__ = "accounts"
id: Mapped[str] = mapped_column(String, primary_key=True)
username: Mapped[str] = mapped_column(String, default="")
platform: Mapped[str] = mapped_column(String, default="")
password: Mapped[str] = mapped_column(String, default="")
note: Mapped[str] = mapped_column(String, default="")
user: Mapped[str] = mapped_column(String, default="")
cookies: Mapped[list] = mapped_column(JSON, default=list)
courses: Mapped[list] = mapped_column(JSON, default=list)
selected: Mapped[list] = mapped_column(JSON, default=list)
status: Mapped[str] = mapped_column(String, default="idle")
last_error: Mapped[str] = mapped_column(String, default="")
last_error_at: Mapped[float] = mapped_column(Float, default=0)
opt_quiz: Mapped[int] = mapped_column(Integer, default=0)
opt_feedback: Mapped[int] = mapped_column(Integer, default=1)
login_at: Mapped[float] = mapped_column(Float, default=0)
created: Mapped[float] = mapped_column(Float, default=0)
updated: Mapped[float] = mapped_column(Float, default=0)
class LoginLog(Base):
"""每次登录尝试(平台原话)。"""
__tablename__ = "login_log"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts: Mapped[str] = mapped_column(String)
username: Mapped[str] = mapped_column(String)
ok: Mapped[int] = mapped_column(Integer)
code: Mapped[str] = mapped_column(String)
elapsed: Mapped[float] = mapped_column(Float)
stage: Mapped[str] = mapped_column(String)
error: Mapped[str] = mapped_column(String)
class RunLog(Base):
"""每个账号(key)的运行日志。"""
__tablename__ = "run_log"
__table_args__ = (Index("idx_run_log_key", "key", "id"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key: Mapped[str] = mapped_column(String, nullable=False)
ts: Mapped[float] = mapped_column(Float)
line: Mapped[str] = mapped_column(String)
class RunState(Base):
"""运行状态快照,进程重启后 /api/runs 还能看到上次跑到哪。"""
__tablename__ = "run_state"
key: Mapped[str] = mapped_column(String, primary_key=True)
state: Mapped[dict] = mapped_column(JSON)
updated: Mapped[float] = mapped_column(Float)
class Snapshot(Base):
"""课程页原始 HTML(站点改版时定位选择器用)。"""
__tablename__ = "snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
plan_id: Mapped[str] = mapped_column(String)
project_id: Mapped[str] = mapped_column(String)
url: Mapped[str] = mapped_column(String)
html: Mapped[str] = mapped_column(String)
created: Mapped[float] = mapped_column(Float)
+145 -35
View File
@@ -1,12 +1,12 @@
"""学习引擎:账号级编排(课程循环、会话续期、按平台分发)。
平台自己的学习流程在 platforms/ 里(旧版 jspx / 新版 study),引擎不认识具体站点。
平台自己的学习流程在 plugins/ 里(各平台自己实现),引擎不认识具体站点。
日志统一走 runtime.log()。
"""
import time
from study_tool import store
from study_tool.config import *
from study_tool import database, notify
from study_tool.platforms import registry
from study_tool.platforms.errors import SessionExpired
from study_tool.runtime import RUNS, RUNS_LOCK, log, set_state
@@ -14,10 +14,31 @@ from study_tool.runtime import RUNS, RUNS_LOCK, log, set_state
# ------------------------------------------------------------------ study engine
def _snap(key):
"""当前运行状态里的进度快照:学完一门课后拿它和开课前对比。"""
st = RUNS.get(key) or {}
return {
"done": st.get("done"),
"total": st.get("total"),
"progress": st.get("progress"),
"course": st.get("course"),
}
def study_worker(key, username, cookies, courses, workers, cap, quiz_key,
enable_quiz=False, enable_feedback=True, password="",
mode="fast", parallel=2):
def study_worker(
key,
username,
cookies,
courses,
workers,
cap,
quiz_key,
enable_quiz=False,
enable_feedback=True,
password="",
mode="fast",
parallel=2,
):
"""Run each selected course in sequence for one account.
enable_quiz 默认 False —— 测验答题正确率无法保证,可能因不及格卡住「完成」,故默认跳过。
@@ -26,25 +47,57 @@ def study_worker(key, username, cookies, courses, workers, cap, quiz_key,
"""
try:
set_state(key, status="discovering", log=[], done=0, total=0, started=time.time())
notify.reset(key) # 新一轮:重试计数清零
failed = []
log(key, "=== 开始:%s(共 %d 门课程)" % (username, len(courses)))
log(key, "设置:自动交测验=%s,自动交反馈表=%s,学习方式=%s" %
("开" if enable_quiz else "关", "开" if enable_feedback else "关",
"快速(只调接口)" if mode != "normal" else "常规(真播,%d 路并发)" % parallel))
log(
key,
"设置:自动交测验=%s,自动交反馈表=%s,学习方式=%s"
% (
"开" if enable_quiz else "关",
"开" if enable_feedback else "关",
"快速(只调接口)" if mode != "normal" else "常规(真播,%d 路并发)" % parallel,
),
)
for ci, c in enumerate(courses, 1):
with RUNS_LOCK:
if (RUNS.get(key) or {}).get("status") == "stopped":
log(key, "已按要求停止")
return
log(key, "--- [%d/%d] %s" % (ci, len(courses), c.get("name") or c["plan_id"]))
set_state(key, course=c.get("name", ""))
# 每门课重新计数:上一门课的 done / total / progress 不能带进来做对比
set_state(key, course=c.get("name", ""), done=0, total=0, progress="")
name = c.get("name") or ""
before = _snap(key)
try:
run_one_course(key, username, cookies, c["plan_id"], c["project_id"], workers, cap,
quiz_key, enable_quiz, enable_feedback, c.get("name") or "", password,
mode, parallel)
run_one_course(
key,
username,
cookies,
c["plan_id"],
c["project_id"],
workers,
cap,
quiz_key,
enable_quiz,
enable_feedback,
name,
password,
mode,
parallel,
)
# 学完再体检一次:学完仍未完成 / 进度没推进都算故障
notify.check_progress(key, name, before, _snap(key))
except Exception as e:
failed.append(c.get("name") or c["plan_id"])
log(key, "课程处理异常:%s" % e)
notify.fault(
"course",
key,
"课程处理异常",
"课程:%s\n异常:%s" % (name or c["plan_id"], str(e)[:300]),
{"course": name},
)
set_state(key, status="finished", finished_at=time.time())
if failed:
log(key, "=== 结束:%d/%d 门课程异常(%s)" % (len(failed), len(courses), "、".join(failed)[:120]))
@@ -53,13 +106,27 @@ def study_worker(key, username, cookies, courses, workers, cap, quiz_key,
except Exception as e:
log(key, "!! 运行异常:%s" % e)
set_state(key, status="error", error=str(e)[:300])
notify.fault("run", key, "运行异常", str(e)[:300])
def run_one_course(key, username, cookies, plan, project, workers, cap, quiz_key,
enable_quiz=False, enable_feedback=True, name="", password="",
mode="fast", parallel=2):
def run_one_course(
key,
username,
cookies,
plan,
project,
workers,
cap,
quiz_key,
enable_quiz=False,
enable_feedback=True,
name="",
password="",
mode="fast",
parallel=2,
):
# 账号添加时选的平台决定登录 / 发现走哪套实现;新增平台不用改这里
account = store.account_get(key) or {}
account = database.account_get(key) or {}
adapter = registry.get(account.get("platform"))
if adapter is None or adapter.discover is None:
raise RuntimeError("平台 %s 没有可用的课程发现实现" % (account.get("platform") or "?"))
@@ -71,6 +138,7 @@ def run_one_course(key, username, cookies, plan, project, workers, cap, quiz_key
except SessionExpired:
# 先试无密码续期(CASTGC 通常还有效),不行再用浏览器里存的账号密码重登
log(key, "会话失效,尝试无密码续期…")
notify.retry(key, "会话续期", "会话失效后自动续期 / 重登")
renewed = adapter.renew_session(s) if adapter.renew_session else False
d = None
if renewed:
@@ -80,34 +148,76 @@ def run_one_course(key, username, cookies, plan, project, workers, cap, quiz_key
d = None
if d is None:
if not password or adapter.login is None:
notify.fault(
"login",
key,
"会话失效且无法续期",
"课程:%s\n账号没有保存密码或平台不支持密码登录,需要手动重新登录。" % (name or plan),
{"course": name},
)
raise
log(key, "续期失败,用账号密码重新登录…")
s, _ = adapter.login(username, password)
d = adapter.discover(s, plan, project, name)
try:
s, _ = adapter.login(username, password)
d = adapter.discover(s, plan, project, name)
except Exception as e:
notify.fault(
"login",
key,
"重新登录失败",
"课程:%s\n异常:%s" % (name or plan, str(e)[:300]),
{"course": name},
)
raise
# 新平台可能刚在 discover 里换到 study.dgjy.net 的会话(Middle-Token),存回去给后续课程用
set_state(key, course=d["course"], progress=d["progress"], user=d["user"],
cookies=(adapter.dump_cookies(s) if adapter.dump_cookies else []))
set_state(
key,
course=d["course"],
progress=d["progress"],
user=d["user"],
cookies=(adapter.dump_cookies(s) if adapter.dump_cookies else []),
)
log(key, "课程:%s | 进度 %s | %s" % (d["course"], d["progress"], d["info"]))
pf = registry.get(d.get("platform"))
gen = d.get("generation")
gen_label = {"legacy": "旧版页面", "spa": "新版页面"}.get(gen, "")
log(key, "平台:%s%s | 活动:课件 %d、反馈表 %d、测验 %d" %
((pf.label if pf else d.get("platform", "?")),
(" · " + gen_label) if gen_label else "",
len(d["courseware"]), len(d["feedback"]), len(d["test"])))
if gen == "spa":
log(key, "新版课程 ID:%s%s" %
(d.get("train_id") or "?",
(" | 页面:" + (d.get("page_url") or "")[:110]) if d.get("page_url") else ""))
log(
key,
"平台:%s%s | 活动:课件 %d、反馈表 %d、测验 %d"
% (
(pf.label if pf else d.get("platform", "?")),
(" · " + gen_label) if gen_label else "",
len(d["courseware"]),
len(d["feedback"]),
len(d["test"]),
),
)
if not d["courseware"] and not d["feedback"] and not d["test"]:
notify.fault(
"progress",
key,
"课程没有可识别的活动",
"课程:%s\n课件 / 反馈表 / 测验都是 0,多半是页面改版或课程没分配到位。" % (d["course"] or name),
{"course": d["course"] or name},
)
raise RuntimeError("课程里没有任何可识别的活动(课件/反馈表/测验都是 0)")
# 平台插件:按 course["platform"] 取实现;新增平台不用改这里
if pf is None or pf.study is None:
raise RuntimeError("平台 %s 没有可用的学习实现" % d.get("platform"))
pf.study(key, s, d, workers=workers, cap=cap, enable_quiz=enable_quiz,
enable_feedback=enable_feedback, mode=mode, parallel=parallel,
quiz_key=quiz_key, cookies=cookies, plan=plan, project=project, name=name)
pf.study(
key,
s,
d,
workers=workers,
cap=cap,
enable_quiz=enable_quiz,
enable_feedback=enable_feedback,
mode=mode,
parallel=parallel,
quiz_key=quiz_key,
cookies=cookies,
plan=plan,
project=project,
name=name,
)
@@ -1,11 +1,10 @@
"""大模型调用(OpenAI 兼容 /chat/completions)。"""
import time
"""大模型调用(OpenAI 兼容 /chat/completions)。
后续所有大模型相关代码都放在这个目录里:新增供应商 / 提示词 / 重试策略时,
在这里加模块,不要让路由或平台插件直接拼 HTTP 请求。
"""
import requests
from study_tool.webapp import app
from study_tool.config import *
from study_tool.settings_store import *
def llm_chat(messages, cfg, max_tokens=None, json_mode=True):
"""OpenAI 兼容的 /chat/completions。不支持 response_format 的服务端会自动去掉重试一次。"""
@@ -40,24 +39,3 @@ def llm_chat(messages, cfg, max_tokens=None, json_mode=True):
if not content:
raise RuntimeError("LLM 返回空内容(可能被 max_tokens 截断,或被网关过滤)")
return content
@app.post("/api/llm/test")
def api_llm_test(b: LlmTestIn):
cfg = dict(load_settings()["llm"])
if b.base_url.strip():
cfg["base_url"] = b.base_url.strip()
if b.model.strip():
cfg["model"] = b.model.strip()
if b.api_key.strip():
cfg["api_key"] = b.api_key.strip()
cfg["timeout"] = max(10, min(120, int(b.timeout or 60)))
if not cfg.get("api_key"):
return {"ok": False, "error": "没有 api_key"}
t0 = time.time()
try:
reply = llm_chat([{"role": "user", "content": "只回复两个字符:ok"}], cfg,
max_tokens=8, json_mode=False)
return {"ok": True, "ms": int((time.time() - t0) * 1000), "reply": (reply or "")[:80]}
except Exception as e:
return {"ok": False, "error": str(e)[:300]}
+33 -42
View File
@@ -1,24 +1,22 @@
"""HTTP 请求模型(Pydantic)。"""
from pydantic import BaseModel
# ------------------------------------------------------------------------ models
# models
class LoginIn(BaseModel):
username: str = ""
password: str = ""
account_id: str = "" # 只给账号 id 时,用户名/密码从 SQLite 里取,密码不出库
platform: str = "guanyixue" # 没传 account_id 时按它选平台
class AccountsIn(BaseModel):
accounts: list = []
account_id: str = "" # 只给账号 id 时,用户名/密码从 SQLite 里取,密码不出库
platform: str = "" # 没传 account_id 时按它选平台
class AccountIn(BaseModel):
id: str = ""
username: str = ""
platform: str = "guanyixue" # 添加账号时选的平台
password: str | None = None # 不传 = 保留库里已有的密码
platform: str = "" # 添加账号时选的平台
password: str | None = None # 不传 = 保留库里已有的密码
note: str = ""
user: str = ""
cookies: list = []
@@ -35,7 +33,7 @@ class AccountIn(BaseModel):
class CookiesIn(BaseModel):
cookies: list
platform: str = "guanyixue"
platform: str = ""
class DiscoverIn(BaseModel):
@@ -43,22 +41,22 @@ class DiscoverIn(BaseModel):
plan_id: str
project_id: str
name: str = ""
platform: str = "guanyixue"
platform: str = ""
class StudyIn(BaseModel):
key: str
username: str = ""
password: str = "" # 仅用于会话失效时自动重登,不落盘
password: str = "" # 仅用于会话失效时自动重登,不落盘
cookies: list = []
courses: list = [] # [{name, plan_id, project_id}]
courses: list = [] # [{name, plan_id, project_id}]
plan_id: str = ""
project_id: str = ""
workers: int = 40
cap_minutes: int = 90
quiz_key: dict | None = None
enable_quiz: bool = False # 默认关闭:答题正确率无法保证
enable_feedback: bool = True # 反馈表无对错,默认开启
enable_quiz: bool = False # 默认关闭:答题正确率无法保证
enable_feedback: bool = True # 反馈表无对错,默认开启
class StopIn(BaseModel):
@@ -68,44 +66,37 @@ class StopIn(BaseModel):
class CaptureIn(BaseModel):
key: str
cookies: list = []
minutes: int = 10 # 抓包最长分钟数(1~30)
minutes: int = 10 # 抓包最长分钟数(1~30)
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 NotifyCfgIn(BaseModel):
"""故障推送配置;空串 / 0 / None 都表示「不改」,避免只保存模型设置时被清空。"""
class QuizSubmitIn(BaseModel):
cookies: list = []
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
base_url: str = ""
app_key: str = ""
app_secret: str = "" # 留空 = 保留原密钥,前端不回显明文
channel_name: str = ""
enabled: bool | None = None
on_error: bool | None = None
on_login: bool | None = None
on_progress: bool | None = None
on_retry: bool | None = None
retry_threshold: int = 0
cooldown_min: int = 0
clear_app_secret: bool = False # True = 清掉已保存的 app_secret
class LlmCfgIn(BaseModel):
base_url: str = ""
api_key: str = ""
model: str = ""
temperature: float = 0.2
temperature: float = 0.2 # 0 = 不改
timeout: int = 120
clear_api_key: bool = False # True = 清掉已保存的 api_key
prompt: str = ""
study_mode: str = "" # "" = 不改;fast / normal
study_parallel: int = 0 # 0 = 不改;1~5
study_mode: str = "" # "" = 不改;fast / normal
study_parallel: int = 0 # 0 = 不改;1~5
notify: NotifyCfgIn | None = None # 不传 = 不动推送配置
class LlmTestIn(BaseModel):
+204
View File
@@ -0,0 +1,204 @@
"""系统通知中心:故障上报 → 推送渠道插件。
渠道插件放在 `plugins/<名字>/`:`plugin.json` 里写 `"kind": "notify"`,
`main.py` 实现一个 `Channel` 子类并在模块末尾 `NOTIFY = notify.register(X())`。
引擎 / 路由 / 平台插件只调本模块的 `fault()` / `retry()`,不 import 任何具体推送服务;
换渠道 = 换一个插件。
故障类型(kind)
run 整体运行异常
course 单门课程处理异常
login 登录失败 / 会话失效且无法续期
progress 学习进度异常(学完仍未完成、进度没推进、没有可识别活动)
retry 学习过程中同一原因反复重试
节流:同一个 (类型, 账号) 在 `cooldown_min` 分钟内只推一次;`retry` 只在计数
刚达到 `retry_threshold` 时推一次,避免一屏一屏地刷推送。
"""
import re
import threading
import time
from study_tool import database
from study_tool.config.settings import load_settings
from study_tool.runtime import log
KINDS = {
"run": "运行异常",
"course": "课程异常",
"login": "登录/会话异常",
"progress": "学习进度异常",
"retry": "多次重试",
}
# 类型 → 设置项里的开关字段
SWITCH = {
"run": "on_error",
"course": "on_error",
"login": "on_login",
"progress": "on_progress",
"retry": "on_retry",
}
_CHANNELS = {} # 渠道 id -> Channel
_COOLDOWN = {} # (kind, key) -> 上次推送时间
_RETRY = {} # (key, what) -> 本轮重试次数
_LOCK = threading.Lock()
class Channel:
"""推送渠道:一个插件一个渠道,`send()` 失败就抛异常。"""
id = ""
label = ""
def send(self, title, content, context=None):
raise NotImplementedError
# ------------------------------------------------------------------ 注册表
def register(channel):
_CHANNELS[channel.id] = channel
return channel
def unregister(cid):
return _CHANNELS.pop(cid or "", None)
def get(cid):
return _CHANNELS.get(cid or "")
def all():
return sorted(_CHANNELS.values(), key=lambda c: c.id)
# ------------------------------------------------------------------ 发送
def _account(key):
acc = database.account_get(key) or {}
return acc.get("username") or acc.get("user") or key
def send(title, content, context=None):
"""直接推一条(测试推送也走这里);返回 (ok, msg)。"""
channels = all()
if not channels:
return False, "没有可用的推送渠道:plugins/ 下缺少 kind=notify 的插件"
errors = []
for ch in channels:
try:
ch.send(title, content, context)
return True, "已通过「%s」推送" % (ch.label or ch.id)
except Exception as e:
errors.append("%s:%s" % (ch.label or ch.id, e))
return False, ";".join(errors) or "推送失败"
def fault(kind, key, title, content, context=None):
"""上报一个系统故障;真的推出去了返回 True。"""
cfg = load_settings()["notify"]
if not cfg.get("enabled", True) or not cfg.get(SWITCH.get(kind, ""), True):
return False
key = key or "system"
now = time.time()
cool = max(1, int(cfg.get("cooldown_min") or 10)) * 60
with _LOCK:
if now - _COOLDOWN.get((kind, key), 0) < cool:
return False
_COOLDOWN[(kind, key)] = now
ctx = dict(context or {})
ctx.update({"kind": kind, "key": key, "account": _account(key)})
ok, msg = send("【%s】%s" % (KINDS.get(kind, "系统故障"), title), _body(key, content, ctx), ctx)
log(key, "故障推送%s:%s(%s)" % ("成功" if ok else "失败", title, msg), level="warn")
if not ok:
# 没推出去就别占着整段冷却:留 1 分钟再试,否则一次网络抖动会把通知压掉十分钟
with _LOCK:
_COOLDOWN[(kind, key)] = now - cool + 60
return ok
def _body(key, content, context):
lines = ["账号:%s" % _account(key)]
if context.get("course"):
lines.append("课程:%s" % context["course"])
lines.append("时间:%s" % time.strftime("%Y-%m-%d %H:%M:%S"))
lines.append("")
lines.append(content or "")
return "\n".join(lines)
# ------------------------------------------------------------------ 重试计数
def retry(key, what, detail=""):
"""记一次重试;同一轮里同一原因累计到阈值就推一次。返回当前次数。"""
cfg = load_settings()["notify"]
if not cfg.get("enabled", True) or not cfg.get("on_retry", True):
return 0
key = key or "system"
with _LOCK:
n = _RETRY.get((key, what), 0) + 1
_RETRY[(key, what)] = n
limit = max(2, int(cfg.get("retry_threshold") or 3))
if n == limit:
fault(
"retry",
key,
"%s 连续重试 %d 次" % (what, n),
detail or "该环节反复重试,通常是会话失效、接口不稳定或平台风控,请人工看一眼。",
)
return n
def reset(key):
"""新一轮开始:清掉该账号的重试计数(冷却时间保留,避免刚推完又推)。"""
with _LOCK:
for k in [k for k in _RETRY if k[0] == key]:
del _RETRY[k]
# ------------------------------------------------------------------ 进度体检
def _ratio(text):
"""从「3/10 课时」「已完成 2/5 个活动」里取 (完成, 总数)。"""
m = re.search(r"(\d+)\s*/\s*(\d+)", str(text or ""))
return (int(m.group(1)), int(m.group(2))) if m else None
def check_progress(key, course, before, after):
"""课程学完后的进度体检:学完仍未完成 / 进度没动 → 推一次。"""
before, after = before or {}, after or {}
course = course or after.get("course") or ""
total, done = after.get("total"), after.get("done")
if total and done is not None and int(total) > 0 and int(done) < int(total):
return fault(
"progress",
key,
"课程学完仍未完成",
"课程:%s\n进度:%s/%s 个活动" % (course or "-", done, total),
{"course": course},
)
now, was = _ratio(after.get("progress")), _ratio(before.get("progress"))
if now and now[1] > 0 and now[0] < now[1]:
if was and now[0] <= was[0]:
return fault(
"progress",
key,
"学习进度没有推进",
"课程:%s\n进度:%s → %s" % (course or "-", before.get("progress"), after.get("progress")),
{"course": course},
)
return fault(
"progress",
key,
"课程学完仍未完成",
"课程:%s\n进度:%s" % (course or "-", after.get("progress")),
{"course": course},
)
return False
+7 -5
View File
@@ -3,17 +3,19 @@
这里放平台**运行时**:`Platform` 契约(`base.py`)、能力映射(`contract.py`)、
错误类型(`errors.py`)、注册表(`registry.py`)。
平台实现都放在插件目录 `pugins/<名字>/`(`pugin.json` + `main.py`,可以再拆实现模块),
由 `study_tool.pugins.load_plugins()` 扫描加载;新增或迁移插件都不用改这里的代码。
平台实现都放在插件目录 `plugins/<名字>/`(`plugin.json` + `main.py`,可以再拆实现模块),
由 `study_tool.plugins.load_plugins()` 扫描加载;新增或迁移插件都不用改这里的代码。
要是有多个插件共用的实现模块,才放回本目录。
路由 / 引擎不用改 —— 它们只认 `course["platform"]`。
"""
from study_tool.platforms import registry # noqa: F401 供 engine、路由使用
from study_tool.platforms import registry # noqa: F401 供 engine、路由使用
from study_tool.platforms.base import Platform # noqa: F401
def load_builtin():
"""加载 `pugins/` 下的全部插件(导入即注册)。幂等,app 启动时调用一次。"""
from study_tool.pugins import load_plugins
"""加载 `plugins/` 下的全部插件(导入即注册)。幂等,app 启动时调用一次。"""
from study_tool.plugins import load_plugins
load_plugins()
+13 -6
View File
@@ -14,8 +14,8 @@
PLATFORM = registry.register(Gdgxk())
新增平台 = 在 `platforms/` 下加一个这样的模块,再把它加进 `platforms/__init__.py`
的 `load_builtin()`。引擎只按 `course["platform"]` 去注册表取实现,不 import 具体平台。
新增平台 = 在 `plugins/<名字>/` 下加一个这样的 `main.py`(附 `plugin.json`),加载器扫目录自动 import。
引擎只按 `course["platform"]` 去注册表取实现,不 import 具体平台。
`entries` 声明登录入口 / 学习入口的 URL 片段,`registry.detect(url, html)` 用它识别
页面属于哪个平台;同一平台的不同代页面(如莞易学的旧版 / 新版)用 `course["generation"]`
@@ -36,17 +36,21 @@
discover(session, plan_id, project_id, name) -> 课程描述
renew_session(session) -> bool,无密码续期
profile(session) -> 显示名
quiz(session, d, b) -> {"ok": ..., "questions": [...]} d 是 discover() 的结果,b 是请求体
quiz_submit(session, b) -> {"ok": ..., "score": ...} 按用户答案交卷
capture(key, cookies, minutes) 另需配套 captures(key) / capture_reset(key)
"""
from study_tool.platforms.contract import capabilities_of
class Platform:
id = "" # course["platform"] 的取值
label = "" # 日志 / 界面里的展示名
entries = () # 登录 / 学习入口 URL 片段,用于识别平台
id = "" # course["platform"] 的取值
label = "" # 日志 / 界面里的展示名
entries = () # 登录 / 学习入口 URL 片段,用于识别平台
# 槽位:子类实现哪个阶段就把它覆盖成方法;保持 None = 该平台没有这项能力
study = None # 学一门课;None = 这个平台还没实现
study = None # 学一门课;None = 这个平台还没实现
session = None
dump_cookies = None
login = None
@@ -54,6 +58,9 @@ class Platform:
discover = None
renew_session = None
profile = None
quiz = None # quiz(self, s, d, b) -> dict,取测验题目;前端据此显示「打开测验」
quiz_submit = None # quiz_submit(self, s, b) -> dict,按用户答案交卷
capture = None # capture(self, key, cookies, minutes),抓包调试;前端据此显示「抓包」
def matches(self, url="", html=""):
"""登录入口或学习入口命中任意片段,就认为页面属于本平台。"""
+12 -3
View File
@@ -8,10 +8,12 @@
查询课程 list_courses courses
课程详情 discover discover
学习 study study
测验 (待 quiz 下沉为平台能力后加入) quiz
测验 quiz / quiz_submit quiz
抓包调试 capture capture
流程(按什么顺序调用)只有一个实现,在引擎里;平台只负责「这一步在这个站点怎么做」。
"""
CAPABILITIES = {
"login": ("login",),
"session": ("session",),
@@ -20,10 +22,17 @@ CAPABILITIES = {
"courses": ("list_courses",),
"discover": ("discover",),
"study": ("study",),
"quiz": ("quiz", "quiz_submit"),
"capture": ("capture",),
}
def capabilities_of(platform) -> list:
"""按已实现的槽位推导能力名,顺序固定(即 CAPABILITIES 的顺序)。"""
return [name for name, slots in CAPABILITIES.items()
if all(getattr(platform, slot, None) for slot in slots)]
return [name for name, slots in CAPABILITIES.items() if all(getattr(platform, slot, None) for slot in slots)]
def sites_of(platform) -> list:
"""界面展示用的适配网站:去掉被更具体域名覆盖的父域(如 dgjy.net)。"""
entries = list(getattr(platform, "entries", ()) or ())
return [e for e in entries if not any(o != e and o.endswith("." + e) for o in entries)]
+1 -2
View File
@@ -1,7 +1,6 @@
"""平台层错误类型。
放在这里是为了让平台实现(jspx / study / gdgxk)不必反向 import 引擎;
`config.py` 再导出一次,老的 `from study_tool.config import *` 仍然可用。
放在这里是为了让平台实现(guanyixue / gdgxk)不必反向 import 引擎。
"""
+1
View File
@@ -1,4 +1,5 @@
"""平台注册表:引擎、路由都只通过这里拿平台实现。"""
from study_tool.platforms.base import Platform
_PLATFORMS = {}
+174
View File
@@ -0,0 +1,174 @@
"""插件目录:`plugins/<名字>/` = 一个插件。
plugin.json 注册信息:id / title / name / kind(只写元数据,不写任何能力绑定)
main.py `Platform` 子类(kind=platform,默认)或 `notify.Channel` 子类
(kind=notify,推送渠道),import 时用 `registry.register(...)` /
`notify.register(...)` 注册自己
对外四个函数(路由 `/api/plugins*` 直接用):
load_plugins() 启动时按启用清单加载全部插件
discover() 磁盘清单 + 启用 / 已加载状态(不 import 插件代码)
set_enabled(name, bool) 启用 / 停用,立即生效并落库
reload(name=None) 重新 import(改完插件代码不用重启服务)
启用清单存在设置项 `plugins` 里(`{"disabled": [...]}`):没列进去的默认启用,
新丢进目录的插件会自动生效;停用的插件从注册表摘掉,`/api/platforms` 里不再出现。
插件在 import 时用 `@app.post(...)` 注册的接口也算插件的:停用 / 重载时一并摘除,
重载不会重复注册;整个目录删掉则启动时根本不会 import,这些接口自然不存在。
"""
import importlib
import json
import sys
from pathlib import Path
from study_tool import database, notify, runtime
from study_tool.platforms import registry
from study_tool.platforms.contract import sites_of
from study_tool.webapp import app
HERE = Path(__file__).resolve().parent
SETTING = "plugins"
_LOADED = {} # 插件名 -> (kind, 注册时用的 id),停用 / 重载时据此摘下旧实现
_ROUTES = {} # 插件名 -> 它在 app 上注册的路由,停用 / 重载时一并摘掉
def _meta(name):
meta = HERE / name / "plugin.json"
if not meta.is_file():
raise RuntimeError("没有这个插件:%s" % name)
return json.loads(meta.read_text(encoding="utf-8"))
def _disabled():
return set((database.get_setting(SETTING) or {}).get("disabled") or [])
def _save_disabled(names):
database.set_setting(SETTING, {"disabled": sorted(names)})
def _forget(name):
"""把该插件的模块(含子模块)从 sys.modules 摘掉,下次 import 会重新执行。"""
pkg = "%s.%s" % (__name__, name)
for mod in [m for m in sys.modules if m == pkg or m.startswith(pkg + ".")]:
del sys.modules[mod]
def _drop_routes(routes):
"""把插件注册过的路由从 app 摘掉(按对象身份,不会误删同路径的别的路由)。"""
ids = {id(r) for r in routes}
app.router.routes[:] = [r for r in app.router.routes if id(r) not in ids]
app.openapi_schema = None
def _load(name):
"""import 一个插件并确认它注册成功;重复调用就是重新加载。"""
meta = _meta(name)
pid = meta.get("id") or ""
kind = meta.get("kind") or "platform"
_unload(name) # 旧实现先下线,id 改了也不会留下残影
n = len(app.router.routes) # import 期间新增的路由都算这个插件的
try:
importlib.import_module("%s.%s.main" % (__name__, name))
obj = notify.get(pid) if kind == "notify" else registry.get(pid)
if obj is None:
raise RuntimeError(
"插件 %s 没有注册%s id=%r" % (name, "推送渠道" if kind == "notify" else "平台", pid)
)
except Exception:
_drop_routes(app.router.routes[n:])
_unregister(kind, pid)
_forget(name)
raise
_LOADED[name] = (kind, pid)
_ROUTES[name] = app.router.routes[n:]
return obj
def _unregister(kind, pid):
"""按插件类型从对应注册表摘掉:平台走 registry,推送渠道走 notify。"""
return notify.unregister(pid) if kind == "notify" else registry.unregister(pid)
def _unload(name):
hit = _LOADED.pop(name, None)
if hit:
_unregister(*hit)
_drop_routes(_ROUTES.pop(name, []))
_forget(name)
def discover():
"""磁盘上的插件清单;不 import 插件代码。"""
disabled = _disabled()
out = []
for meta in sorted(HERE.glob("*/plugin.json")):
name = meta.parent.name
info = json.loads(meta.read_text(encoding="utf-8"))
pid = info.get("id") or ""
kind = info.get("kind") or "platform"
obj = notify.get(pid) if kind == "notify" else registry.get(pid)
out.append(
{
"name": name,
"id": pid,
"title": info.get("title") or "",
"kind": kind,
"enabled": name not in disabled,
"loaded": obj is not None,
"label": getattr(obj, "label", "") if obj else "",
"sites": sites_of(obj) if obj is not None and kind == "platform" else [],
"capabilities": (
obj.capabilities() if kind == "platform" and obj else (["push"] if obj else [])
),
}
)
return out
def load_plugins():
"""启动时加载启用清单里的全部插件。幂等。"""
for item in discover():
if not item["enabled"]:
continue
try:
_load(item["name"])
except Exception as e:
runtime.log("system", "插件 %s 加载失败:%s" % (item["name"], e), level="warn")
def set_enabled(name, enabled):
"""启用 / 停用插件并立即生效;返回最新的插件清单。"""
if enabled:
obj = _load(name) # 先加载,失败了不写进启用清单
disabled = _disabled()
disabled.discard(name)
_save_disabled(disabled)
runtime.log("system", "插件已启用:%s(%s)" % (name, getattr(obj, "label", name)))
else:
disabled = _disabled()
disabled.add(name)
_save_disabled(disabled)
_unload(name)
runtime.log("system", "插件已停用:%s" % name)
return discover()
def reload(name=None):
"""重新 import 插件;name 为空 = 重载所有已启用的插件。"""
items = discover()
if name:
item = next((i for i in items if i["name"] == name), None)
if item is None:
raise RuntimeError("没有这个插件:%s" % name)
if not item["enabled"]:
raise RuntimeError("插件 %s 已停用,先启用再重载" % name)
names = [name]
else:
names = [i["name"] for i in items if i["enabled"]]
for n in names:
_load(n)
runtime.log("system", "插件已重载:%s" % n)
return discover()
@@ -0,0 +1 @@
"""呆呆面板推送插件:实现在 main.py,配置在「系统设置 → 推送通知」。"""
+111
View File
@@ -0,0 +1,111 @@
"""呆呆面板推送:换 token(缓存到过期)+ 发通知。
接口
POST {base_url}/api/open-api/token {app_key, app_secret} -> token
POST {base_url}/api/notifications/send Authorization: Bearer <token>
配置全部来自系统设置 `notify`(base_url / app_key / app_secret / channel_name),
本文件不写死任何地址或密钥。换 token 只在内存里缓存,重启后自动重取。
"""
import threading
import time
import requests
from study_tool import notify
from study_tool.config.settings import load_settings
TOKEN_PATH = "/api/open-api/token"
SEND_PATH = "/api/notifications/send"
TOKEN_TTL = 3600 # 接口没给有效期时的兜底
_CACHE = {} # (base_url, app_key) -> (token, 过期时间)
_LOCK = threading.Lock()
def _base(cfg):
return (cfg.get("base_url") or "https://corn.hanhan.ltd").rstrip("/")
def _find(obj, names, depth=0):
"""在返回体里找字段:顶层找不到就往 data / result 里找一层。"""
if not isinstance(obj, dict) or depth > 3:
return ""
for n in names:
v = obj.get(n)
if isinstance(v, (str, int, float)) and str(v).strip():
return v
for n in ("data", "result", "content", "body"):
v = _find(obj.get(n), names, depth + 1)
if v != "":
return v
return ""
def _json(r, what):
try:
return r.json()
except Exception:
raise RuntimeError("%s 返回非 JSON(HTTP %s):%s" % (what, r.status_code, (r.text or "")[:120]))
class DaidaiPush(notify.Channel):
id = "daidai_push"
label = "呆呆面板推送"
def _token(self, cfg, force=False):
ck = (_base(cfg), cfg.get("app_key"))
with _LOCK:
hit = _CACHE.get(ck)
if hit and not force and hit[1] > time.time() + 60:
return hit[0]
r = requests.post(
ck[0] + TOKEN_PATH,
json={"app_key": cfg["app_key"], "app_secret": cfg["app_secret"]},
timeout=20,
)
j = _json(r, "换 token")
token = str(_find(j, ("token", "access_token", "accessToken")) or "")
if not token:
raise RuntimeError("换 token 失败:%s" % str(j)[:200])
try:
ttl = int(float(_find(j, ("expires_in", "expire_in", "expiresIn")) or 0))
except (TypeError, ValueError):
ttl = 0
with _LOCK:
_CACHE[ck] = (token, time.time() + (ttl or TOKEN_TTL))
return token
def send(self, title, content, context=None):
cfg = load_settings()["notify"]
if not cfg.get("app_key") or not cfg.get("app_secret"):
raise RuntimeError("推送未配置:系统设置 → 推送通知 里填 app_key / app_secret")
base = _base(cfg)
payload = {
"title": title,
"content": content,
"content_type": "html",
"context": {str(k): v for k, v in (context or {}).items()},
}
if cfg.get("channel_name"):
payload["channel_name"] = cfg["channel_name"]
for attempt in (0, 1):
token = self._token(cfg, force=attempt == 1)
r = requests.post(
base + SEND_PATH,
headers={"Authorization": "Bearer " + token},
json=payload,
timeout=20,
)
if r.status_code in (401, 403) and attempt == 0:
continue # token 过期:丢掉缓存换一张再发一次
j = _json(r, "推送")
ok = str(_find(j, ("code",)) or 200) in ("0", "200") or _find(j, ("success", "ok")) in (True, "true")
if r.status_code >= 400 or not ok:
raise RuntimeError("推送失败:HTTP %s %s" % (r.status_code, str(j)[:200]))
return j
raise RuntimeError("推送失败:token 被拒(换新 token 后仍然 401/403)")
NOTIFY = notify.register(DaidaiPush())
@@ -0,0 +1,6 @@
{
"id": "daidai_push",
"title": "呆呆面板推送",
"name": "daidai_push",
"kind": "notify"
}
@@ -1 +1 @@
"""广东公需课插件:实现在 `main.py`,注册信息在 `pugin.json`。"""
"""广东公需课插件:实现在 `main.py`,注册信息在 `plugin.json`。"""
+21
View File
@@ -0,0 +1,21 @@
answer = [
{
"workId": "a_22f272254f7a427e8b7a6d2373753caf",
"answer": [
'A', 'D', 'B', 'B', 'A', 'D', 'B', 'B', 'D', 'B',
'ABCD', 'ABCDE', 'ABC', 'ACD', 'ABC', 'ABCD', 'ABCD', 'ABD', 'ABCD', 'ABC',
'A', 'A', 'B', 'B', 'B', 'B', 'A', 'A', 'A', 'B'
],
},
{
"workId": "",
"answer": [
'A', 'C', 'D', 'D', 'B', 'D', 'B', 'B', 'D', 'B',
'ABC', 'ABC', 'AB', 'ABC', 'ABCD', 'ABCD', 'ABCD', 'BCD', 'BCD', 'ABC',
'A', 'B', 'A', 'B', 'B', 'B', 'A', 'B', 'B', 'A'
],
},
]
+486
View File
@@ -0,0 +1,486 @@
[
{
"q": "生成式人工智能(GenAI)目前还没有在教育领域得到广泛应用。",
"a": "B"
},
{
"q": "人工智能(AI)是指计算机系统模拟类似人类的认知能力,如学习、决策和解决问题的能力。",
"a": "A"
},
{
"q": "生成式人工智能工具没有任何局限性,可以完全替代人类完成创作工作。",
"a": "B"
},
{
"q": "ChatGPT 生成的参考文献、作者、URL等,有可能是编造的、不存在的。",
"a": "A"
},
{
"q": "早期的机器人关节以一体化关节为主,将电机、减速器等部件深度融合设计。",
"a": "B"
},
{
"q": "具身智能只需要实现视觉能力,不需要语言和行动能力。",
"a": "B"
},
{
"q": "OpenClaw本身是收费软件,使用时会直接产生高额费用。",
"a": "B"
},
{
"q": "为保障安全,建议不要在主电脑上直接运行 OpenClaw,可在专用备用电脑、云服务器或Docker 容器中运行。",
"a": "A"
},
{
"q": "传统科研与AI驱动科研的之间关键转变 : 从“ 人驱动\"到 ”AI智能 \" 驱动。",
"a": "A"
},
{
"q": "人工智能驱动的新型科研范式,本质上是在提升科研活动的系统效率、认知效率和处理效率。",
"a": "A"
},
{
"q": "科学大模型建设的三大意义:支撑前沿科学问题研究、支撑交叉学科融合创新、支撑从科研到应用的衔接。",
"a": "A"
},
{
"q": "未来科研平台竞争维度包括:硬件竞争、数据能力竞争、模型能力竞争、智能协同能力竞争。",
"a": "A"
},
{
"q": "创新研发模式五大支柱:数据、模型、模仿、实验、反馈。",
"a": "B"
},
{
"q": "6G不只是更快的通信,更重要的是网络、感知、计算、智能全面融合。",
"a": "A"
},
{
"q": "社科研究的原因:技术发展速度远超伦理规范制定速度,技术能力边界扩大带来责任边界扩大,缺乏价值引导的技术可能走向“ 恶” 的方向。",
"a": "A"
},
{
"q": "AI是道德中立的工具,它携带了开发者的价值观与社会偏见。",
"a": "A"
},
{
"q": "执行型智能体不具备感知能力,仅能执行任务。",
"a": "B"
},
{
"q": "OpenClaw集成的物理信息神经网络(PINNs)技术,属于物理信息机器学习的范畴。",
"a": "A"
},
{
"q": "售前AI推送是根据用户的消费行为、兴趣爱好等信息,向用户推送个性化商品或服务的智能方式。",
"a": "A"
},
{
"q": "仿真设定的参数仅包含激光功率和扫描速度,不涉及基板预热温度。",
"a": "B"
},
{
"q": "智能AI质检中,运用卷积神经网络和目标检测模型可实现缺陷图像的特征识别及分类。",
"a": "A"
},
{
"q": "三条训练损失曲线从高位快速下降后出现明显反弹波动,说明模型学习过程正常,未出现过拟合欠拟合。",
"a": "B"
},
{
"q": "AI视觉系统仅能识别田间的杂草,无法识别病害斑点,因此无法实现精准施药。",
"a": "B"
},
{
"q": "系统可根据实时水质监测数据,远程控制增氧泵、底部增氧机等设备,实现水质的远程调控。",
"a": "A"
},
{
"q": "2025 年 12月,字节推出了 “豆包手机助手 ”正式版,合作伙伴包含中兴努比亚(M153工程机)。",
"a": "A"
},
{
"q": "AI+消费在技术层面的核心挑战,仅包含多模态大模型消费应用的自主可控能力建设。",
"a": "B"
},
{
"q": "家政服务机器人3.0时代的目标是打造具备通用家务能力的人形机器人,成为家庭“全面手” 。",
"a": "A"
},
{
"q": "家用服务机器人的 “安全性 ” 特质,仅需避免物理碰撞,无需完善的主动安全防护机制。",
"a": "B"
},
{
"q": "智能家居行业竞争的核心,目前仍局限于单一产品的功能参数比拼。",
"a": "B"
},
{
"q": "MOVVA 推出的追觅 “ 天穹” 系列芯片尚未量产,还处于研发测试阶段。",
"a": "B"
},
{
"q": "“ 脑机捕捉意图 ” 的核心作用是直接生成个性化数字内容,完成交互闭环的最后一步。",
"a": "B"
},
{
"q": "伦理制度化的核心目标,是推动行业监管从原则倡导向制度化落地转变。",
"a": "A"
},
{
"q": "ChatGPT 的本质是一个拥有过亿参数的大模型,其参数可通过机器学习自动找出。",
"a": "A"
},
{
"q": "代码生成模块的能力包含根据需求生成 Python 、 JavaScript等语言的代码片段,以及自动补全与注释生成。",
"a": "A"
},
{
"q": "使用DeepSeek推理模型时,需通过显式步骤拆解任务,强行拆解步骤能更好发挥能力。",
"a": "A"
},
{
"q": "根据DeepSeek提示词技巧,提示词表达越模糊越像日常对话,就能获得更好效果。",
"a": "B"
},
{
"q": "18世纪普鲁士人最先实施发明的课堂教学模式中,核心形式是学生自主讲解知识,教师负责聆听指导。",
"a": "B"
},
{
"q": "生成式AI目前已具备人类的数学直觉能力,可完全替代人类进行创造性数学研究。",
"a": "B"
},
{
"q": "人工智能+金融阶段的发展,建立在 IT信息系统稳定可靠、互联网环境成熟基础之上。",
"a": "A"
},
{
"q": "2018 年谷歌借鉴 ELMo和GPT,推出大规模语言模型BERT,掀起NLP第二次大变革,进入魔改BERT时代。",
"a": "A"
},
{
"q": "CTF-Net 是纯 CNN架构网络,专门解决纯Transformer架构全局感受野不足问题。",
"a": "A"
},
{
"q": "城市交通系统是规模巨大、关联复杂、随机性强、混合交通流的大系统,利用交通大数据解决预报控制难题极具挑战性。",
"a": "A"
},
{
"q": "MBA-Net模型需要依赖辅助模型,才能为被遮挡行人图像生成鲁棒表示特征。",
"a": "B"
},
{
"q": "数据科学进步,推动复杂系统研究从数据机理融合驱动,向经验驱动、机理驱动范式变革。",
"a": "B"
},
{
"q": "针对新建光伏电站历史运行数据不足,方案提出基于迁移学习的模型微调,可借同类站点提升发电量预测精度。",
"a": "A"
},
{
"q": "人工智能大模型工程伦理问题,核心仅涉及科技法律二维,与权利、道德、生命维度无关。",
"a": "B"
},
{
"q": "《人工智能能力建设普惠计划》2025年12月正式发布,聚焦发展中国家AI能力,缩小数字鸿沟。",
"a": "A"
},
{
"q": "世界人工智能合作组织倡议2025年7月提出,总部拟设于上海。",
"a": "B"
},
{
"q": "中国人工智能2025—2026重点:多模态突破、类脑智能、国产算力、产业落地。",
"a": "A"
},
{
"q": "中国AI发展理念:以技术为本、智能向快。",
"a": "B"
},
{
"q": "中国大模型首次用于地震救援,DeepSeek仅用7小时开发中缅应急系统。",
"a": "A"
},
{
"q": "中国AI诊所落地沙特,全球首个AI诊所,覆盖30种呼吸疾病,诊断准确率近100%。",
"a": "A"
},
{
"q": "AI技术法国家庭节电25%-30%,为联合国气候平台首批伙伴。",
"a": "A"
},
{
"q": "未来AI注重普惠,缩小数字鸿沟、均衡发展。",
"a": "A"
},
{
"q": "核心技术依赖进口、产品同质化;中小企业研发强度1.3%,远低于发达国家3%,难破高端市场。",
"a": "A"
},
{
"q": "2025年基础理论重大突破、部分技术世界领先,成为产业升级经济转型主要动力。",
"a": "B"
},
{
"q": "《“人工智能+制造”专项行动》保障:推进机制、资金支持、应用推广、监测评估。",
"a": "A"
},
{
"q": "工业母机柔性化:AI深度融入数控,实现自适应作业、智能诊断、智能调度,大幅提升利用率精度。",
"a": "A"
},
{
"q": "数据治理五步法:汇聚、清洗、标注、增强、治理。",
"a": "A"
},
{
"q": "AI价值:驱动“新三样”从规模出海转向高质量出海,构建全球竞争壁垒。",
"a": "A"
},
{
"q": "未来工厂三大特征:全流程自主运行、虚实实时同步、智能持续迭代。",
"a": "A"
},
{
"q": "正视挑战、技术创新、标准统一,释放工业数据价值,实现智能化转型跨越。",
"a": "A"
},
{
"q": "广东区域创新能力连续9年全国第一。",
"a": "A"
},
{
"q": "广东高技术制造业占规上工业比重不足三成。",
"a": "B"
},
{
"q": "耐心资本渠道:财政专项资金、政府投资基金、地方政府专项债、超长期特别国债。",
"a": "A"
},
{
"q": "成长期赛道企业可在北交所、广东区域性股权市场上市。",
"a": "B"
},
{
"q": "战略性新兴产业是新质生产力主要载体。",
"a": "A"
},
{
"q": "核心驱动力:新质生产力推动战略性新兴产业持续升级。",
"a": "A"
},
{
"q": "广东游戏营收、数字出版产值、动漫产值、电影票房等指标全国第一。",
"a": "A"
},
{
"q": "截至2023年底,广东激光与增材制造产业有效发明专利全国第一。",
"a": "A"
},
{
"q": "广东战略性新兴产业指导思想:加快从制造大省到制造强省转变。",
"a": "A"
},
{
"q": "广东战略性新兴产业定位:打造世界先进水平产业基地。",
"a": "A"
},
{
"q": "新质生产力理论是马克思主义生产力理论创新,丰富发展政治经济学。",
"a": "A"
},
{
"q": "产业集群成长期竞争优势完全成熟,全球领先。",
"a": "B"
},
{
"q": "新质生产力支撑仅含技术创新,不含数字化转型与人才支撑。",
"a": "B"
},
{
"q": "2024年广东数字经济增加值占GDP持续提升,连续多年全国第一。",
"a": "B"
},
{
"q": "工业机器人上游核心零部件:减速器、伺服电机、控制器,技术壁垒最高。",
"a": "A"
},
{
"q": "小六轴机器人参与者少,国内暂无优势。",
"a": "B"
},
{
"q": "腾讯音乐涨价短期拉动营收,不影响付费转化率。",
"a": "B"
},
{
"q": "东伦敦科技城经验:无政府资金支持相关设施。",
"a": "B"
},
{
"q": "新质生产力以数据为关键要素,强调知识创新应用。",
"a": "A"
},
{
"q": "产业布局是产业发展规律体现,指一国地区产业空间分布组合。",
"a": "A"
},
{
"q": "智能化技术底座:人工智能、大数据、云计算、5G、互联网。",
"a": "B"
},
{
"q": "转型成效评估:结构优化、创新驱动、集群发展质量。",
"a": "A"
},
{
"q": "家电产业链行为:产品差异化、并购、研发、价格行为。",
"a": "A"
},
{
"q": "全球传统产业转移趋势:区域化、成本化、绿色化、多元化。",
"a": "A"
},
{
"q": "广东传统产业升级原则:创新驱动、重点突破、开放合作、畅通循环、质效优先、绿色发展、市场主导、政府引导。",
"a": "B"
},
{
"q": "构建高效服务业体系:推动传统服务向现代服务转型。",
"a": "A"
},
{
"q": "佛山南海开通全国常态化无人机外卖航线。",
"a": "A"
},
{
"q": "服务业4.0:大数据、AI、IoT驱动无缝服务演进。",
"a": "A"
},
{
"q": "大都市创新城市:战略性新兴产业与服务业融合,推动产业升级高质量发展。",
"a": "A"
},
{
"q": "2015-2020广东服务业增加值全国第一。",
"a": "A"
},
{
"q": "广东服务业人才瓶颈:金融、信息、科研等领域高端人才短缺。",
"a": "A"
},
{
"q": "现代服务业与先进制造、现代农业深度融合是优质服务业体系重点。",
"a": "A"
},
{
"q": "服务业监管:企业自律+行业自律+政府监管+社会参与多元共治。",
"a": "A"
},
{
"q": "1978-1999中国家电研发强、核心器件自主,不依赖进口。",
"a": "B"
},
{
"q": "2019中国家电零售8910亿,行业成熟。",
"a": "A"
},
{
"q": "2018-2024小米IoT与生活消费品收入占比25.1%→28.5%,结构多元。",
"a": "A"
},
{
"q": "小米家电早期高性价比:入门定价、核心功能聚焦、快速抢占份额。",
"a": "A"
},
{
"q": "2024小米之家约1.5万家,构建人车家全生态。",
"a": "A"
},
{
"q": "小米电视低价互联网模式:硬件不赚钱,软件盈利。",
"a": "A"
},
{
"q": "改革开放初期家电政策核心:扩大内需、全球化发展。",
"a": "B"
},
{
"q": "2011-2019家电线上渠道10.4%→25.4%,社交电商、直播带货增长。",
"a": "A"
},
{
"q": "我国人口转变特征:迅速性、政策干预、不彻底、不稳定、不平衡。",
"a": "A"
},
{
"q": "广东20个战略性产业集群增加值占GDP17%,全国约40%。",
"a": "B"
},
{
"q": "我国人口最多,人口发展事关民族复兴。",
"a": "A"
},
{
"q": "中国人口转变2050年呈倒U型。",
"a": "A"
},
{
"q": "人口高质量发展核心:规模、结构、质量、分布四维。",
"a": "B"
},
{
"q": "经济高质量发展是中国式现代化物质根基与核心支撑。",
"a": "A"
},
{
"q": "欧盟《新电池法》2024.8.17生效,禁止供应链碳足迹追溯。",
"a": "B"
},
{
"q": "绿色转型阵痛:传统产业退出导致失业、地方财政下行等短期压力。",
"a": "A"
},
{
"q": "截至2025年底氢燃料电池汽车销量近4万,实现0-1产业化突破。",
"a": "A"
},
{
"q": "AI巡检提升效率8倍,成本降15%-30%。",
"a": "A"
},
{
"q": "2026政策逻辑从控碳转向控能,核心惩罚能耗总量、限制发展空间。",
"a": "B"
},
{
"q": "碳市场扩容后覆盖碳排放占比60%→30%+。",
"a": "B"
},
{
"q": "高质量、绿色共生:摒弃高耗能高污染,实现经济生态协同。",
"a": "A"
},
{
"q": "第一次生产力革命(蒸汽)标志:蒸汽机使用。",
"a": "A"
},
{
"q": "工业5.0是工业4.0延续,新增绿色可持续与人机协作。",
"a": "A"
},
{
"q": "欧盟模式核心武器:硅谷+华尔街,对标美国法条标准。",
"a": "B"
},
{
"q": "上海土地充足、生活成本低,传统劳动密集产业有优势。",
"a": "B"
},
{
"q": "合肥创新模式:市场雨林型,靠供应链与市场活力。",
"a": "B"
}
]
+4
View File
@@ -0,0 +1,4 @@
{
"year": "",
"pick": 1
}
+304
View File
@@ -0,0 +1,304 @@
"""广东公需课:jsglpt 登录 → jsxx 学习。
登录在 `jsglpt.gds.edu.cn`(账号 / 密码都是 base64),成功后用
`ssoItlmsGxkTicket.action` 换 ITLMS 票,带票进 `jsxx.gds.edu.cn` 拿学习会话;
两个域名的 cookie 都留在同一个 requests.Session 里。
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度靠 `updateViewTime1`
心跳上报(`lastViewTime` 是已看秒数,页面上「要求观看视频 N 分钟」才是完成门槛)。
平台按真实流逝时间放行(实测一次报 +300 只记 +60),所以只能 1 倍速慢慢刷。
本文件只留 `Platform` 子类与注册,工具按阶段拆到 `utils/`(登录 / 课程解析 / 心跳)。
"""
import re
import requests
from study_tool import database, notify, runtime
from study_tool.platforms import registry
from study_tool.platforms.base import Platform
from .utils import course, exam, http, learn, login, register
MAX_ROUNDS = 5 # 最多「刷一轮 → 重取进度页」的轮数
MAX_STALL = 3 # 连续这么多轮平台完成数没增加就放弃,避免非视频任务点死循环
ROUND_WAIT = 60 # 两轮之间等平台刷新完成状态的秒数
EXAM_MARKS = ("考核", "考试") # 课程目录里这两类活动是试卷
def _exam_activity(courseware):
"""课程目录里的考核活动:考核是独立活动页(/<aid>/study/course/<cid>),题目只在它上面。"""
return next((c for c in courseware or [] if any(k in (c.get("title") or "") for k in EXAM_MARKS)), None)
def _courseware(cid, acts):
"""进度页的活动清单 → 引擎用的 courseware 条目。"""
return [
{
"aid": a["aid"],
"title": a["title"],
"done": a["done"],
"href": http.X + "/" + a["aid"] + "/study/course/" + cid,
"total": 0,
"viewTime": 0,
}
for a in acts
]
class Gdgxk(Platform):
"""广东公需课:jsglpt 登录(账号 / 密码都是 base64 + 图形验证码),jsxx 学习。
账号级能力都实现在这里;反馈表 / 测验这类平台没有的阶段不实现,保持基类的 None。
"""
id = http.PLATFORM_ID
label = "广东公需课"
entries = ("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn")
def session(self, cookies=None):
s = requests.Session()
s.headers.update({"User-Agent": http.UA, "Accept-Language": "zh-CN,zh;q=0.9"})
for c in cookies or []:
if "gds.edu.cn" 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(self, s):
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path} for c in s.cookies]
def login(self, username, password):
s = self.session()
s.get(http.G + "/login.jsp", timeout=30)
err = ""
for _ in range(5):
data = {
"loginUser.userName": login.b64(username),
"loginUser.loginType": "normal",
"loginUser.password": login.b64(password),
"captcha": login.ocr().classification(login.captcha(s)),
}
r = s.post(http.G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
if r.status_code == 302 and "/default.jsp" in (r.headers.get("location") or ""):
login.sso(s)
return s, self.profile(s) or username
err = login.login_error(s)
if "验证码" not in err: # 不是验证码的问题,重试没意义
break
raise RuntimeError("登录失败:%s" % (err or "账号或密码不对"))
def renew_session(self, s):
try:
login.sso(s)
return True
except Exception:
return False
def profile(self, s):
"""个人中心里平台显示的用户名:学员是打码姓名(如「*栋」),老师是完整姓名。"""
html = s.get(http.G + "/teacher/index.do", timeout=30).text
# 左上角头像区:<a href="javascript:;" class="name" ...>姓名</a>
m = re.search(r'class="name"[^>]*>([^<]{1,20})</a>', html)
if m and m.group(1).strip():
return m.group(1).strip()
m = re.search(r"你好[,,]?\s*([^\s<]+)\s*老师", re.sub(r"\s+", " ", html))
return m.group(1) if m else ""
def list_courses(self, s):
html = s.get(http.X + "/uc/store/courseRegister", timeout=30).text
out = course.parse_courses(html)
if not out and "logout" not in html:
raise http.expired("学习平台会话已失效,请重新登录")
# 没有目标年度的课:去选课超市选一门,再重新读列表
if not any(register.config()[0] in c["section"] for c in out) and register.ensure(s):
out = course.parse_courses(s.get(http.X + "/uc/store/courseRegister", timeout=30).text)
return out
def discover(self, s, plan_id, project_id, name=""):
cid = plan_id
page = http.X + "/study/course/" + cid
s.headers["Referer"] = page
r = s.get(page + "?v=1", timeout=30)
if "loginBtn" in r.text and "md5Uid" not in r.text:
raise http.expired("学习平台会话已失效,请重新登录")
# 一门课是「章 / 节 / 活动」的目录,不是单个视频;活动清单和完成情况都从进度页取
pr = course.progress(s, cid)
if not pr["acts"]:
raise RuntimeError("进度页里没有找到活动列表,页面结构可能变了")
courseware = _courseware(cid, pr["acts"])
return {
"user": self.profile(s),
"course": name or "公需课程",
"progress": "已完成 %d/%d 个活动" % (pr["done"], pr["total"]),
"info": page,
"courseware": courseware,
"feedback": [],
"test": [],
"platform": http.PLATFORM_ID,
"generation": "gxk",
"plan_id": cid,
"project_id": "",
}
def study(self, key, s, d, **opts):
"""按进度页的活动清单逐个心跳;平台只按真实时间放行,所以默认 1 倍速慢慢刷。"""
speed = float(opts.get("speed") or 1)
beat = int(opts.get("beat") or 60)
cid = d["plan_id"]
cws = d["courseware"]
runtime.set_state(key, status="studying", total=len(cws), done=0)
best, stall, pr, remain = -1, 0, {"done": 0, "total": len(cws)}, []
for rnd in range(1, MAX_ROUNDS + 1):
todo = [c for c in cws if not c.get("done")]
base = len(cws) - len(todo)
for i, cw in enumerate(todo, 1):
if learn.stopped(key):
runtime.log(key, "已按要求停止")
return
for attempt in (1, 2):
try:
learn.study_one(key, s, cw, speed, beat)
break
except Exception as e:
expired = e.__class__.__name__ == "SessionExpired"
if expired and attempt == 1:
s = self._relogin(key) # 掉线:重登后把当前活动重新走一遍
continue
if expired:
raise
runtime.log(key, "活动「%s」本轮跳过:%s" % (cw["title"], e), level="warn")
break
runtime.set_state(key, done=base + i)
# 以平台进度页为准:不管本轮刷了几个(包括一个都不用刷),都重取任务点状态
s, pr = self._progress(key, s, cid)
cws = _courseware(cid, pr["acts"])
remain = [c["title"] for c in cws if not c["done"]]
runtime.set_state(key, done=pr["done"], total=pr["total"])
if pr["done"] >= pr["total"] and not remain:
break
stall = 0 if pr["done"] > best else stall + 1
best = pr["done"]
if stall >= MAX_STALL:
break
runtime.log(
key,
"第 %d 轮结束,平台进度 %d/%d,仍有 %d 个任务点未完成,稍后自动重新学习:%s"
% (rnd, pr["done"], pr["total"], len(remain), "、".join(remain) or "(进度页未列出具体任务点)"),
level="warn",
)
notify.retry(
key,
"未完成任务点自动重学",
"平台进度 %d/%d,第 %d 轮仍有 %d 个任务点未完成" % (pr["done"], pr["total"], rnd, len(remain)),
)
if not learn.wait(key, ROUND_WAIT):
runtime.log(key, "已按要求停止")
return
if remain: # 视频都刷完了还有缺口,多半是课程考核;交完卷再按进度页复核一次
cw = _exam_activity(cws)
if cw:
s = self._exam(key, s, cw["href"], opts)
s, pr = self._progress(key, s, cid)
remain = [c["title"] for c in _courseware(cid, pr["acts"]) if not c["done"]]
runtime.set_state(key, status="done", done=pr["done"], total=pr["total"])
if pr["done"] >= pr["total"] and not remain:
runtime.log(key, "平台进度:%d/%d 个活动已完成" % (pr["done"], pr["total"]))
else:
runtime.log(
key,
"平台进度:%d/%d 个活动已完成,自动重学后仍未完成,需人工处理%s"
% (pr["done"], pr["total"], ("(" + "、".join(remain) + ")") if remain else ""),
level="warn",
)
def _exam(self, key, s, url, opts):
"""课程考核:题库优先、大模型兜底;题库没覆盖全部题目就不交卷。掉线重登后重试一次。"""
auto = opts.get("exam_auto")
auto = True if auto is None else bool(auto)
try:
s, msg = exam.run(key, s, url, auto)
except Exception as e:
if e.__class__.__name__ != "SessionExpired":
runtime.log(key, "考核处理失败:%s" % e, level="warn")
return s
s = self._relogin(key)
try:
s, msg = exam.run(key, s, url, auto)
except Exception as e2:
runtime.log(key, "考核处理失败:%s" % e2, level="warn")
return s
if msg:
runtime.set_state(key, status="exam", total=1, done=1)
return s
def quiz(self, s, d, b):
"""课程考核的题目:考核是独立活动页,解析它上面的 testForm。已交卷也返回题目,方便回看。"""
cw = _exam_activity(d.get("courseware"))
if not cw:
return {"ok": False, "error": "这门课没有考核"}
cid = d["plan_id"]
html = s.get(cw["href"], timeout=30).text
done = exam.finished(html)
ex = exam.parse(html)
questions = [
{
"no": str(q["no"]),
"type": exam.TYPE_CODE.get(q["type"], "01"),
"title": q["title"],
"options": [{"key": o["key"], "text": o["text"]} for o in q["options"]],
}
for q in (ex or {}).get("questions") or []
]
if not done and not questions:
return {"ok": False, "error": "考核页里没有找到题目(页面结构可能变了)"}
return {
"ok": True,
"platform": http.PLATFORM_ID,
"plan_id": cid,
"object_id": cw["aid"],
"title": "课程考核",
"done": done,
"questions": questions,
}
def quiz_submit(self, s, b):
"""弹窗里点交卷:按用户选的字母答案提交,返回得分。"""
if not b.object_id:
return {"ok": False, "error": "缺少考核活动 ID,请重新打开测验"}
url = http.X + "/" + b.object_id + "/study/course/" + b.plan_id
return exam.submit_letters(s, url, b.answers)
def _progress(self, key, s, cid):
"""取进度页;掉线就重登后再取一次,返回 (会话, 进度)。"""
try:
return s, course.progress(s, cid)
except Exception as e:
if e.__class__.__name__ != "SessionExpired":
raise
notify.retry(key, "会话掉线重登", "取进度页时会话失效,自动重新登录")
s = self._relogin(key)
return s, course.progress(s, cid)
def _relogin(self, key):
"""掉线后按库里存的账号密码重登,返回新会话。"""
acc = database.account_get(key) or {}
pw = database.account_password(key)
if not pw:
raise http.expired("会话掉线,且没有保存密码,无法自动重连")
runtime.log(key, "会话掉线,正在用账号密码自动重新登录…", level="warn")
s, _ = self.login(acc.get("username") or "", pw)
# 新会话要写回账号和运行状态:否则库里留的还是刚失效的那份,
# 下次「刷新课程」立刻又判过期,反复触发重登。
cookies = self.dump_cookies(s)
database.account_save_cookies(key, cookies)
runtime.set_state(key, cookies=cookies)
runtime.log(key, "重新登录成功,继续学习")
return s
PLATFORM = registry.register(Gdgxk())
@@ -0,0 +1,522 @@
// ==UserScript==
// @name 2026广东公需课·全自动完美版(后台保活+10分钟刷新)
// @namespace http://tampermonkey.net/
// @version 1.3
// @description 超强防掉线+后台保活不暂停+10分钟刷新+题库优先+自动考试
// @author 广东数学张叔
// @match https://jsxx.gds.edu.cn/*
// @grant none
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// 基础配置
const QUICK_DELAY_MIN = 600;
const QUICK_DELAY_MAX = 1400;
let panel = null, progressText = null;
let isAnswering = false;
let activeTimer = null;
// 兜底遍历(仅视频题使用)
let bruteForceRound = 0;
const MAX_BRUTE_ROUND = 12;
// 强制看完视频再下一节
const FORCE_WATCH_END = true;
let videoWatched = false;
// 脚本开关
let scriptEnabled = true;
let isMinimized = false;
// ==============================================
// 后台保活:防止浏览器后台暂停脚本
// ==============================================
function enableBackgroundKeepAlive() {
setInterval(() => { }, 1000);
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
console.log('页面进入后台,继续保活');
} else {
console.log('页面回到前台');
}
});
window.addEventListener('blur', () => {
setTimeout(() => {
window.dispatchEvent(new Event('focus'));
}, 500);
});
}
// ==============================================
// 自动刷新:每10分钟刷新一次页面
// ==============================================
const REFRESH_INTERVAL = 10 * 60 * 1000;
let lastRefreshTime = Date.now();
function checkAutoRefresh() {
if (!scriptEnabled) return;
if (Date.now() - lastRefreshTime > REFRESH_INTERVAL) {
console.log("已挂机10分钟,自动刷新保活");
location.reload();
}
}
// ==============================================
// 考核答案:双套识别逻辑【已反转,解决反了问题】
// ==============================================
function getExamAnswers() {
const idVal = document.querySelector('.aidHidden.ing')?.value;
if (idVal === 'a_22f272254f7a427e8b7a6d2373753caf') {
// 现在这里放第二套答案
return [
'A','D','B','B','A','D','B','B','D','B',
'ABCD','ABCDE','ABC','ACD','ABC','ABCD','ABCD','ABD','ABCD','ABC',
'A','A','B','B','B','B','A','A','A','B'
];
} else {
// 现在这里放第一套答案
return [
'A','C','D','D','B','D','B','B','D','B',
'ABC','ABC','AB','ABC','ABCD','ABCD','ABCD','BCD','BCD','ABC',
'A','B','A','B','B','B','A','B','B','A'
];
}
}
// ==============================================
// 视频题库
// ==============================================
const videoBank = new Map();
// 专题1
videoBank.set("生成式人工智能(GenAI)目前还没有在教育领域得到广泛应用。", "B");
videoBank.set("人工智能(AI)是指计算机系统模拟类似人类的认知能力,如学习、决策和解决问题的能力。", "A");
videoBank.set("生成式人工智能工具没有任何局限性,可以完全替代人类完成创作工作。", "B");
videoBank.set("ChatGPT 生成的参考文献、作者、URL等,有可能是编造的、不存在的。", "A");
videoBank.set("早期的机器人关节以一体化关节为主,将电机、减速器等部件深度融合设计。", "B");
videoBank.set("具身智能只需要实现视觉能力,不需要语言和行动能力。", "B");
videoBank.set("OpenClaw本身是收费软件,使用时会直接产生高额费用。", "B");
videoBank.set("为保障安全,建议不要在主电脑上直接运行 OpenClaw,可在专用备用电脑、云服务器或Docker 容器中运行。", "A");
videoBank.set("传统科研与AI驱动科研的之间关键转变 : 从“ 人驱动\"到 ”AI智能 \" 驱动。", "A");
videoBank.set("人工智能驱动的新型科研范式,本质上是在提升科研活动的系统效率、认知效率和处理效率。", "A");
videoBank.set("科学大模型建设的三大意义:支撑前沿科学问题研究、支撑交叉学科融合创新、支撑从科研到应用的衔接。", "A");
videoBank.set("未来科研平台竞争维度包括:硬件竞争、数据能力竞争、模型能力竞争、智能协同能力竞争。", "A");
videoBank.set("创新研发模式五大支柱:数据、模型、模仿、实验、反馈。", "B");
videoBank.set("6G不只是更快的通信,更重要的是网络、感知、计算、智能全面融合。", "A");
videoBank.set("社科研究的原因:技术发展速度远超伦理规范制定速度,技术能力边界扩大带来责任边界扩大,缺乏价值引导的技术可能走向“ 恶” 的方向。", "A");
videoBank.set("AI是道德中立的工具,它携带了开发者的价值观与社会偏见。", "A");
videoBank.set("执行型智能体不具备感知能力,仅能执行任务。", "B");
videoBank.set("OpenClaw集成的物理信息神经网络(PINNs)技术,属于物理信息机器学习的范畴。", "A");
videoBank.set("售前AI推送是根据用户的消费行为、兴趣爱好等信息,向用户推送个性化商品或服务的智能方式。", "A");
videoBank.set("仿真设定的参数仅包含激光功率和扫描速度,不涉及基板预热温度。", "B");
videoBank.set("智能AI质检中,运用卷积神经网络和目标检测模型可实现缺陷图像的特征识别及分类。", "A");
videoBank.set("三条训练损失曲线从高位快速下降后出现明显反弹波动,说明模型学习过程正常,未出现过拟合欠拟合。", "B");
videoBank.set("AI视觉系统仅能识别田间的杂草,无法识别病害斑点,因此无法实现精准施药。", "B");
videoBank.set("系统可根据实时水质监测数据,远程控制增氧泵、底部增氧机等设备,实现水质的远程调控。", "A");
videoBank.set("2025 年 12月,字节推出了 “豆包手机助手 ”正式版,合作伙伴包含中兴努比亚(M153工程机)。", "A");
videoBank.set("AI+消费在技术层面的核心挑战,仅包含多模态大模型消费应用的自主可控能力建设。", "B");
videoBank.set("家政服务机器人3.0时代的目标是打造具备通用家务能力的人形机器人,成为家庭“全面手” 。", "A");
videoBank.set("家用服务机器人的 “安全性 ” 特质,仅需避免物理碰撞,无需完善的主动安全防护机制。", "B");
videoBank.set("智能家居行业竞争的核心,目前仍局限于单一产品的功能参数比拼。", "B");
videoBank.set("MOVVA 推出的追觅 “ 天穹” 系列芯片尚未量产,还处于研发测试阶段。", "B");
videoBank.set("“ 脑机捕捉意图 ” 的核心作用是直接生成个性化数字内容,完成交互闭环的最后一步。", "B");
videoBank.set("伦理制度化的核心目标,是推动行业监管从原则倡导向制度化落地转变。", "A");
videoBank.set("ChatGPT 的本质是一个拥有过亿参数的大模型,其参数可通过机器学习自动找出。", "A");
videoBank.set("代码生成模块的能力包含根据需求生成 Python 、 JavaScript等语言的代码片段,以及自动补全与注释生成。", "A");
videoBank.set("使用DeepSeek推理模型时,需通过显式步骤拆解任务,强行拆解步骤能更好发挥能力。", "A");
videoBank.set("根据DeepSeek提示词技巧,提示词表达越模糊越像日常对话,就能获得更好效果。", "B");
videoBank.set("18世纪普鲁士人最先实施发明的课堂教学模式中,核心形式是学生自主讲解知识,教师负责聆听指导。", "B");
videoBank.set("生成式AI目前已具备人类的数学直觉能力,可完全替代人类进行创造性数学研究。", "B");
videoBank.set("人工智能+金融阶段的发展,建立在 IT信息系统稳定可靠、互联网环境成熟基础之上。", "A");
videoBank.set("2018 年谷歌借鉴 ELMo和GPT,推出大规模语言模型BERT,掀起NLP第二次大变革,进入魔改BERT时代。", "A");
videoBank.set("CTF-Net 是纯 CNN架构网络,专门解决纯Transformer架构全局感受野不足问题。", "A");
videoBank.set("城市交通系统是规模巨大、关联复杂、随机性强、混合交通流的大系统,利用交通大数据解决预报控制难题极具挑战性。", "A");
videoBank.set("MBA-Net模型需要依赖辅助模型,才能为被遮挡行人图像生成鲁棒表示特征。", "B");
videoBank.set("数据科学进步,推动复杂系统研究从数据机理融合驱动,向经验驱动、机理驱动范式变革。", "B");
videoBank.set("针对新建光伏电站历史运行数据不足,方案提出基于迁移学习的模型微调,可借同类站点提升发电量预测精度。", "A");
videoBank.set("人工智能大模型工程伦理问题,核心仅涉及科技法律二维,与权利、道德、生命维度无关。", "B");
videoBank.set("《人工智能能力建设普惠计划》2025年12月正式发布,聚焦发展中国家AI能力,缩小数字鸿沟。", "A");
videoBank.set("世界人工智能合作组织倡议2025年7月提出,总部拟设于上海。", "B");
videoBank.set("中国人工智能2025—2026重点:多模态突破、类脑智能、国产算力、产业落地。", "A");
videoBank.set("中国AI发展理念:以技术为本、智能向快。", "B");
videoBank.set("中国大模型首次用于地震救援,DeepSeek仅用7小时开发中缅应急系统。", "A");
videoBank.set("中国AI诊所落地沙特,全球首个AI诊所,覆盖30种呼吸疾病,诊断准确率近100%。", "A");
videoBank.set("AI技术法国家庭节电25%-30%,为联合国气候平台首批伙伴。", "A");
videoBank.set("未来AI注重普惠,缩小数字鸿沟、均衡发展。", "A");
videoBank.set("核心技术依赖进口、产品同质化;中小企业研发强度1.3%,远低于发达国家3%,难破高端市场。", "A");
videoBank.set("2025年基础理论重大突破、部分技术世界领先,成为产业升级经济转型主要动力。", "B");
videoBank.set("《“人工智能+制造”专项行动》保障:推进机制、资金支持、应用推广、监测评估。", "A");
videoBank.set("工业母机柔性化:AI深度融入数控,实现自适应作业、智能诊断、智能调度,大幅提升利用率精度。", "A");
videoBank.set("数据治理五步法:汇聚、清洗、标注、增强、治理。", "A");
videoBank.set("AI价值:驱动“新三样”从规模出海转向高质量出海,构建全球竞争壁垒。", "A");
videoBank.set("未来工厂三大特征:全流程自主运行、虚实实时同步、智能持续迭代。", "A");
videoBank.set("正视挑战、技术创新、标准统一,释放工业数据价值,实现智能化转型跨越。", "A");
// 专题2
videoBank.set("广东区域创新能力连续9年全国第一。", "A");
videoBank.set("广东高技术制造业占规上工业比重不足三成。", "B");
videoBank.set("耐心资本渠道:财政专项资金、政府投资基金、地方政府专项债、超长期特别国债。", "A");
videoBank.set("成长期赛道企业可在北交所、广东区域性股权市场上市。", "B");
videoBank.set("战略性新兴产业是新质生产力主要载体。", "A");
videoBank.set("核心驱动力:新质生产力推动战略性新兴产业持续升级。", "A");
videoBank.set("广东游戏营收、数字出版产值、动漫产值、电影票房等指标全国第一。", "A");
videoBank.set("截至2023年底,广东激光与增材制造产业有效发明专利全国第一。", "A");
videoBank.set("广东战略性新兴产业指导思想:加快从制造大省到制造强省转变。", "A");
videoBank.set("广东战略性新兴产业定位:打造世界先进水平产业基地。", "A");
videoBank.set("新质生产力理论是马克思主义生产力理论创新,丰富发展政治经济学。", "A");
videoBank.set("产业集群成长期竞争优势完全成熟,全球领先。", "B");
videoBank.set("新质生产力支撑仅含技术创新,不含数字化转型与人才支撑。", "B");
videoBank.set("2024年广东数字经济增加值占GDP持续提升,连续多年全国第一。", "B");
videoBank.set("工业机器人上游核心零部件:减速器、伺服电机、控制器,技术壁垒最高。", "A");
videoBank.set("小六轴机器人参与者少,国内暂无优势。", "B");
videoBank.set("腾讯音乐涨价短期拉动营收,不影响付费转化率。", "B");
videoBank.set("东伦敦科技城经验:无政府资金支持相关设施。", "B");
videoBank.set("新质生产力以数据为关键要素,强调知识创新应用。", "A");
videoBank.set("产业布局是产业发展规律体现,指一国地区产业空间分布组合。", "A");
videoBank.set("智能化技术底座:人工智能、大数据、云计算、5G、互联网。", "B");
videoBank.set("转型成效评估:结构优化、创新驱动、集群发展质量。", "A");
videoBank.set("家电产业链行为:产品差异化、并购、研发、价格行为。", "A");
videoBank.set("全球传统产业转移趋势:区域化、成本化、绿色化、多元化。", "A");
videoBank.set("广东传统产业升级原则:创新驱动、重点突破、开放合作、畅通循环、质效优先、绿色发展、市场主导、政府引导。", "B");
videoBank.set("构建高效服务业体系:推动传统服务向现代服务转型。", "A");
videoBank.set("佛山南海开通全国常态化无人机外卖航线。", "A");
videoBank.set("服务业4.0:大数据、AI、IoT驱动无缝服务演进。", "A");
videoBank.set("大都市创新城市:战略性新兴产业与服务业融合,推动产业升级高质量发展。", "A");
videoBank.set("2015-2020广东服务业增加值全国第一。", "A");
videoBank.set("广东服务业人才瓶颈:金融、信息、科研等领域高端人才短缺。", "A");
videoBank.set("现代服务业与先进制造、现代农业深度融合是优质服务业体系重点。", "A");
videoBank.set("服务业监管:企业自律+行业自律+政府监管+社会参与多元共治。", "A");
videoBank.set("1978-1999中国家电研发强、核心器件自主,不依赖进口。", "B");
videoBank.set("2019中国家电零售8910亿,行业成熟。", "A");
videoBank.set("2018-2024小米IoT与生活消费品收入占比25.1%→28.5%,结构多元。", "A");
videoBank.set("小米家电早期高性价比:入门定价、核心功能聚焦、快速抢占份额。", "A");
videoBank.set("2024小米之家约1.5万家,构建人车家全生态。", "A");
videoBank.set("小米电视低价互联网模式:硬件不赚钱,软件盈利。", "A");
videoBank.set("改革开放初期家电政策核心:扩大内需、全球化发展。", "B");
videoBank.set("2011-2019家电线上渠道10.4%→25.4%,社交电商、直播带货增长。", "A");
videoBank.set("我国人口转变特征:迅速性、政策干预、不彻底、不稳定、不平衡。", "A");
videoBank.set("广东20个战略性产业集群增加值占GDP17%,全国约40%。", "B");
videoBank.set("我国人口最多,人口发展事关民族复兴。", "A");
videoBank.set("中国人口转变2050年呈倒U型。", "A");
videoBank.set("人口高质量发展核心:规模、结构、质量、分布四维。", "B");
videoBank.set("经济高质量发展是中国式现代化物质根基与核心支撑。", "A");
videoBank.set("欧盟《新电池法》2024.8.17生效,禁止供应链碳足迹追溯。", "B");
videoBank.set("绿色转型阵痛:传统产业退出导致失业、地方财政下行等短期压力。", "A");
videoBank.set("截至2025年底氢燃料电池汽车销量近4万,实现0-1产业化突破。", "A");
videoBank.set("AI巡检提升效率8倍,成本降15%-30%。", "A");
videoBank.set("2026政策逻辑从控碳转向控能,核心惩罚能耗总量、限制发展空间。", "B");
videoBank.set("碳市场扩容后覆盖碳排放占比60%→30%+。", "B");
videoBank.set("高质量、绿色共生:摒弃高耗能高污染,实现经济生态协同。", "A");
videoBank.set("第一次生产力革命(蒸汽)标志:蒸汽机使用。", "A");
videoBank.set("工业5.0是工业4.0延续,新增绿色可持续与人机协作。", "A");
videoBank.set("欧盟模式核心武器:硅谷+华尔街,对标美国法条标准。", "B");
videoBank.set("上海土地充足、生活成本低,传统劳动密集产业有优势。", "B");
videoBank.set("合肥创新模式:市场雨林型,靠供应链与市场活力。", "B");
// ==============================================
// 悬浮面板
// ==============================================
function initDragPanel() {
panel = document.createElement('div');
panel.style.cssText = `position:fixed;top:10px;right:10px;z-index:999999;background:#ECFDF5;color:#065F46;border-radius:12px;box-shadow:0 6px 18px rgba(0,0,0,0.2);min-width:280px;user-select:none;overflow:hidden;`;
const dragBar = document.createElement('div');
dragBar.style.cssText = `padding:10px 14px;background:#10B981;color:white;font-size:14px;font-weight:bold;cursor:move;display:flex;justify-content:space-between;align-items:center;`;
dragBar.innerText = "公需课全自动(后台保活版)";
const miniBtn = document.createElement('button');
miniBtn.innerText = "−";
miniBtn.style.cssText = `background:none;border:none;color:white;font-size:16px;cursor:pointer;`;
const toggleBtn = document.createElement('button');
toggleBtn.style.cssText = `margin:8px 12px;padding:8px 12px;width:calc(100% - 24px);border:none;border-radius:8px;font-size:14px;font-weight:bold;background:#10B981;color:white;cursor:pointer;`;
toggleBtn.innerText = "✅ 运行中|点击暂停";
const contentWrap = document.createElement('div');
progressText = document.createElement('div');
progressText.style.cssText = "padding:12px 14px;font-size:15px;line-height:1.5;text-align:center;";
progressText.textContent = '✅ 准备就绪 | 支持后台';
contentWrap.appendChild(progressText);
contentWrap.appendChild(toggleBtn);
dragBar.appendChild(miniBtn);
panel.appendChild(dragBar);
panel.appendChild(contentWrap);
document.body.appendChild(panel);
let drag = false, ox, oy;
dragBar.onmousedown = e => { drag = true; ox = e.clientX - panel.getBoundingClientRect().left; oy = e.clientY - panel.getBoundingClientRect().top; panel.style.left = panel.getBoundingClientRect().left + "px"; panel.style.top = panel.getBoundingClientRect().top + "px"; panel.style.right = "auto"; };
document.onmousemove = e => { if (!drag) return; panel.style.left = e.clientX - ox + "px"; panel.style.top = e.clientY - oy + "px"; };
document.onmouseup = () => drag = false;
miniBtn.onclick = () => {
isMinimized = !isMinimized;
contentWrap.style.display = isMinimized ? "none" : "block";
miniBtn.innerText = isMinimized ? "□" : "−";
dragBar.innerText = isMinimized ? "公需课(后台挂着)" : "公需课全自动(后台保活版)";
dragBar.appendChild(miniBtn);
};
toggleBtn.onclick = () => {
scriptEnabled = !scriptEnabled;
const v = document.querySelector('video');
if (scriptEnabled) {
toggleBtn.innerText = "✅ 运行中|点击暂停";
toggleBtn.style.background = "#10B981";
v?.play().catch(() => { });
progressText.textContent = "✅ 继续运行 | 支持后台";
} else {
toggleBtn.innerText = "⏸ 已暂停|点击启动";
toggleBtn.style.background = "#6B7280";
v?.pause();
progressText.textContent = "⏸ 已暂停";
}
};
}
// ==============================================
// 超强真人行为模拟(防掉线)
// ==============================================
function humanLikeActivity() {
if (!scriptEnabled) return;
try {
const w = window.innerWidth, h = window.innerHeight;
const x = Math.random() * w * 0.8 + w * 0.1;
const y = Math.random() * h * 0.8 + h * 0.1;
document.dispatchEvent(new MouseEvent('mousemove', { clientX: x, clientY: y, bubbles: true }));
} catch (e) {}
try {
if (Math.random() > 0.6) {
window.scrollBy({ top: (Math.random() > 0.5 ? 1 : -1) * (Math.random() * 20 + 5), behavior: 'smooth' });
}
} catch (e) {}
try {
if (Math.random() > 0.85) {
const tg = document.querySelectorAll('body,div,section')[Math.floor(Math.random() * document.querySelectorAll('body,div,section').length)];
if (tg && !tg.closest('.layui-layer,.mylayer')) tg.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
} catch (e) {}
clearTimeout(activeTimer);
activeTimer = setTimeout(humanLikeActivity, Math.floor(Math.random() * 14000) + 13000);
}
function randDelay() {
return Math.floor(Math.random() * (QUICK_DELAY_MAX - QUICK_DELAY_MIN)) + QUICK_DELAY_MIN;
}
// ==============================================
// 视频答题:优先题库 → 多选组合火力覆盖
// ==============================================
function videoAnswer() {
if (!scriptEnabled || isAnswering) return;
const v = document.querySelector('video');
if (v && v.paused && !document.querySelector('.layui-layer,.mylayer-wrap')) {
v.play().catch(() => { });
}
const txt = document.body.innerText;
if (txt.includes('你已完成') || txt.includes('已完成观看')) {
bruteForceRound = 0;
const n = document.querySelector('#studySelectAct .next');
if (n) n.click();
return;
}
if (txt.includes('正确') || txt.includes('错误') || txt.includes('回答') || txt.includes('确定')) {
bruteForceRound = 0;
const btn = document.querySelector('.layui-layer-btn0,.layui-layer-btn1,.mylayer-btn');
if (btn) btn.click();
return;
}
const box = document.querySelector('.layui-layer,.mylayer-wrap');
if (!box) return;
const content = box.querySelector('.layui-layer-content,.mylayer-content');
const submit = box.querySelector('button[type="submit"],.btn,.layui-layer-btn0');
const radio = content?.querySelectorAll('input[type="radio"]');
const check = content?.querySelectorAll('input[type="checkbox"]');
if (!content || !submit || (radio.length < 2 && check.length < 2)) return;
isAnswering = true;
progressText.textContent = "📝 视频答题中";
const clean = s => s.replace(/\s+/g, '').replace(/[()()【】]/g, '');
const t = clean(content.innerText);
let ans = null;
for (const [k, v] of videoBank) {
const keyClean = clean(k);
if (keyClean.includes(t) || t.includes(keyClean)) {
ans = v;
break;
}
}
if (ans) {
setTimeout(() => {
if (radio.length >= 2) {
const target = ans === 'A' ? radio[0] : radio[1];
target.checked = true;
target.click();
target.dispatchEvent(new Event('change', { bubbles: true }));
}
submit.click();
setTimeout(() => { isAnswering = false; progressText.textContent = "✅ 答题成功"; }, 500);
}, randDelay());
return;
}
// 题库未命中 → 火力覆盖(单选+多选组合试错)
progressText.textContent = `⚠️ 题库未命中,遍历第${bruteForceRound + 1}轮`;
if (radio.length >= 2) {
radio.forEach(r => r.checked = false);
const idx = bruteForceRound % radio.length;
radio[idx].checked = true;
radio[idx].click();
} else if (check.length >= 2) {
check.forEach(c => c.checked = false);
let sel = [];
switch(bruteForceRound){
case 0: sel=[0]; break;
case 1: sel=[1]; break;
case 2: sel=[0,1]; break;
case 3: sel=[2]; break;
case 4: sel=[0,2]; break;
case 5: sel=[1,2]; break;
case 6: sel=[0,1,2]; break;
case 7: sel=[3]; break;
case 8: sel=[0,3]; break;
case 9: sel=[1,3]; break;
case 10: sel=[2,3]; break;
case 11: sel=[0,1,2,3]; break;
}
sel.forEach(i=>{ if(check[i]){ check[i].checked=true; check[i].click(); } });
}
submit.click();
bruteForceRound = (bruteForceRound + 1) % MAX_BRUTE_ROUND;
setTimeout(() => { isAnswering = false; }, 500);
}
// ==============================================
// 【多选题终极修复】不会消失、不会被清空
// ==============================================
function examAutoInOrder() {
if (!scriptEnabled || isAnswering) return;
const topicList = document.getElementById('topicList');
if (!topicList) return;
const ansList = getExamAnswers();
const items = Array.from(topicList.querySelectorAll('.m-topic-item'));
if (items.length < 10) return;
progressText.textContent = `📝 考核答题中(共${items.length}题)`;
const map = { A: 0, B: 1, C: 2, D: 3, E: 4 };
(async () => {
isAnswering = true;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const ans = ansList[i]?.trim().toUpperCase();
if (!ans) continue;
progressText.textContent = `第${i + 1}题:${ans}`;
const r = item.querySelectorAll('input[type="radio"]');
const c = item.querySelectorAll('input[type="checkbox"]');
// 单选不变
if (r.length > 0) {
r.forEach(x => x.checked = false);
const idx = map[ans[0]];
if (idx !== undefined && r[idx]) {
r[idx].checked = true;
r[idx].click();
}
}
// 多选:强制锁定勾选,网站无法清空
if (c.length > 0) {
// 先解除网站监听
c.forEach(x => {
x.onchange = null;
x.onclick = null;
});
c.forEach(x => x.checked = false);
for (let ch of ans) {
const idx = map[ch];
if (idx !== undefined && c[idx]) {
c[idx].checked = true;
c[idx].dispatchEvent(new Event('click', { bubbles: true }));
}
}
}
await new Promise(resolve => setTimeout(resolve, randDelay()));
}
progressText.textContent = "✅ 已全部答完,请手动提交";
isAnswering = false;
})();
}
// ==============================================
// 视频监控 & 自动下一节
// ==============================================
function videoMonitor() {
if (!scriptEnabled) return;
const v = document.querySelector('video');
if (!v) {
if (document.body.innerText.includes('考核') || document.getElementById('topicList')) {
progressText.textContent = "⏳ 进入考核,自动答题";
setTimeout(examAutoInOrder, 1000);
} else {
progressText.textContent = "⏳ 等待视频加载";
}
return;
}
const p = v.duration ? Math.round(v.currentTime / v.duration * 100) : 0;
progressText.textContent = `▶ 观看进度 ${p}% | 后台可挂`;
if (p >= 100) videoWatched = true;
const next = document.querySelector('#studySelectAct .next');
if (FORCE_WATCH_END && !videoWatched && next) {
next.style.pointerEvents = 'none';
next.style.opacity = '0.5';
} else if (next) {
next.style.pointerEvents = 'auto';
next.style.opacity = '1';
}
}
function autoNext() {
if (!scriptEnabled) return;
const v = document.querySelector('video');
const popup = document.querySelector('.layui-layer,.mylayer-wrap');
if (v && !popup && v.ended && videoWatched) {
const n = document.querySelector('#studySelectAct .next');
if (n && !n.innerText.includes('考核')) n.click();
}
}
// ==============================================
// 主循环
// ==============================================
function main() {
checkAutoRefresh();
videoAnswer();
videoMonitor();
}
// 启动
enableBackgroundKeepAlive();
initDragPanel();
humanLikeActivity();
setInterval(main, 700);
setInterval(autoNext, 1000);
})();
@@ -0,0 +1,140 @@
"""广东省教师继续教育平台抓包工具(一次性取证用,不参与运行)。
用法(在仓库根目录执行):
python src/study_tool/plugins/gdgxk/script/capture-jsglpt.py [分钟数]
行为:开一个有头 Chromium(持久 profile,登录态会保留),打开 jsglpt 登录页,
把你在窗口里操作期间产生的 XHR/fetch 全量落到 data/capture-jsglpt-<时间>.jsonl,
同时每 3 秒把 cookie 快照写到 data/capture-jsglpt-cookies.json。
"""
import json
import os
import sys
import time
from playwright.sync_api import sync_playwright
# 脚本在 src/study_tool/plugins/gdgxk/script/,往上 5 层是仓库根
ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), *[".."] * 5))
DATA = os.path.join(ROOT, "data")
LOGIN = "https://jsglpt.gds.edu.cn/login.jsp"
MINUTES = float(sys.argv[1]) if len(sys.argv) > 1 else 40
os.makedirs(DATA, exist_ok=True)
STAMP = time.strftime("%Y%m%d-%H%M%S")
JSONL = os.path.join(DATA, "capture-jsglpt-%s.jsonl" % STAMP)
COOKIES = os.path.join(DATA, "capture-jsglpt-cookies.json")
PROFILE = os.path.join(DATA, ".gd-profile")
out = open(JSONL, "a", encoding="utf-8")
seen = 0
def emit(rec):
global seen
seen += 1
rec["n"] = seen
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
out.flush()
print(
" %-4s %s %s -> %s"
% (rec.get("rt", "doc"), rec.get("method", ""), rec.get("url", "")[:150], rec.get("status", "")),
flush=True,
)
def on_response(r):
try:
req = r.request
rt = req.resource_type
if rt not in ("xhr", "fetch", "document"):
return
h = {}
try:
h = dict(r.headers or {})
except Exception:
pass
body = ""
# 只留文本类响应,视频/图片跳过
if (
not (r.url or "")
.lower()
.split("?")[0]
.endswith((".mp4", ".m3u8", ".ts", ".png", ".jpg", ".gif", ".woff", ".woff2", ".css", ".js"))
):
try:
body = r.text()[:8000]
except Exception:
body = "<binary>"
emit(
{
"t": time.strftime("%H:%M:%S"),
"rt": rt,
"method": req.method,
"url": r.url,
"req_headers": dict(req.headers or {}),
"req": (req.post_data or "")[:4000],
"status": r.status,
"resp_headers": h,
"resp": body,
}
)
except Exception:
pass
def main():
print("抓包输出:%s" % JSONL, flush=True)
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
PROFILE,
headless=False,
args=["--disable-blink-features=AutomationControlled"],
viewport={"width": 1440, "height": 900},
)
hooked = set()
def hook(page):
if id(page) in hooked:
return
hooked.add(id(page))
page.on("response", on_response)
print("[页面] %s" % (page.url or "about:blank"), flush=True)
ctx.on("page", hook)
pg = ctx.pages[0] if ctx.pages else ctx.new_page()
hook(pg)
try:
pg.goto(LOGIN, wait_until="domcontentloaded", timeout=60000)
except Exception as e:
print("打开登录页失败:%s" % e, flush=True)
last = 0.0
t0 = time.time()
while time.time() - t0 < MINUTES * 60:
try:
now = time.time()
if now - last > 3:
last = now
with open(COOKIES, "w", encoding="utf-8") as f:
json.dump(ctx.cookies(), f, ensure_ascii=False, indent=1)
ctx.pages[0].wait_for_timeout(1000)
except Exception:
# 用户把所有页面关掉了,收工
break
try:
with open(COOKIES, "w", encoding="utf-8") as f:
json.dump(ctx.cookies(), f, ensure_ascii=False, indent=1)
except Exception:
pass
try:
ctx.close()
except Exception:
pass
out.close()
print("抓包结束:%d 条 → %s" % (seen, JSONL), flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,54 @@
"""广东公需课自测:用浏览器抓到的 cookie 走一遍 列表 → 发现 → 心跳。
用法(在仓库根目录执行):
PYTHONPATH=src python src/study_tool/plugins/gdgxk/script/test-gdgxk.py [课件id]
cookie 来自 capture-jsglpt.py 落下的 data/capture-jsglpt-cookies.json。
"""
import json
import sys
import uuid
from study_tool.plugins.gdgxk.main import PLATFORM as gdgxk
from study_tool.plugins.gdgxk.utils import course, http
cid = sys.argv[1] if len(sys.argv) > 1 else ""
cookies = json.load(open("data/capture-jsglpt-cookies.json", encoding="utf-8"))
s = gdgxk.session(cookies)
courses = gdgxk.list_courses(s)
print("课程数:", len(courses))
for c in courses:
print(" ", c["plan_id"], c["name"])
if not cid and courses:
cid = courses[0]["plan_id"]
d = gdgxk.discover(s, cid, "")
print("\n课程:", d["course"])
print("进度:", d["progress"])
print("视频:", d["info"])
cw = d["courseware"][0]
print("课件:", cw["id"], "| 总长", cw["total"], "| 已看", cw["viewTime"])
sid = uuid.uuid4().hex
sign = course.sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, cw["uid"])
print("\n播放注册签名:", sign)
print("服务端已看秒数:", course.view_time(s, cw, cw["uid"], sign))
r = http.xhr(
s,
"POST",
http.X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1",
data={
"_method": "PUT",
"aid": cw["aid"],
"isLimit": "false",
"md5": sign["md5"],
"expires": sign["time"],
"md5Uid": cw["uid"],
"lastViewTime": str(min(cw["viewTime"] + 60, cw["total"])),
"playbackSessionId": sid,
},
)
print("\n心跳响应:", r.status_code, r.text[:300])
@@ -0,0 +1,7 @@
"""广东公需课插件的实现模块:`main.py` 只留 `Platform` 子类,工具按阶段拆到这里。
http.py 域名 / UA / 平台 id,XHR 上报封装与统一过期异常
login.py base64 账号密码 + 图形验证码 + ITLMS 换票
course.py 课程列表卡片、学习页上报参数、进度页活动清单
learn.py 活动页打开 → 注册播放 → 1x 心跳上报
"""
@@ -0,0 +1,17 @@
"""公需课题库:题库 JSON 放 plugins/gdgxk/bank/(明年换题库就加一个新文件),匹配与 AI 兜底走核心 bank。"""
from functools import lru_cache
from pathlib import Path
from study_tool import bank as qbank
BANK_DIR = Path(__file__).resolve().parents[1] / "bank"
@lru_cache(maxsize=1)
def load():
return qbank.load(BANK_DIR)
def answer(key, questions):
"""题库优先、没命中走大模型,返回 {题号: {"answer": ["A"], "source": "bank"|"llm"}}。"""
return qbank.answer(key, questions, load())
@@ -0,0 +1,93 @@
"""课程页解析:课程列表卡片、学习页上报参数、进度页活动清单。"""
import re
from . import http
def parse_courses(html):
"""课程列表页:每个 `showCourse('id','名称')` 前面就是这张卡片的信息。"""
out = []
for m in re.finditer(r"showCourse\('([^']+)','([^']*)'\)", html):
block = html[max(0, m.start() - 2500):m.start()]
year = re.search(r"培训年度:\s*<[^>]*>(\d{4})<", block)
hours = re.search(r"课程学时:\s*<[^>]*>(\d+)<", block)
st = re.search(r">\s*(未完成|已完成)\s*<", block)
out.append({
"plan_id": m.group(1),
"project_id": "",
"name": m.group(2).strip(),
"section": ("%s年公需课" % year.group(1)) if year else "",
"status_label": st.group(1) if st else "",
"info": ("%s 学时" % hours.group(1)) if hours else "",
"in_study": True,
"platform": http.PLATFORM_ID,
})
return out
def parse_course(html):
"""从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、要求时长、已看秒数。"""
d = {}
m = re.search(r"/a_([0-9a-f]{32})/study/video/user/", html)
if not m:
raise RuntimeError("学习页里没有找到课件上报路径,页面结构可能变了")
d["aid"] = "a_" + m.group(1)
m = re.search(r"updateVideoStatus1'[\s\S]{0,200}?id=([0-9a-f]{32})", html) \
or re.search(r"id=([0-9a-f]{32})&md5Uid=", html)
if not m:
raise RuntimeError("学习页里没有找到课件 id")
d["id"] = m.group(1)
m = re.search(r"md5Uid=([0-9a-f]{64,128})", html)
if not m:
raise RuntimeError("学习页里没有找到 md5Uid")
d["uid"] = m.group(1)
# `t` 是服务端记的「已看秒数」,不是总长;完成门槛是页面上的「要求观看视频 N 分钟」
m = re.search(r"\bt\s*=\s*'(\d+)'", html)
d["viewTime"] = int(m.group(1)) if m else 0
m = re.search(r"要求观看视频<span>(\d+)</span>分钟", html)
d["total"] = int(m.group(1)) * 60 if m else 0
m = re.search(r"url1\s*=\s*'([^']+)'", html)
d["video"] = m.group(1) if m else ""
d["title"] = (re.search(r"<title>([^<]*)</title>", html).group(1).strip()
if "<title>" in html else "")
return d
def sign(s, cw, uid):
"""updateVideoStatus1:注册播放权,换回心跳签名(time / md5)。"""
r = http.xhr(s, "POST", http.X + "/" + cw["aid"] + "/study/video/user/updateVideoStatus1",
data={"_method": "PUT", "id": cw["id"], "md5Uid": uid,
"playbackSessionId": cw["sid"], "playbackTakeover": "true"})
j = r.json() if r.text.strip().startswith("{") else {}
if j.get("responseCode") != "00":
raise RuntimeError("注册播放失败:%s" % (r.text[:120] or "空响应"))
return j["responseData"]
def view_time(s, cw, uid, sign):
r = http.xhr(s, "GET", http.X + "/" + cw["aid"] + "/study/video/user/getViewTime",
params={"id": cw["id"], "md5": sign.get("md5"), "expires": sign.get("time"),
"md5Uid": uid})
j = r.json() if r.text.strip().startswith("{") else {}
rd = j.get("responseData")
if isinstance(rd, dict):
return int(rd.get("viewTime") or 0)
return int(rd or 0)
def progress(s, cid):
"""学习进度页:活动总数、已完成数、每个活动(aid / 名称 / 是否完成)。"""
html = s.get(http.X + "/" + cid + "/study/course/progess", timeout=30).text
if "studyCourse(" not in html:
raise http.expired("学习平台会话已失效,请重新登录")
m = re.search(r"\$\('#actNum'\)\.text\('(\d+)'\)", html)
total = int(m.group(1)) if m else 0
m = re.search(r"\$\('#completeActNum'\)\.text\('(\d+)'\)", html)
done = int(m.group(1)) if m else 0
acts = []
for row in re.findall(r"<tr[^>]*>([\s\S]*?)</tr>", html):
m = re.search(r"studyCourse\('[^']*','[^']*','(a_[0-9a-f]{32})'\)[^>]*>([^<]+)<", row)
if m:
acts.append({"aid": m.group(1), "title": m.group(2).strip(),
"done": "u-ico-finish" in row})
return {"total": total or len(acts), "done": done, "acts": acts}
+166
View File
@@ -0,0 +1,166 @@
"""课程考核:题目内嵌在课程学习页 HTML 里,交卷是 POST finishTest2(Spring 的 _method=put)。
页面结构(服务端渲染,非 JSON 接口):
form#testForm[action=.../ncts/test/delivery/<id>/finishTest2]
input[type=hidden][name=qti_item_Q1:2:1_RESPONSE]
#topicList .m-topic-item × 30
h3.title > .item-num + .qs-type([单选题]/[多选题]/[是非题])+ 题干
ol.m-question-lst > li > label > span「A、选项」+ input[name=qti_response_Q1:2:1_RESPONSE value=Choice0]
"""
import re
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from study_tool import runtime
from . import bank as qbank
from . import http
KINDS = (("多选", "multi"), ("是非", "judge"), ("单选", "single"))
# 前端测验弹窗用的题型码:01 单选 / 02 多选 / 03 判断
TYPE_CODE = {"single": "01", "multi": "02", "judge": "03"}
SCORE = re.compile(r"已得[::]\s*(\d+(?:\.\d+)?)\s*分")
def finished(html):
"""页面上已经显示「您已完成测验 / 已得 N 分」就不再重做。"""
return "已完成测验" in html or bool(SCORE.search(html))
def score(html):
m = SCORE.search(html)
return float(m.group(1)) if m else None
def parse(html):
"""课程学习页 → 考核表单;没有考核返回 None。"""
soup = BeautifulSoup(html, "lxml")
form = soup.find("form", id="testForm")
if not form:
return None
d = {"action": urljoin(http.X + "/", form.get("action") or ""), "fields": {}, "questions": []}
# 隐藏字段照原样回传:form 里的全收,qti_item_* 再按整页兜一次(题块可能不在 form 内)
for i in form.select("input[type=hidden]") + soup.select("input[type=hidden][name^=qti_item_]"):
if i.get("name"):
d["fields"].setdefault(i["name"], i.get("value") or "")
for it in soup.select("#topicList .m-topic-item"): # 题目列表不一定包在 form 里,按整页找
h3 = it.select_one("h3.title")
if not h3:
continue
kind = next((v for k, v in KINDS if k in h3.get_text()), "single")
for tag in h3.select(".item-num, .qs-type"):
tag.decompose()
opts = []
for li in it.select(".m-question-lst > li"):
inp = li.find("input")
if not inp:
continue
span = li.select_one("span")
text = (span or li).get_text(strip=True)
m = re.match(r"([A-F])[、..]\s*(.*)", text)
opts.append({
"key": m.group(1) if m else "",
"text": (m.group(2) if m else text).strip(),
"value": inp.get("value"),
"name": inp.get("name"),
})
d["questions"].append({
"no": len(d["questions"]) + 1,
"type": kind,
"title": h3.get_text(" ", strip=True),
"options": opts,
})
return d
def answer(key, ex):
"""题库优先、没命中走大模型,再把选项字母换成平台提交值(Choice0..4)。"""
got = qbank.answer(key, ex["questions"])
out = {}
for q in ex["questions"]:
hit = got.get(str(q["no"]))
if not hit:
continue
vals = [o["value"] for o in q["options"] if o["key"] in hit["answer"]]
if vals:
out[q["no"]] = vals
return out
def submit(s, ex, answers):
"""按页面 ajaxSubmit 的做法交卷:POST 表单 action,body 带 _method=put。"""
pairs = [("_method", "put")] + list(ex["fields"].items())
for q in ex["questions"]:
name = q["options"][0]["name"] if q["options"] else ""
for v in answers.get(q["no"], []):
pairs.append((name, v))
r = s.post(
ex["action"],
data=pairs,
headers={"X-Requested-With": "XMLHttpRequest", "Referer": ex["action"]},
timeout=40,
)
if "loginBtn" in r.text and len(r.text) < 3000:
raise http.expired("交卷时会话已失效,请重新登录")
return r.text
def submit_letters(s, url, letters):
"""按用户在弹窗里选的字母答案交卷:重取一次考核页拿隐藏字段,保证表单是当前会话的。"""
html = s.get(url, timeout=30).text
ex = parse(html)
if not ex or not ex["questions"]:
return {"ok": False, "error": "重新取题失败,无法提交"}
answers = {}
for q in ex["questions"]:
raw = str((letters or {}).get(str(q["no"])) or "").strip().upper()
vals = [o["value"] for o in q["options"] if o["key"] and o["key"] in raw]
if vals:
answers[q["no"]] = vals
if not answers:
return {"ok": False, "error": "没有可提交的答案"}
back = submit(s, ex, answers)
return {
"ok": True,
"answers": len(answers),
"total": len(ex["questions"]),
"score": score(back),
"msg": "提交成功",
}
def run(key, s, url, auto=True):
"""取考核活动页 → 解析考核 → 答题 → 交卷。返回 (会话, 结果说明);没有考核或没做完返回空串。
考核是独立活动页(`/<aid>/study/course/<cid>`),课程主页上没有 testForm。
题库没覆盖全部题目时不交卷(auto=False 或缺口 > 0),只记 warn,避免瞎蒙拉低分数。
"""
html = s.get(url, timeout=30).text
if finished(html):
sc = score(html)
return s, "考核已完成" + (",得分 %s" % sc if sc is not None else "")
ex = parse(html)
if not ex or not ex["questions"]:
return s, ""
answers = answer(key, ex)
miss = [q["no"] for q in ex["questions"] if q["no"] not in answers]
if miss:
runtime.log(
key,
"考核共 %d 题,%d 题没答案(第 %s 题),本次不交卷"
% (len(ex["questions"]), len(miss), "、".join(map(str, miss[:10]))),
level="warn",
)
return s, ""
if not auto:
runtime.log(key, "考核已填好 %d 题,按配置不自动交卷" % len(answers))
return s, ""
back = submit(s, ex, answers)
sc = score(back)
msg = "考核已交卷" + (",得分 %s" % sc if sc is not None else "")
if sc is not None and sc < 60:
runtime.log(key, msg + "(低于 60 分,需人工重考)", level="warn")
else:
runtime.log(key, msg)
return s, msg
@@ -0,0 +1,19 @@
"""请求底座:域名 / UA / 平台 id,以及上报接口的 XHR 封装。"""
from study_tool.platforms.errors import SessionExpired
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36")
G = "https://jsglpt.gds.edu.cn"
X = "https://jsxx.gds.edu.cn"
PLATFORM_ID = "gdgxk"
def xhr(s, method, url, **kw):
"""这些上报接口必须带 Referer(指向课程页),否则直接 500。"""
kw.setdefault("headers", {})["X-Requested-With"] = "XMLHttpRequest"
return s.request(method, url, timeout=30, **kw)
def expired(msg):
"""统一错误类型:引擎据此触发续期 / 重新登录。"""
return SessionExpired(msg)
@@ -0,0 +1,86 @@
"""学习过程:活动页打开 → 注册播放 → 按墙钟推的 1x 心跳上报。"""
import time
import uuid
from study_tool import runtime
from . import course, http, login
def stopped(key):
with runtime.RUNS_LOCK:
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
def wait(key, seconds):
"""可中断的等待;期间被停止返回 False。"""
for _ in range(seconds):
if stopped(key):
return False
time.sleep(1)
return True
def study_one(key, s, cw, speed, beat):
"""打开活动页 → 注册播放 → 1x 心跳;会话失效时抛 SessionExpired 交给上层重登。"""
try:
login.sso(s) # 一门课要跑几十分钟到几小时,每个活动前续一次票
except Exception:
pass # 续票失败但老会话还能用就继续
html = s.get(cw["href"], timeout=30).text
if "loginBtn" in html and "md5Uid" not in html:
raise http.expired("学习页要求重新登录")
try:
info = course.parse_course(html)
except Exception as e:
raise RuntimeError("不是视频活动(%s)" % e)
if not info["total"]:
raise RuntimeError("页面上没有「要求观看视频 N 分钟」")
cw.update(id=info["id"], uid=info["uid"], total=info["total"], viewTime=info["viewTime"])
s.headers["Referer"] = cw["href"]
sign = course.sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": uuid.uuid4().hex}, cw["uid"])
cur = course.view_time(s, {"aid": cw["aid"], "id": cw["id"]}, cw["uid"], sign)
runtime.log(key, "开始学习:%s(要求 %d 秒,已看 %d 秒)" % (cw["title"], cw["total"], cur))
if cur < cw["total"]:
learn(key, s, cw, speed, beat)
else:
runtime.log(key, "服务端已记 %d 秒,达到要求,等平台刷新完成状态" % cur, level="warn")
def learn(key, s, cw, speed, beat):
"""心跳上报。平台只按真实流逝时间放行(1x),所以目标值按墙钟推,不靠每拍累加。"""
uid, total = cw["uid"], cw["total"]
s.headers["Referer"] = cw.get("href") or http.X
sid = uuid.uuid4().hex
base = {"_method": "PUT", "aid": cw["aid"]}
sign = course.sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, uid)
cur = course.view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
start, t0, stall = cur, time.time(), 0
while cur < total:
if stopped(key):
runtime.log(key, "已按要求停止")
return
time.sleep(beat)
want = min(total, start + int((time.time() - t0) * speed))
data = dict(base, isLimit="false", md5=sign.get("md5"), expires=sign.get("time"),
md5Uid=uid, lastViewTime=str(max(want, cur)), playbackSessionId=sid)
r = http.xhr(s, "POST", http.X + "/" + cw["aid"] + "/study/video/user/" + cw["id"]
+ "/updateViewTime1", data=data)
if "loginBtn" in (r.text or "") or not (r.text or "").strip().startswith("{"):
raise http.expired("上报被踢回登录页(HTTP %s)" % r.status_code)
j = r.json()
rd = j.get("responseData") or {}
if j.get("responseCode") == "00" and rd.get("md5"):
sign = {"md5": rd["md5"], "time": rd.get("time")}
before = cur
cur = course.view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
pct = min(100, int(cur * 100 / total)) if total else 0
runtime.log(
key,
"进度上报:平台已记 %d/%d 秒(%d%%),本次上报到 %d 秒" % (cur, total, pct, want),
)
stall = stall + 1 if cur <= before else 0
if stall >= 5:
runtime.log(key, "连续 5 次上报都没被平台接受,先停这个活动", level="warn")
return
runtime.log(key, "课件完成:%s(平台已记 %d/%d 秒)" % (cw["title"], cur, total))
@@ -0,0 +1,52 @@
"""登录链路:base64 账号密码、图形验证码识别、ITLMS 换票。"""
import base64
import re
import time
from . import http
_OCR = None
def b64(text):
return base64.b64encode(str(text).encode("utf-8")).decode()
def ocr():
"""4 位图形验证码:ddddocr 离线识别。"""
global _OCR
if _OCR is None:
try:
import ddddocr
except Exception as e:
raise RuntimeError("登录要过图形验证码,需要先装 ddddocr(pip install ddddocr):%s" % e)
_OCR = ddddocr.DdddOcr(show_ad=False)
return _OCR
def captcha(s):
raw = s.get(http.G + "/login/readCaptcha.action?d=%.6f" % time.time(), timeout=30).text
return base64.b64decode(raw.strip().strip('"').split(",", 1)[-1])
def login_error(s):
html = s.get(http.G + "/login.jsp", timeout=30).text
m = re.search(r'id="errorText"[^>]*>([^<]*)<', html)
return (m.group(1).strip() if m else "")
def sso(s):
"""jsglpt 会话换 ITLMS 票,票按 `ITLMS-xxx||ticketId` 两段交给 jsxx。
交接入口来自 jsglpt 首页那个隐藏表单:
`GET https://jsxx.gds.edu.cn/nts/loginToStoreByAccount?ticket=&ticketId=`
"""
r = s.post(http.G + "/teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=", timeout=30)
ticket = (r.text or "").strip()
if not ticket.startswith("ITLMS-") or "||" not in ticket:
raise http.expired("取 ITLMS 票失败:%s" % (ticket[:80] or "空响应"))
t, tid = ticket.split("||", 1)
q = s.get(http.X + "/nts/loginToStoreByAccount", params={"ticket": t, "ticketId": tid}, timeout=30)
if "logout" not in q.text and "我的课程" not in q.text:
raise http.expired("带票进学习平台失败,票可能已失效")
return s
@@ -0,0 +1,57 @@
"""选课兜底:课程列表里没有目标年度的课时,去选课超市 /uc/store/course/ 挑一门选上。
配置在插件目录 `config.json`:`year` 目标年度(空 = 当前年份),`pick` 都没选时选第几门(从 1 开始,默认 1)。
页面状态:按钮「进入学习」= 这门就是已选的课;「已有选课」= 已经选了另一门;其余视为未选。
"""
import json
import re
from datetime import date
from pathlib import Path
from urllib.parse import urljoin
from study_tool import runtime
from . import http
CONFIG = Path(__file__).resolve().parents[1] / "config.json"
STORE = http.X + "/uc/store/course/"
def config():
c = json.loads(CONFIG.read_text("utf-8")) if CONFIG.exists() else {}
return str(c.get("year") or date.today().year), max(int(c.get("pick") or 1), 1)
def _get(s, url):
s.headers["Referer"] = STORE
return s.get(url, timeout=30).text
def ensure(s):
"""选课超市里已有选中的课就直接返回 True;都没选就按 pick 选一门。成功返回 True。"""
year, pick = config()
html = _get(s, STORE)
hrefs = re.findall(r'href=["\']([^"\']*store/course/[^"\']+)["\']', html)
urls = [u for u in dict.fromkeys(urljoin(STORE, h) for h in hrefs) if u.rstrip("/") != STORE.rstrip("/")]
if not urls:
runtime.log("system", "公需课选课:选课超市里没有解析到课程链接(%d 字节)" % len(html), "warn")
return False
pages = [(u, _get(s, u)) for u in urls]
if any("进入学习" in p for _, p in pages):
runtime.log("system", "公需课选课:%s 年度已有选中的课程,无需再选" % year)
return True
url, page = pages[min(pick, len(pages)) - 1]
if "已有选课" in page:
runtime.log("system", "公需课选课:已经选了其他课程,页面不允许再选", "warn")
return False
m = re.search(r"<(a|button)\b([^>]*)>[^<]*选课[^<]*</\1>", page)
attrs = m.group(2) if m else ""
target = re.search(r'href=["\']([^"\']+)["\']', attrs)
target = target.group(1) if target and not target.group(1).startswith(("javascript", "#")) else None
target = target or (re.search(r'["\'](/[^"\']+|https?://[^"\']+)["\']', attrs) or [None, None])[1]
if not target:
runtime.log("system", "公需课选课:没有在课程页找到选课按钮的请求地址,页面结构可能变了", "warn")
return False
r = s.get(urljoin(url, target), headers={"Referer": url}, timeout=30)
runtime.log("system", "公需课选课:已选第 %d 门(%s),响应 %d:%s" % (pick, url, r.status_code, r.text[:120]))
return r.ok
@@ -0,0 +1 @@
"""莞易学插件:`main.py` 注册平台,`utils/` 放按阶段拆分的工具,`detect.py` 识别页面代际。"""
@@ -1,11 +1,14 @@
"""抓包:把页面 XHR/fetch 落成 data/capture-*.json。"""
import json
import os
import time
from urllib.parse import urlsplit
from study_tool import store
from study_tool.config import *
from study_tool.runtime import RUNS, log
from ... import database
from study_tool.runtime import RUNS, log, set_state
from .config import STUDY
# ------------------------------------------------------- 抓包(排查平台行为)
CAPTURES = {}
@@ -23,8 +26,7 @@ def capture_worker(key, cookies, minutes=10):
log(key, "抓包需要 playwright:%s" % e)
return
recs = CAPTURES.setdefault(key, [])
log(key, "抓包开始:浏览器里手动走一遍(点课程 → 播几秒 → 交评价),"
"完事点「停止抓包」,最多 %d 分钟" % minutes)
log(key, "抓包开始:浏览器里手动走一遍(点课程 → 播几秒 → 交评价),完事点「停止抓包」,最多 %d 分钟" % minutes)
def on_resp(r):
try:
@@ -37,9 +39,16 @@ def capture_worker(key, cookies, minutes=10):
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})
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:
@@ -52,9 +61,16 @@ def capture_worker(key, cookies, minutes=10):
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 "/"}])
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
@@ -83,8 +99,7 @@ def capture_worker(key, cookies, minutes=10):
path = ""
if recs:
try:
path = os.path.join(store.DATA_DIR,
"capture-%s-%s.json" % (key, time.strftime("%Y%m%d-%H%M%S")))
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:
@@ -1,18 +1,13 @@
"""全局常量 / 环境变量。
"""莞易学插件常量:站点地址、登录授权入口、课程板块、页面快照目录。
平台地址、默认值只在这里写一份,其它模块 `from study_tool.config import *`。
只给 `plugins/guanyixue/` 内部用;其它平台各自在自己的插件目录里放常量。
"""
import os
import re
import shutil
# 包内路径 -> 项目根目录:data / static / script 这些运行时资源都留在仓库根
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
DATA = os.path.join(ROOT, "data")
LOGDIR = os.path.join(DATA, "logs") # 旧版日志目录;只有首次迁移会读它
STATIC_DIR = os.path.join(ROOT, "static")
os.makedirs(LOGDIR, exist_ok=True)
from study_tool.config import DATA
def _find_node():
"""Locate a node.exe: $NODE_BIN > PATH > common install dirs."""
@@ -26,7 +21,7 @@ def _find_node():
NODE = _find_node()
DESENC = os.path.join(ROOT, "script", "desenc.js")
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")
@@ -67,7 +62,3 @@ PLAN_STATUS = [
]
# 只有这两种状态还能进去学;已结束的只能查看
STUDYABLE_STATUS = {"TAKING_PLAN", "NO_BEGAIN_PLAN"}
# 平台层错误类型;老代码 `from study_tool.config import *` 仍然拿得到 SessionExpired
from study_tool.platforms.errors import SessionExpired # noqa: E402
@@ -9,8 +9,14 @@
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_MARKERS = (
"/training/curriculum",
"/training/courseVideo",
"/training/comment",
"static/js/app.",
"study.dgjy.net/training",
"study.dgjy.net/#/",
)
SPA_HOST = "study.dgjy.net"
+70
View File
@@ -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())
@@ -1,4 +1,5 @@
"""测验:题目解析、交卷、AI 答题(流式)。"""
import json
import re
import time
@@ -6,20 +7,66 @@ 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 study_tool.config import *
from study_tool.llm import *
from study_tool.models import *
from study_tool.platforms import registry
from study_tool.pugins.guanyixue.jspx import new_session, parse_ids
from study_tool.pugins.guanyixue.study import StudyClient
from study_tool.runtime import log
from .config import JSPX, STUDY
from .utils.course import parse_ids
from .utils.http import new_session
from .utils.spa import StudyClient
# ------------------------------------------------------------------ quiz
STUDY_QMAP = {"SINGLECHOICE": "01", "MULTIPLECHOICE": "02", "JUDGMENT": "03",
"TRUEFALSE": "03", "FILLIN": "04", "SHORTANSWER": "04", "ESSAY": "04"}
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 下标表示。
@@ -40,8 +87,7 @@ def answer_tokens(raw, options=None, qtype="01"):
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])]
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:
@@ -87,16 +133,19 @@ def study_parse_paper(data):
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))
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})
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
@@ -168,15 +217,15 @@ def study_parse_review(data, show_answers=True):
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")))
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("#")) - {""})
is_correct = (set(picked.split("#")) - {""}) == (set(correct.split("#")) - {""})
else:
correct = _answer_text(it.get("answer"))
try:
@@ -187,16 +236,20 @@ def study_parse_review(data, show_answers=True):
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 "")})
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
@@ -212,8 +265,12 @@ def parse_quiz_html(html):
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"))
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:
@@ -227,12 +284,16 @@ def parse_quiz_html(html):
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")})
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
@@ -253,6 +314,8 @@ def api_quiz(b: QuizIn):
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)
@@ -260,7 +323,11 @@ def api_quiz(b: QuizIn):
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": "这门课没有测验"}
@@ -281,10 +348,16 @@ def api_quiz(b: QuizIn):
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}
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=""):
@@ -300,15 +373,18 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
这些和页面上「开始考试 / 查看试卷」发的是同一条请求,所以纯 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))
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:
@@ -322,14 +398,15 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
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 ""})
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])
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:
@@ -339,12 +416,14 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
# ---- 重考:已交卷且还有剩余次数,start/do 会生成一份新答卷 ----
if retake:
if not t.get("done") and t.get("testpaper_result_id"):
return {"ok": False, "code": "doing",
"error": "还有一份未交卷的答卷,先交卷再重考"}
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)}
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,无法重考"}
@@ -353,13 +432,12 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
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": "平台没有生成新答卷(可能次数已用完或需要老师重置)"}
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 会校准
left = max(0, left - 1) # 先本地减 1;交卷后重新 discover 会校准
rid = new_rid
else:
rid = str(t.get("testpaper_result_id") or "")
@@ -367,12 +445,10 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
# ---- 第一次取题:还没有答卷就自动创建 ----
if not rid:
if t.get("done"):
return {"ok": False, "code": "no_result",
"error": "这个测验已完成,但平台没有返回答卷 ID,无法再看题目"}
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,无法自动开始答卷"}
return {"ok": False, "code": "no_period", "error": "这个测验没有课时 ID,无法自动开始答卷"}
rid, err = start_paper(pid, "自动开始答卷失败")
if err:
return {"ok": False, "error": err}
@@ -381,8 +457,7 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
# ---- 取当前答卷;已交卷的再调 review 拿标准答案 ----
paper = None
try:
j = client.call("GET", "/questions/front/testpaper/getById",
params={"testpaperResultId": rid})
j = client.call("GET", "/questions/front/testpaper/getById", params={"testpaperResultId": rid})
if str(j.get("code")) == "200":
paper = j.get("data") or {}
except Exception:
@@ -394,8 +469,7 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
qs, review = [], False
if done:
try:
jr = client.call("GET", "/questions/front/testpaper/teacher/review",
params={"testpaperResultId": rid})
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)
@@ -416,24 +490,29 @@ def _quiz_study(s, d, tests, retake=False, period_id=""):
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")}
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):
@@ -449,8 +528,7 @@ def _quiz_study_submit(b):
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})
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":
@@ -462,9 +540,12 @@ def _quiz_study_submit(b):
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 "")}
return {
"ok": False,
"code": "already_finished",
"error": "这份答卷已经交过卷%s;点「再考一次」可以生成新答卷再提交"
% ("(得分 %s)" % sc if sc is not None else ""),
}
items, answered = [], 0
for q in qs:
@@ -485,24 +566,35 @@ def _quiz_study_submit(b):
score = None
try:
j2 = client.call("GET", "/questions/front/testpaper/getById",
params={"testpaperResultId": b.result_id})
res = ((j2.get("data") or {}).get("result") or {})
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": "交卷成功"}
return {"ok": True, "answers": answered, "total": len(items), "score": score, "msg": "交卷成功"}
@app.post("/api/quiz/submit")
def api_quiz_submit(b: QuizSubmitIn):
"""按 quesNo 合并用户/模型的答案后交卷。重新取一次题,保证 traineeid 是当前会话的。"""
"""按平台分发交卷:每个平台自己决定怎么把答案交给站点。"""
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)
s = new_session(b.cookies)
try:
html = fetch_quiz_page(s, b.plan_id, b.object_id)
except Exception as e:
@@ -516,30 +608,39 @@ def api_quiz_submit(b: QuizSubmitIn):
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=(",", ":"))}
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)
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]}
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.I)
re.IGNORECASE,
)
def parse_llm_answers(content, questions):
"""优先按 JSON 解析;模型没给 JSON(或被截断)时从「1.A / 第3题:B」里抠。"""
def _pick(raw):
out = {}
for q in questions:
@@ -553,7 +654,7 @@ def parse_llm_answers(content, questions):
return out
txt = re.sub(r"```(?:json)?|```", "", (content or "").strip()).strip()
m = re.search(r"\{.*\}", txt, re.S)
m = re.search(r"\{.*\}", txt, re.DOTALL)
if m:
try:
data = json.loads(m.group(0))
@@ -570,16 +671,17 @@ def parse_llm_answers(content, questions):
QUIZ_BATCH = 5
# 这些错误重试也没用(鉴权/路径/参数问题),其它(超时、5xx、空内容)值得再问一次
_RETRY_HINT = re.compile(r"timed out|timeout|超时|429|5\d\d|空内容|不是 JSON", re.I)
_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 "无"))
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)
@@ -594,10 +696,13 @@ 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)
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
@@ -608,16 +713,28 @@ def _sse(obj):
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))
_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]
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 "")
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)
@@ -628,27 +745,32 @@ def _quiz_stream(system, questions, cfg, key=""):
break
continue
answers.update(got)
part = [q for q in part if str(q.get("no")) not in 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", " ")))
_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))
_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"})
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")
@@ -667,7 +789,9 @@ def api_quiz_answer(b: QuizAnswerIn):
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"})
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
@@ -1,18 +1,20 @@
"""新版莞易学课程页(study.dgjy.net):StudyClient、课程结构、学习动作。"""
"""新版莞易学(study.dgjy.net):StudyClient、课程结构、学习动作与浏览器播放。"""
import os
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import parse_qs
from urllib.parse import parse_qs, urlsplit
from bs4 import BeautifulSoup
from study_tool import store
from study_tool.config import *
from study_tool.pugins.guanyixue.jspx import *
from study_tool import database, notify
from study_tool.platforms.errors import SessionExpired
from study_tool.runtime import RUNS, log, set_state
# ------------------------------------------------------- 新版平台:页面识别/提取
from ..config import DEBUG_DIR, HEX32, JSPX, STUDY
from .http import dump_cookies, new_session, study_token
from .login import ensure_study_session
def extract_train_id(page_url, html):
@@ -83,7 +85,7 @@ def save_snapshot(plan, project, url, html):
"""
safe_url = re.sub(r"([?&])(token|access_token|securityKey|sign)=[^&]*", r"\1\2=***", url or "")
try:
sid = store.snapshot_add(plan, project, safe_url, html or "")
sid = database.snapshot_add(plan, project, safe_url, html or "")
return "/api/snapshot/%d" % sid
except Exception:
pass
@@ -100,15 +102,6 @@ def save_snapshot(plan, project, url, html):
return "(保存失败)"
# ------------------------------------------------------- 新版平台:HTTP 客户端
def _study_token(s):
for c in s.cookies:
if c.name == "Middle-Token" and "dgjy" in (c.domain or ""):
return c.value
return ""
class StudyClient:
"""新版平台接口客户端。
@@ -119,7 +112,7 @@ class StudyClient:
def __init__(self, session, page_url=""):
self.s = session
self.token = _study_token(session)
self.token = study_token(session)
self.bases = []
origin = ""
if page_url:
@@ -180,11 +173,9 @@ class StudyClient:
ensure_study_session(self.s, force=True)
except Exception:
pass
self.token = _study_token(self.s)
self.token = study_token(self.s)
# ------------------------------------------------------- 新版平台:课程结构
def _flatten_periods(nodes, chapter="", out=None):
out = [] if out is None else out
for n in nodes or []:
@@ -230,9 +221,12 @@ def _int_or_none(v):
return None
# 视频文件类型 -> 播放器通道(来自课程播放页 chunk-3d0ebfea 的 judgeVideoType)
CLOUD_TYPES = {"cloud_video", "middle"} # polyv 云视频:/period/middle/play
MOOC_TYPES = {"mooc_video"} # 网易云课堂:走签名/moc 播放
OTHER_TYPES = {"other"} # 普通 mp4:原生 <video>,ended 即完成
@@ -431,8 +425,6 @@ def discover_study(client, train_id, name="", user=""):
}
# ------------------------------------------------------- 新版平台:动作
def study_period_log(client, period_id, done, seconds=0):
"""播放器两个包:播放中 {isComplete:0, lastProgressTime},播完 {isComplete:1}(不带时长)。"""
body = {"periodId": int(period_id), "isComplete": 1 if done else 0}
@@ -622,7 +614,7 @@ def study_join_course(client, train_id):
return str(j.get("code")) == "200", str(j.get("msg") or j.get("message") or "")
def study_complete_period(cookies, page_url, item):
def study_complete_period(cookies, page_url, item, key=""):
"""把一个视频课时报到 complete=1,并以服务端 completeTime 为准。"""
s = new_session(cookies)
c = StudyClient(s, page_url)
@@ -650,6 +642,11 @@ def study_complete_period(cookies, page_url, item):
except Exception as e:
last = str(e)[:100]
time.sleep(1.0)
notify.retry(
key,
"单课时上报",
"课时「%s」第 %d 次上报后仍未生效:%s" % (item.get("title") or pid, attempt, last),
)
return False, last
@@ -681,8 +678,6 @@ def study_submit_feedback(client, fb, key, train_id):
log(key, "反馈表:提交失败 code=%s %s" % (r.get("code"), str(r.get("msg") or "")[:80]))
# ------------------------------------------------------------ 新版学习流程
def run_study_course(key, s, d, workers, cap, enable_quiz, enable_feedback,
mode="fast", parallel=2):
"""新版平台(study.dgjy.net / Vue SPA)课程:视频走 study/log 接口,
@@ -707,7 +702,7 @@ def run_study_course(key, s, d, workers, cap, enable_quiz, enable_feedback,
log(key, "加入培训被拒:%s" % (why or "未知原因"))
probe = next((c for c in d["courseware"] if not c["done"]), None)
if probe:
good, last = study_complete_period(cookies, page_url, probe)
good, last = study_complete_period(cookies, page_url, probe, key)
log(key, "试报 1 个课时:%s" % ("生效" if good else "未生效(%s)" % last))
if not good:
log(key, "该账号对这门课没有写入权限,本次跳过。"
@@ -799,8 +794,6 @@ def run_study_course(key, s, d, workers, cap, enable_quiz, enable_feedback,
log(key, "复查失败:%s" % e)
# 平台靠 document.hidden / visibilitychange 判断「用户切走了」,一 hidden 就暂停并记异常。
# 多路真播时把可见性钉死成 visible,不然后台标签页会被判成离开。
VIS_KEEP_JS = """
(() => {
const d = document;
@@ -944,7 +937,7 @@ def study_videos_fast(key, cookies, page_url, train_id, todo, workers, cap, done
if hard:
log(key, "并发提交 %d 个视频课时(workers=%d)…" % (len(hard), min(workers, len(hard))))
ex = ThreadPoolExecutor(max_workers=min(workers, len(hard)))
futs = {ex.submit(study_complete_period, cookies, page_url, it): it for it in hard}
futs = {ex.submit(study_complete_period, cookies, page_url, it, key): it for it in hard}
try:
for fut in as_completed(futs, timeout=max(1, deadline - time.time())):
it = futs[fut]
@@ -1114,7 +1107,6 @@ def _reread_study(key, s, d):
set_state(key, progress=d2["progress"], info=d2["info"], cookies=dump_cookies(s))
log(key, "课程结束:%s | %s" % (d2["progress"], d2["info"]))
# ---------------------------------------------------------------- 学习实现
def _study(key, s, d, **opts):
return run_study_course(key, s, d, opts.get("workers", 1), opts.get("cap", 30),
-133
View File
@@ -1,133 +0,0 @@
"""插件目录:`pugins/<名字>/` = 一个平台插件。
pugin.json 注册信息:id / title / name(只写元数据,不写任何能力绑定)
main.py `Platform` 子类,import 时用 `registry.register(...)` 注册自己
对外四个函数(路由 `/api/plugins*` 直接用):
load_plugins() 启动时按启用清单加载全部插件
discover() 磁盘清单 + 启用 / 已加载状态(不 import 插件代码)
set_enabled(name, bool) 启用 / 停用,立即生效并落库
reload(name=None) 重新 import(改完插件代码不用重启服务)
启用清单存在设置项 `plugins` 里(`{"disabled": [...]}`):没列进去的默认启用,
新丢进目录的插件会自动生效;停用的插件从注册表摘掉,`/api/platforms` 里不再出现。
"""
import importlib
import json
import sys
from pathlib import Path
from study_tool import runtime, store
from study_tool.platforms import registry
HERE = Path(__file__).resolve().parent
SETTING = "plugins"
_LOADED = {} # 插件名 -> 注册时用的平台 id,停用 / 重载时据此摘下旧实现
def _meta(name):
meta = HERE / name / "pugin.json"
if not meta.is_file():
raise RuntimeError("没有这个插件:%s" % name)
return json.loads(meta.read_text(encoding="utf-8"))
def _disabled():
return set((store.get_setting(SETTING) or {}).get("disabled") or [])
def _save_disabled(names):
store.set_setting(SETTING, {"disabled": sorted(names)})
def _forget(name):
"""把该插件的模块(含子模块)从 sys.modules 摘掉,下次 import 会重新执行。"""
pkg = "%s.%s" % (__name__, name)
for mod in [m for m in sys.modules if m == pkg or m.startswith(pkg + ".")]:
del sys.modules[mod]
def _load(name):
"""import 一个插件并确认它注册成功;重复调用就是重新加载。"""
pid = _meta(name).get("id") or ""
_unload(name) # 旧实现先下线,id 改了也不会留下残影
importlib.import_module("%s.%s.main" % (__name__, name))
platform = registry.get(pid)
if platform is None:
raise RuntimeError("插件 %s 没有注册 id=%r" % (name, pid))
_LOADED[name] = pid
return platform
def _unload(name):
pid = _LOADED.pop(name, None)
if pid:
registry.unregister(pid)
_forget(name)
def discover():
"""磁盘上的插件清单;不 import 插件代码。"""
disabled = _disabled()
out = []
for meta in sorted(HERE.glob("*/pugin.json")):
name = meta.parent.name
info = json.loads(meta.read_text(encoding="utf-8"))
platform = registry.get(info.get("id") or "")
out.append({
"name": name,
"id": info.get("id") or "",
"title": info.get("title") or "",
"enabled": name not in disabled,
"loaded": platform is not None,
"label": platform.label if platform else "",
"capabilities": platform.capabilities() if platform else [],
})
return out
def load_plugins():
"""启动时加载启用清单里的全部插件。幂等。"""
for item in discover():
if not item["enabled"]:
continue
try:
_load(item["name"])
except Exception as e:
runtime.log("system", "插件 %s 加载失败:%s" % (item["name"], e), level="warn")
def set_enabled(name, enabled):
"""启用 / 停用插件并立即生效;返回最新的插件清单。"""
if enabled:
platform = _load(name) # 先加载,失败了不写进启用清单
disabled = _disabled()
disabled.discard(name)
_save_disabled(disabled)
runtime.log("system", "插件已启用:%s(%s)" % (name, platform.label))
else:
disabled = _disabled()
disabled.add(name)
_save_disabled(disabled)
_unload(name)
runtime.log("system", "插件已停用:%s" % name)
return discover()
def reload(name=None):
"""重新 import 插件;name 为空 = 重载所有已启用的插件。"""
items = discover()
if name:
item = next((i for i in items if i["name"] == name), None)
if item is None:
raise RuntimeError("没有这个插件:%s" % name)
if not item["enabled"]:
raise RuntimeError("插件 %s 已停用,先启用再重载" % name)
names = [name]
else:
names = [i["name"] for i in items if i["enabled"]]
for n in names:
_load(n)
runtime.log("system", "插件已重载:%s" % n)
return discover()
-401
View File
@@ -1,401 +0,0 @@
"""广东公需课:jsglpt 登录 → jsxx 学习。
登录在 `jsglpt.gds.edu.cn`(账号 / 密码都是 base64),成功后用
`ssoItlmsGxkTicket.action` 换 ITLMS 票,带票进 `jsxx.gds.edu.cn` 拿学习会话;
两个域名的 cookie 都留在同一个 requests.Session 里。
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度靠 `updateViewTime1`
心跳上报(`lastViewTime` 是已看秒数,页面上「要求观看视频 N 分钟」才是完成门槛)。
平台按真实流逝时间放行(实测一次报 +300 只记 +60),所以只能 1 倍速慢慢刷。
"""
import base64
import json
import re
import time
import uuid
import requests
from study_tool import runtime, store
from study_tool.platforms import registry
from study_tool.platforms.base import Platform
from study_tool.platforms.errors import SessionExpired
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36")
G = "https://jsglpt.gds.edu.cn"
X = "https://jsxx.gds.edu.cn"
PLATFORM_ID = "gdgxk"
def _xhr(s, method, url, **kw):
"""这些上报接口必须带 Referer(指向课程页),否则直接 500。"""
kw.setdefault("headers", {})["X-Requested-With"] = "XMLHttpRequest"
return s.request(method, url, timeout=30, **kw)
def _expired(msg):
"""统一错误类型:引擎据此触发续期 / 重新登录。"""
return SessionExpired(msg)
def _b64(text):
return base64.b64encode(str(text).encode("utf-8")).decode()
def _sso(s):
"""jsglpt 会话换 ITLMS 票,票按 `ITLMS-xxx||ticketId` 两段交给 jsxx。
交接入口来自 jsglpt 首页那个隐藏表单:
`GET https://jsxx.gds.edu.cn/nts/loginToStoreByAccount?ticket=&ticketId=`
"""
r = s.post(G + "/teacher/ssoItlmsGxkTicket.action?verificaCode=true&page=", timeout=30)
ticket = (r.text or "").strip()
if not ticket.startswith("ITLMS-") or "||" not in ticket:
raise _expired("取 ITLMS 票失败:%s" % (ticket[:80] or "空响应"))
t, tid = ticket.split("||", 1)
q = s.get(X + "/nts/loginToStoreByAccount", params={"ticket": t, "ticketId": tid}, timeout=30)
if "logout" not in q.text and "我的课程" not in q.text:
raise _expired("带票进学习平台失败,票可能已失效")
return s
_OCR = None
def _ocr():
"""4 位图形验证码:ddddocr 离线识别。"""
global _OCR
if _OCR is None:
try:
import ddddocr
except Exception as e:
raise RuntimeError("登录要过图形验证码,需要先装 ddddocr(pip install ddddocr):%s" % e)
_OCR = ddddocr.DdddOcr(show_ad=False)
return _OCR
def _captcha(s):
raw = s.get(G + "/login/readCaptcha.action?d=%.6f" % time.time(), timeout=30).text
return base64.b64decode(raw.strip().strip('"').split(",", 1)[-1])
def _login_error(s):
html = s.get(G + "/login.jsp", timeout=30).text
m = re.search(r'id="errorText"[^>]*>([^<]*)<', html)
return (m.group(1).strip() if m else "")
def _parse_courses(html):
"""课程列表页:每个 `showCourse('id','名称')` 前面就是这张卡片的信息。"""
out = []
for m in re.finditer(r"showCourse\('([^']+)','([^']*)'\)", html):
block = html[max(0, m.start() - 2500):m.start()]
year = re.search(r"培训年度:\s*<[^>]*>(\d{4})<", block)
hours = re.search(r"课程学时:\s*<[^>]*>(\d+)<", block)
st = re.search(r">\s*(未完成|已完成)\s*<", block)
out.append({
"plan_id": m.group(1),
"project_id": "",
"name": m.group(2).strip(),
"section": ("%s年公需课" % year.group(1)) if year else "",
"status_label": st.group(1) if st else "",
"info": ("%s 学时" % hours.group(1)) if hours else "",
"in_study": True,
"platform": PLATFORM_ID,
})
return out
def parse_course(html):
"""从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、要求时长、已看秒数。"""
d = {}
m = re.search(r"/a_([0-9a-f]{32})/study/video/user/", html)
if not m:
raise RuntimeError("学习页里没有找到课件上报路径,页面结构可能变了")
d["aid"] = "a_" + m.group(1)
m = re.search(r"updateVideoStatus1'[\s\S]{0,200}?id=([0-9a-f]{32})", html) \
or re.search(r"id=([0-9a-f]{32})&md5Uid=", html)
if not m:
raise RuntimeError("学习页里没有找到课件 id")
d["id"] = m.group(1)
m = re.search(r"md5Uid=([0-9a-f]{64,128})", html)
if not m:
raise RuntimeError("学习页里没有找到 md5Uid")
d["uid"] = m.group(1)
# `t` 是服务端记的「已看秒数」,不是总长;完成门槛是页面上的「要求观看视频 N 分钟」
m = re.search(r"\bt\s*=\s*'(\d+)'", html)
d["viewTime"] = int(m.group(1)) if m else 0
m = re.search(r"要求观看视频<span>(\d+)</span>分钟", html)
d["total"] = int(m.group(1)) * 60 if m else 0
m = re.search(r"url1\s*=\s*'([^']+)'", html)
d["video"] = m.group(1) if m else ""
d["title"] = (re.search(r"<title>([^<]*)</title>", html).group(1).strip()
if "<title>" in html else "")
return d
def _sign(s, cw, uid):
"""updateVideoStatus1:注册播放权,换回心跳签名(time / md5)。"""
r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/updateVideoStatus1",
data={"_method": "PUT", "id": cw["id"], "md5Uid": uid,
"playbackSessionId": cw["sid"], "playbackTakeover": "true"})
j = r.json() if r.text.strip().startswith("{") else {}
if j.get("responseCode") != "00":
raise RuntimeError("注册播放失败:%s" % (r.text[:120] or "空响应"))
return j["responseData"]
def _view_time(s, cw, uid, sign):
r = _xhr(s, "GET", X + "/" + cw["aid"] + "/study/video/user/getViewTime",
params={"id": cw["id"], "md5": sign.get("md5"), "expires": sign.get("time"),
"md5Uid": uid})
j = r.json() if r.text.strip().startswith("{") else {}
rd = j.get("responseData")
if isinstance(rd, dict):
return int(rd.get("viewTime") or 0)
return int(rd or 0)
def _progress(s, cid):
"""学习进度页:活动总数、已完成数、每个活动(aid / 名称 / 是否完成)。"""
html = s.get(X + "/" + cid + "/study/course/progess", timeout=30).text
if "studyCourse(" not in html:
raise _expired("学习平台会话已失效,请重新登录")
m = re.search(r"\$\('#actNum'\)\.text\('(\d+)'\)", html)
total = int(m.group(1)) if m else 0
m = re.search(r"\$\('#completeActNum'\)\.text\('(\d+)'\)", html)
done = int(m.group(1)) if m else 0
acts = []
for row in re.findall(r"<tr[^>]*>([\s\S]*?)</tr>", html):
m = re.search(r"studyCourse\('[^']*','[^']*','(a_[0-9a-f]{32})'\)[^>]*>([^<]+)<", row)
if m:
acts.append({"aid": m.group(1), "title": m.group(2).strip(),
"done": "u-ico-finish" in row})
return {"total": total or len(acts), "done": done, "acts": acts}
def _stopped(key):
with runtime.RUNS_LOCK:
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
def _study_one(key, s, cw, speed, beat):
"""打开活动页 → 注册播放 → 1x 心跳;会话失效时抛 SessionExpired 交给上层重登。"""
try:
_sso(s) # 一门课要跑几十分钟到几小时,每个活动前续一次票
except Exception:
pass # 续票失败但老会话还能用就继续
html = s.get(cw["href"], timeout=30).text
if "loginBtn" in html and "md5Uid" not in html:
raise _expired("学习页要求重新登录")
try:
info = parse_course(html)
except Exception as e:
raise RuntimeError("不是视频活动(%s)" % e)
if not info["total"]:
raise RuntimeError("页面上没有「要求观看视频 N 分钟」")
cw.update(id=info["id"], uid=info["uid"], total=info["total"], viewTime=info["viewTime"])
s.headers["Referer"] = cw["href"]
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": uuid.uuid4().hex}, cw["uid"])
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, cw["uid"], sign)
runtime.log(key, "开始学习:%s(要求 %d 秒,已看 %d 秒)" % (cw["title"], cw["total"], cur))
if cur < cw["total"]:
_learn(key, s, cw, speed, beat)
else:
runtime.log(key, "服务端已记 %d 秒,达到要求,等平台刷新完成状态" % cur, level="warn")
def _learn(key, s, cw, speed, beat):
"""心跳上报。平台只按真实流逝时间放行(1x),所以目标值按墙钟推,不靠每拍累加。"""
uid, total = cw["uid"], cw["total"]
s.headers["Referer"] = cw.get("href") or X
sid = uuid.uuid4().hex
base = {"_method": "PUT", "aid": cw["aid"]}
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, uid)
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
start, t0, stall = cur, time.time(), 0
while cur < total:
if _stopped(key):
runtime.log(key, "已按要求停止")
return
time.sleep(beat)
want = min(total, start + int((time.time() - t0) * speed))
data = dict(base, isLimit="false", md5=sign.get("md5"), expires=sign.get("time"),
md5Uid=uid, lastViewTime=str(max(want, cur)), playbackSessionId=sid)
r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1",
data=data)
if "loginBtn" in (r.text or "") or not (r.text or "").strip().startswith("{"):
raise _expired("上报被踢回登录页(HTTP %s)" % r.status_code)
j = r.json()
rd = j.get("responseData") or {}
if j.get("responseCode") == "00" and rd.get("md5"):
sign = {"md5": rd["md5"], "time": rd.get("time")}
before = cur
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
runtime.log(key, "进度上报:目标 %d 秒 / 服务端 %d 秒 / 要求 %d 秒" % (want, cur, total))
stall = stall + 1 if cur <= before else 0
if stall >= 5:
runtime.log(key, "连续 5 次上报都没被平台接受,先停这个活动", level="warn")
return
runtime.log(key, "课件完成:%s(%d 秒)" % (cw["title"], total))
class Gdgxk(Platform):
"""广东公需课:jsglpt 登录(账号 / 密码都是 base64 + 图形验证码),jsxx 学习。
账号级能力都实现在这里;反馈表 / 测验这类平台没有的阶段不实现,保持基类的 None。
"""
id = PLATFORM_ID
label = "广东公需课"
entries = ("jsglpt.gds.edu.cn", "jsxx.gds.edu.cn")
def session(self, 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 "gds.edu.cn" 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(self, s):
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path}
for c in s.cookies]
def login(self, username, password):
s = self.session()
s.get(G + "/login.jsp", timeout=30)
err = ""
for _ in range(5):
data = {
"loginUser.userName": _b64(username),
"loginUser.loginType": "normal",
"loginUser.password": _b64(password),
"captcha": _ocr().classification(_captcha(s)),
}
r = s.post(G + "/loginIn.do", data=data, allow_redirects=False, timeout=30)
if r.status_code == 302 and "/default.jsp" in (r.headers.get("location") or ""):
_sso(s)
return s, self.profile(s) or username
err = _login_error(s)
if "验证码" not in err: # 不是验证码的问题,重试没意义
break
raise RuntimeError("登录失败:%s" % (err or "账号或密码不对"))
def renew_session(self, s):
try:
_sso(s)
return True
except Exception:
return False
def profile(self, s):
html = s.get(G + "/teacher/index.do", timeout=30).text
m = re.search(r"你好[,,]?\s*([^\s<]+)\s*老师", re.sub(r"\s+", " ", html))
if m:
return m.group(1)
m = re.search(r'class="user"[^>]*>\s*<[^>]*>\s*([^\s<]{2,20})', html)
return m.group(1) if m else ""
def list_courses(self, s):
html = s.get(X + "/uc/store/courseRegister", timeout=30).text
out = _parse_courses(html)
if not out:
if "logout" not in html:
raise _expired("学习平台会话已失效,请重新登录")
return []
return out
def discover(self, s, plan_id, project_id, name=""):
cid = plan_id
page = X + "/study/course/" + cid
s.headers["Referer"] = page
r = s.get(page + "?v=1", timeout=30)
if "loginBtn" in r.text and "md5Uid" not in r.text:
raise _expired("学习平台会话已失效,请重新登录")
# 一门课是「章 / 节 / 活动」的目录,不是单个视频;活动清单和完成情况都从进度页取
pr = _progress(s, cid)
if not pr["acts"]:
raise RuntimeError("进度页里没有找到活动列表,页面结构可能变了")
courseware = [{
"aid": a["aid"], "title": a["title"], "done": a["done"],
"href": X + "/" + a["aid"] + "/study/course/" + cid,
"total": 0, "viewTime": 0,
} for a in pr["acts"]]
return {
"user": self.profile(s),
"course": name or "公需课程",
"progress": "已完成 %d/%d 个活动" % (pr["done"], pr["total"]),
"info": page,
"courseware": courseware,
"feedback": [], "test": [],
"platform": PLATFORM_ID, "generation": "gxk",
"plan_id": cid, "project_id": "",
}
def study(self, key, s, d, **opts):
"""按进度页的活动清单逐个心跳;平台只按真实时间放行,所以默认 1 倍速慢慢刷。"""
speed = float(opts.get("speed") or 1)
beat = int(opts.get("beat") or 60)
cid = d["plan_id"]
total = len(d["courseware"])
runtime.set_state(key, status="studying", total=total, done=0)
skipped = []
for i, cw in enumerate(d["courseware"], 1):
if _stopped(key):
runtime.log(key, "已按要求停止")
return
if cw.get("done"):
runtime.log(key, "活动 %d/%d 已完成,跳过:%s" % (i, total, cw["title"]))
runtime.set_state(key, done=i)
continue
for attempt in (1, 2):
try:
_study_one(key, s, cw, speed, beat)
break
except Exception as e:
expired = e.__class__.__name__ == "SessionExpired"
if expired and attempt == 1:
s = self._relogin(key) # 掉线:重登后把当前活动重新走一遍
continue
if expired:
raise
runtime.log(key, "活动「%s」跳过:%s" % (cw["title"], e), level="warn")
skipped.append(cw["title"])
break
runtime.set_state(key, done=i)
# 以平台进度页为准收尾:视频秒数够 ≠ 课程完成,考核没做就是没完成
try:
pr = _progress(s, cid)
except Exception as e:
if e.__class__.__name__ != "SessionExpired":
raise
s = self._relogin(key)
pr = _progress(s, cid)
act_total = pr["total"] or total
runtime.set_state(key, status="done", done=pr["done"], total=act_total)
if pr["done"] >= act_total:
runtime.log(key, "平台进度:%d/%d 个活动已完成" % (pr["done"], act_total))
else:
runtime.log(key, "平台进度:%d/%d 个活动已完成,剩余需人工处理%s"
% (pr["done"], act_total,
("(" + "、".join(skipped) + ")") if skipped else ""),
level="warn")
def _relogin(self, key):
"""掉线后按库里存的账号密码重登,返回新会话。"""
acc = store.account_get(key) or {}
pw = store.account_password(key)
if not pw:
raise _expired("会话掉线,且没有保存密码,无法自动重连")
runtime.log(key, "会话掉线,正在用账号密码自动重新登录…", level="warn")
s, _ = self.login(acc.get("username") or "", pw)
runtime.log(key, "重新登录成功,继续学习")
return s
PLATFORM = registry.register(Gdgxk())
@@ -1 +0,0 @@
"""莞易学插件:`main.py` 注册平台,`jspx.py` 旧版页面、`study.py` 新版 SPA、`detect.py` 代际识别。"""
-825
View File
@@ -1,825 +0,0 @@
"""旧版平台(jspx.dgjy.net):会话 / cookie / 登录 / 课程抓取。
对外:new_session / login / scrape_courses / discover。
"""
import json
import re
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from html import unescape as html_unescape
from urllib.parse import unquote, urljoin, urlsplit
import requests
from bs4 import BeautifulSoup
from study_tool.config import *
from study_tool.platforms import registry
from study_tool.pugins.guanyixue.detect import detect_generation, has_study_marker
from study_tool.runtime import RUNS, RUNS_LOCK, log, set_state
# ------------------------------------------------------------------------ crypto
def str_enc(data: str) -> str:
r = subprocess.run([NODE, DESENC, data, "1", "2", "3"],
capture_output=True, text=True, timeout=30)
if r.returncode != 0:
raise RuntimeError("strEnc failed: %s" % (r.stderr or "")[:200])
return r.stdout.strip()
# ------------------------------------------------------------------- http client
def new_session(cookies=None):
s = requests.Session()
s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
for c in (cookies or []):
if "dgjy" not in (c.get("domain") or ""):
continue
s.cookies.set(c["name"], c.get("value") or "",
domain=(c.get("domain") or "").lstrip("."),
path=c.get("path") or "/")
return s
def dump_cookies(s):
return [{"name": c.name, "value": c.value, "domain": c.domain, "path": c.path}
for c in s.cookies]
def whoami(html):
soup = BeautifulSoup(html, "lxml")
hdr = soup.select_one("#header")
if hdr:
m = re.search(r"你好[,,]?\s*(\S+)\s*老师", re.sub(r"\s+", " ", hdr.get_text(" ", strip=True)))
if m:
return m.group(1)
return ""
def parse_ids(text):
plan = project = ""
m = re.search(r"planId=([0-9A-Fa-f]{16,})", text or "")
if m:
plan = m.group(1)
m = re.search(r"projectId=([0-9A-Fa-f]{16,})", text or "")
if m:
project = m.group(1)
return plan, project
def _follow_js_login(s, page):
"""补上浏览器才会执行的两跳,否则只拿得到 CASTGC、拿不到 jspx 的会话。 `oauth2.0/authorize` 链的最后一跳**不是 302**,而是 dj.dgjy.net 上一个
1486 字节的 JS 中转页,里面动态插 <script>:
scriptLogin.src = 'https://passport.dgjy.net/scriptLogin?action=simpleLogin&d1=...&d2=...';
scriptLogin 返回的是 JS,不是跳转:
var loginError=null;
var verifyURL="https://dj.dgjy.net/fpf/login/verify.action?ticket=...&auth=...&sid=...";
location.href=verifyURL;
verify.action 之后才会 302 → loginCall → jspx/tstudy/passport/login.action,
到这一步 jspx.dgjy.net 的 JSESSIONID 才真正下发。
requests 不执行 JS,所以这两跳必须手工走一遍。
"""
m = re.search(r"scriptLogin\.src\s*=\s*'([^']+)'", page or "")
if not m:
return False
r = s.get(html_unescape(m.group(1)), timeout=40)
body = r.text or ""
if re.search(r"loginError\s*=\s*(?!null)", body):
raise RuntimeError("SSO 校验未通过:%s" % body.strip()[:200])
m2 = re.search(r'verifyURL\s*=\s*"([^"]+)"', body)
if not m2:
return False
s.get(html_unescape(m2.group(1)).replace("\\/", "/"), timeout=40, allow_redirects=True)
return True
def _js_redirect_candidates(page):
"""从一个不执行 JS 的响应体里,按出现顺序抽出下一跳 URL。"""
out = []
for pat in (r"scriptLogin\.src\s*=\s*'([^']+)'",
r'scriptLogin\.src\s*=\s*"([^"]+)"',
r'verifyURL\s*=\s*"([^"]+)"',
r"verifyURL\s*=\s*'([^']+)'",
r"location\.href\s*=\s*['\"]([^'\"]+)['\"]",
r"window\.location\.href\s*=\s*['\"]([^'\"]+)['\"]"):
out.extend(re.findall(pat, page or ""))
return [html_unescape(u).replace("\\/", "/") for u in out if u]
def follow_js_hops(s, start_url, stop=None, max_hops=6):
"""通用 SSO 补跳:页面给的不是 302 而是 JS/scriptLogin 时,手工走完。
study.dgjy.net 的授权链和 jspx 不是同一套页面模板,所以这里按「下一跳 URL」
逐个 GET,直到 stop() 为真(例如 Middle-Token 已经下发)或没有下一跳。
"""
cur, seen = start_url, set()
for _ in range(max_hops):
if cur in seen:
break
seen.add(cur)
try:
r = s.get(cur, timeout=40, allow_redirects=True)
except Exception:
break
if stop and stop():
return True
nxt = _js_redirect_candidates(r.text or "")
if not nxt:
break
cur = urljoin(r.url, nxt[0])
return bool(stop and stop())
def _login_error(html, status=200):
"""从 /tpass/login 的响应里取出服务端提示。
提示藏在 <script type="text/html" id="login_template"> 里的
<span id="errormsghide" style="display:none">,BeautifulSoup 的 get_text()
看不见 script 内容,所以只能按原文正则取;锁号是另一张 403 提示页。
"""
text = html or ""
m = re.search(r"验证失败次数过多[^<]*", text)
if m:
return m.group(0).strip()
m = re.search(r'<span[^>]*id="errormsghide"[^>]*>(.*?)</span>', text, re.S)
if m:
msg = re.sub(r"<[^>]+>", " ", m.group(1))
msg = re.sub(r"\s+", " ", html_unescape(msg).replace("\xa0", " ")).strip()
if msg:
return msg
m = re.search(r"(用户名|密码|账号|账户)[^<>]{0,40}(错误|不存在|不正确|锁定)", text)
if m:
return m.group(0).strip()
if status == 403:
return "账号已被锁定(403),约 10 分钟后解锁"
return ""
def parse_accountlist(html):
"""多身份账号:登录响应里内联 `var accountlist = '<json>';`。
每个元素带 USERNAME(身份证)、CODENAME(教师/家长/学生)、UNIT_NAME,
以及该身份自己的 LOGINUSERNAME / LOGINPASSWORD —— 选身份那一步是服务端
把可登录的账号密码回给前端,再带 choosenumber 提交一次。
"""
m = re.search(r"var\s+accountlist\s*=\s*'(.*?)';", html or "", re.S)
if not m:
return []
try:
data = json.loads(html_unescape(m.group(1)))
except Exception:
return []
return data if isinstance(data, list) else []
def _login_as_identity(s, un, lt, ex, it):
"""按 login_dgjy.js 的选身份分支提交:rsa 用该身份的 LOGINUSERNAME+LOGINPASSWORD。"""
name = it.get("LOGINUSERNAME") or ""
pwd = it.get("LOGINPASSWORD") or ""
payload = {"un": un, "ul": str(len(name)), "pl": str(len(pwd)),
"rsa": str_enc(name + pwd + lt), "lt": lt,
"choosenumber": it.get("USERNAME") or "", "_eventId": "submit"}
if ex:
payload["execution"] = ex
return s.post(TPASS + "/login", data=payload, timeout=30, allow_redirects=True,
headers=LOGIN_HEADERS)
def ensure_jspx_session(s):
"""用 CASTGC 走老平台(jspx)的授权链,建立 jspx.dgjy.net 的会话;成功返回 True。
必须先把 jspx 的 JSESSIONID / route 清掉:这两个是粘性会话 cookie,
浏览器里带过来的那份常常指向已经下线的后端节点(route 对不上号),
留着就永远跳 /admin/login.action,看起来像「登录失效」。
"""
try:
r = s.get(JSPX + "/admin/main.action", timeout=30)
if "table-results" in (r.text or ""):
return True
except Exception:
pass
for c in list(s.cookies):
if "jspx" in (c.domain or ""):
s.cookies.clear(c.domain, c.path, c.name)
try:
r = s.get(AUTHORIZE, timeout=40, allow_redirects=True)
_follow_js_login(s, r.text or "")
r2 = s.get(JSPX + "/admin/main.action", timeout=30)
return "table-results" in (r2.text or "")
except Exception:
return False
def _jspx_call(s, fn):
"""跑一个 jspx 请求;会话失效且手上还有 CASTGC 时,清掉粘性 cookie 重新授权后重试一次。
浏览器 / IndexedDB 里存着的 jspx JSESSIONID+route 经常指向已经下线的后端节点,
fetch_admin 看到的就是「跳回 /admin/login.action」,看着像登录过期,
实际 CASTGC 还有效 —— 这种不该让用户重新输密码。
"""
try:
return fn()
except SessionExpired:
if not ensure_jspx_session(s):
raise
return fn()
def login(username: str, password: str):
"""SSO login -> (session, user)。"""
s = new_session()
r = s.get(TPASS + "/login", timeout=30)
lt = re.search(r'id="lt"[^>]*value="([^"]*)"', r.text)
lt = lt.group(1) if lt else ""
ex = re.search(r'name="execution"[^>]*value="([^"]*)"', r.text)
payload = {
"un": username, "pd": password,
"ul": str(len(username)), "pl": str(len(password)),
"rsa": str_enc(username + password + lt),
"lt": lt, "choosenumber": "", "_eventId": "submit",
}
if ex:
payload["execution"] = ex.group(1)
r2 = s.post(TPASS + "/login", data=payload, timeout=30, allow_redirects=True,
headers=LOGIN_HEADERS)
if not any(c.name == "CASTGC" for c in s.cookies):
accts = parse_accountlist(r2.text)
if accts:
# 一个身份证下挂多个身份时,第一次提交只回账号选择页,不会给票据。
# 这里取教师身份(本平台主要场景),没有就取第一个,然后用该身份重提。
pick = next((x for x in accts if "教师" in (x.get("CODENAME") or "")), accts[0])
m = re.search(r'id="lt"[^>]*value="([^"]*)"', r2.text)
ex2 = re.search(r'name="execution"[^>]*value="([^"]*)"', r2.text)
r2 = _login_as_identity(s, username, (m.group(1) if m else lt),
(ex2.group(1) if ex2 else (ex.group(1) if ex else "")), pick)
if not any(c.name == "CASTGC" for c in s.cookies):
msg = _login_error(r2.text, r2.status_code)
raise RuntimeError((msg or "登录失败:未获得登录票据(账号或密码错误?)")[:200])
# 老平台:authorize 链的终点是 JS 中转页,必须手工补两跳才建得起 jspx 会话
if not ensure_jspx_session(s):
raise RuntimeError("SSO 登录未完成:jspx.dgjy.net 没有建立会话(登录链路可能已改版)")
html = fetch_admin(s)
# 同一张 CASTGC 再授权一次给新版平台(client_id=gyx),拿 Middle-Token
ensure_study_session(s)
return s, whoami(html)
def _next_hop_urls(page):
"""不执行 JS 时,从响应体里找出所有可能的下一跳(SSO 中转页/脚本/表单)。"""
out = _js_redirect_candidates(page)
for m in re.finditer(r'(?:src|href)\s*=\s*["\']([^"\']*(?:login|callback|authorize|verify|code)[^"\']*)["\']',
page or "", re.I):
out.append(html_unescape(m.group(1)))
for m in re.finditer(r'<form[^>]+action=["\']([^"\']+)["\']', page or "", re.I):
out.append(html_unescape(m.group(1)))
seen, uniq = set(), []
for u in out:
u = u.replace("\\/", "/")
if u.startswith("http") or u.startswith("/") or "?" in u:
if u not in seen:
seen.add(u)
uniq.append(u)
return uniq
def ensure_study_session(s, force=False):
"""用现有 SSO 票把 study.dgjy.net 的会话补上。
新版平台的登录是 OAuth 授权码换 token(前端 SPA 源码里写死了这几个地址):
GET tyyh.dgjy.net/tpass/oauth2.0/authorize?client_id=gyx
&redirect_uri=https://study.dgjy.net/#/login/back&response_type=code
... -> study.dgjy.net/#/login/back?code=ST-xxxx-tpass
POST study.dgjy.net/mod/gjt/login/<CODE> # 换出前端会话(JSESSIONID)
GET study.dgjy.net/mod/education/gyx/train/front/login
-> {"result":{"url":".../training","token":"<jwt>"}} # 这才是 Middle-Token
成功标准只有一个:cookie 里出现 study.dgjy.net 的 Middle-Token。
"""
from study_tool.pugins.guanyixue.study import _study_token
if force:
for c in list(s.cookies):
if c.name == "Middle-Token":
try:
s.cookies.clear(c.domain, c.path, c.name)
except Exception:
pass
if _study_token(s):
return True
try:
r = s.get(TPASS + TRAIN_AUTHORIZE_PATH, timeout=40, allow_redirects=True)
except Exception:
return False
cur_url, body = r.url or "", r.text or ""
tried = set()
for _ in range(6):
m = CODE_RE.search(body) or CODE_RE.search(cur_url)
if m and m.group(1) not in tried:
tried.add(m.group(1))
if _exchange_code(s, m.group(1)):
return True
hops = _next_hop_urls(body)
if not hops:
break
nxt = urljoin(cur_url or STUDY, hops[0])
if "code=" in nxt:
code = (CODE_RE.search(nxt) or [None, ""])[1]
if code and code not in tried and _exchange_code(s, code):
return True
try:
r = s.get(nxt, timeout=40, allow_redirects=True)
except Exception:
break
cur_url, body = r.url or "", r.text or ""
return bool(_study_token(s))
def _fetch_train_token(s):
"""SSO 登录后,再用 gyx 会话换培训系统的 token(前端 `_51` 那个接口)。
返回体形如:
{"result":{"url":"https://study.dgjy.net/training","token":"<jwt>"},"resultCode":200}
这个 token 就是培训系统的 `Middle-Token`(前端把它写进 cookie 再发 Bearer)。
"""
try:
r = s.get(STUDY + "/mod/education/gyx/train/front/login", timeout=40,
headers={"Accept": "application/json, text/plain, */*"})
j = r.json()
except Exception:
return False
data = j.get("result") if isinstance(j, dict) else None
if not isinstance(data, dict):
data = j.get("data") if isinstance(j, dict) else None
tok = ""
if isinstance(data, dict):
for k in ("token", "accessToken", "middleToken"):
if data.get(k):
tok = str(data[k])
break
if not tok:
return False
s.cookies.set("Middle-Token", tok, domain=urlsplit(STUDY).hostname, path="/")
return True
def _exchange_code(s, code):
"""code -> Middle-Token。前端是 POST /mod/gjt/login/<code>,再取 gyx 培训 token。"""
from study_tool.pugins.guanyixue.study import _study_token
paths = ("/mod/gjt/login/%s", "/train/mod/gjt/login/%s")
for path in paths:
try:
rr = s.post(STUDY + (path % code), timeout=40,
headers={"Accept": "application/json, text/plain, */*"})
except Exception:
continue
if _study_token(s):
return True
try:
j = rr.json()
except Exception:
j = {}
ok = isinstance(j, dict) and str(j.get("resultCode")) == "200"
if ok:
# SSO 换票成功后,再换培训系统的 Bearer token(Middle-Token)
if _fetch_train_token(s):
return True
data = j.get("result") or {}
if isinstance(data, dict):
for k in ("token", "accessToken", "middleToken"):
if data.get(k):
s.cookies.set("Middle-Token", str(data[k]),
domain=urlsplit(STUDY).hostname, path="/")
return True
return False
def fetch_admin(s):
"""GET /admin/main.action。
站点会话失效时**不返回 302**,而是 200 + 55 字节的 JS 跳转存根:
<script>location.href="/admin/login.action";</script>
旧代码直接把它当正常页面解析 -> 一个表格都没有 -> 静默返回空列表,
于是「会话失效」「站点改版」「账号确实没有课」被压成同一条提示。
"""
r = s.get(JSPX + "/admin/main.action", timeout=40)
html = r.text or ""
if "/admin/login.action" in html and "table-results" not in html:
# CASTGC 还有效的话,这里能无密码把 jspx 会话补回来
if ensure_jspx_session(s):
r = s.get(JSPX + "/admin/main.action", timeout=40)
html = r.text or ""
if "/admin/login.action" in html and "table-results" not in html:
raise SessionExpired("登录已失效:站点把请求跳回了 /admin/login.action(请重新登录)")
if "table-results" not in html:
raise RuntimeError("课程页结构异常:未找到 table-results 表格(站点可能已改版,页面长度 %d)"
% len(html))
return html
def _parse_course_rows(html, section=""):
"""从「我的课程」列表片段里抽出课程行。
行结构(各板块一致):
<table class="table-results"><tr><th>在学课程</th></tr>
<tr><td><p class="course-tt"><a href="courseStudyMain.action?planId=..&projectId=..">名字</a></p>
<p class="course-dd"><span>班期:..</span><span>分数:82</span><span>成绩:合格</span></p>
</td><td><a class="abtn-orange-big">查看</a></td></tr>
</table>
注意 href 是**不带前导斜杠的相对路径**(`courseStudyMain.action?...`),
相对 `/train/trainee/` 解析,不能直接 `JSPX + href`。
"""
soup = BeautifulSoup(html, "lxml")
out = []
for table in soup.select("table.table-results"):
th = table.select_one("th")
sec = section or (re.sub(r"\s+", " ", th.get_text(" ", strip=True)) if th else "")
# 只按「有没有课程行」判断,不能用 "暂无" in table.get_text():
# 表格里任何一个单元格出现「暂无」都会把整张表(含全部课程)丢掉。
rows = [tr for tr in table.select("tr") if tr.select_one("p.course-tt a")]
for tr in rows:
a = tr.select_one("p.course-tt a")
href = unquote(a.get("href") or "")
plan, project = parse_ids(href)
if not plan or not project:
continue
dd = tr.select_one("p.course-dd")
out.append({
"section": sec,
"name": a.get_text(" ", strip=True),
"info": re.sub(r"\s+", " ", dd.get_text(" ", strip=True)) if dd else "",
"plan_id": plan,
"project_id": project,
"url": urljoin(JSPX + "/train/trainee/", href),
"progress": "",
})
return out
def scrape_courses(s, with_progress=True):
"""把「我的课程」下三个板块 × 三种状态**全部**拉出来,含已学完的。
站点把列表拆成了 AJAX 接口,每个组合一次请求:
/train/trainee/studyingCourse.action?courseType=studying&planStatus=TAKING_PLAN
/train/assist/assistanceCourse.action?courseType=assistance&planStatus=FINISHED_PLAN
/train/inspect/inspectingCourse.action?courseType=inspecting&planStatus=NO_BEGAIN_PLAN
...
只看 /admin/main.action 是看不到「已结束」课程的——那个首页只渲染在学/助学/督学
三张「进行中」的概览表,课一学完就从首页消失了。
返回空列表只代表「三个板块九种状态都没课」;
会话失效或页面改版一律抛异常,让调用方能给出准确提示。
"""
# 先用首页校验会话,避免九个接口各自失败还看不出来
_jspx_call(s, lambda: fetch_admin(s))
out, seen = [], set()
for section, path, ctype in COURSE_SECTIONS:
for label, status in PLAN_STATUS:
url = "%s%s?courseType=%s&planStatus=%s" % (JSPX, path, ctype, status)
try:
html = s.get(url, timeout=40).text
except Exception:
continue
for c in _parse_course_rows(html, section):
key = (c["plan_id"], c["project_id"])
if key in seen:
continue
seen.add(key)
c["course_type"] = ctype
c["status"] = status
c["status_label"] = label
c["in_study"] = status in STUDYABLE_STATUS
out.append(c)
# 进度只在「进行中/未开始」的课上查,已结束的动辄几十门,逐个请求太慢
if with_progress:
for c in out:
if not c["in_study"]:
continue
try:
rr = s.get(c["url"], timeout=30)
p = BeautifulSoup(rr.text, "lxml").select_one("p.loading font")
c["progress"] = p.get_text(strip=True) if p else ""
except Exception:
pass
return out
def parse_jspx_course(html):
"""老版 jspx 课程页(tier-tree)解析。"""
soup = BeautifulSoup(html, "lxml")
course = soup.select_one("h1")
prog = soup.select_one("p.loading font")
info = soup.select_one("div.results-info p.info")
cw, fb, ts = [], [], []
for one in soup.select("div.tier-tree-one"):
ns = one.select_one("p.tier1.has-children span.name")
chapter = re.sub(r"\s+", " ", ns.get_text(" ", strip=True)) if ns else "?"
for p in one.select("p.tier3"):
a = p.select_one("a")
if not a:
continue
href = a.get("href") or ""
txt = re.sub(r"\s+", " ", p.get_text(" ", strip=True))
name = p.select_one("span.name")
title = re.sub(r"\s+", " ", name.get_text(" ", strip=True)) if name else txt[:40]
m = re.search(r"(\d+)分钟", txt)
done = "已完成" in txt
if "courseware.action" in href:
cw.append({"chapter": chapter, "title": title, "href": href, "done": done,
"duration": int(m.group(1)) if m else None})
elif "doFeedback.action" in href:
fb.append({"title": title, "href": href, "done": done})
elif "doTest.action" in href:
ts.append({"title": title, "href": href, "done": done})
return {
"user": whoami(html),
"course": course.get_text(" ", strip=True) if course else "?",
"progress": prog.get_text(strip=True) if prog else "?",
"info": re.sub(r"\s+", " ", info.get_text(" ", strip=True)) if info else "?",
"courseware": cw, "feedback": fb, "test": ts,
}
def discover(s, plan, project, name=""):
"""打开课程页并解析;会话失效时先用 CASTGC 补一次 jspx 授权再重试。"""
return _jspx_call(s, lambda: _discover_once(s, plan, project, name))
def _discover_once(s, plan, project, name=""):
# study.py 反向 import 本模块,模块级 import 会成环,用到时再取
from study_tool.pugins.guanyixue.study import (StudyClient, discover_study, extract_study_token,
extract_train_id, save_snapshot,
study_resolve_train_id)
url = "%s/train/trainee/courseStudyMain.action?planId=%s&projectId=%s&trainRole=0" % (JSPX, plan, project)
r = s.get(url, timeout=40)
html = r.text or ""
if "/admin/login.action" in html and len(html) < 2000:
# 课程页也走同一个判断:CASTGC 有效就无密码续期
if ensure_jspx_session(s):
r = s.get(url, timeout=40)
html = r.text or ""
if "没有权限" in html:
raise RuntimeError("该账号对此课程没有权限")
if "/admin/login.action" in html and len(html) < 2000:
raise SessionExpired("登录已失效:课程页把请求跳回了 /admin/login.action(请重新登录)")
# 课程页 302 到 study 时,token 在跳转 URL 里;哪怕 SPA 外壳返回空也要当成学过的新平台
study_url = "study.dgjy.net" in (r.url or "")
tok = extract_study_token(r.url, r.history)
if tok:
host = urlsplit(r.url).hostname or urlsplit(STUDY).hostname
try:
s.cookies.set("Middle-Token", tok, domain=host, path="/")
except Exception:
pass
if len(html) < 3000 and not (study_url or tok or has_study_marker(html, r.url)):
raise RuntimeError("课程页返回异常(页面长度仅 %d,登录可能已失效)" % len(html))
generation = detect_generation(html, r.url)
# 按登录 / 学习入口识别平台;以后新增平台时这里不用改,靠 Platform.entries 命中
pf = registry.detect(r.url, html)
platform_id = pf.id if pf else "guanyixue"
if generation == "legacy":
d = parse_jspx_course(html)
if d["courseware"] or d["feedback"] or d["test"]:
d["platform"] = platform_id
d["generation"] = "legacy"
return d
# 老结构但一个活动都没有:多半是「慕课」外链页(页面还在 jspx,课时在 study 平台)
if not has_study_marker(html, r.url):
snap = save_snapshot(plan, project, r.url, html)
raise RuntimeError("课程页能打开,但没有任何课件/反馈表/测验(页面已存 %s)。"
"若是慕课课程,请确认新平台登录态是否有效" % snap)
elif generation != "spa":
snap = save_snapshot(plan, project, r.url, html)
raise RuntimeError("无法识别课程页结构(站点可能又改版):页面里既没有 jspx 的 "
"tier-tree,也没有新版 SPA 标记。页面已存 %s,请把它发我" % snap)
# ---- 新版平台(Vue SPA + /train/course/front/* JSON 接口)----
tid = extract_train_id(r.url, html)
user = whoami(html)
client = StudyClient(s, r.url)
def _load():
nonlocal tid
if not tid:
tid = study_resolve_train_id(client, name)
if not tid:
snap = save_snapshot(plan, project, r.url, html)
raise RuntimeError("新版课程页未带 trainCourseId,且按课程名也匹配不到(课程名:%s)。"
"页面已存 %s" % (name or "?", snap))
return discover_study(client, tid, name, user)
try:
d = _load()
except SessionExpired:
client.bootstrap()
tid = extract_train_id(r.url, html)
d = _load()
d["platform"] = platform_id
d["generation"] = "spa"
# URL 里可能带 token(凭据),日志/状态里一律打码;鉴权已经写进 cookie
d["page_url"] = re.sub(r"([?&])(token|access_token|securityKey|sign)=[^&]*", r"\1\2=***", r.url or "")
d["plan_id"] = plan
d["project_id"] = project
return d
# ------------------------------------------------------------ 旧版学习流程
class CwItem:
def __init__(self, d, cookies):
self.title = d["title"]
href = d["href"]
self.url = href if href.startswith("http") else JSPX + href
self.s = new_session(cookies)
self.rec = None
self.status = "done" if d.get("done") else "pending"
self.last = None
def resolve(self):
try:
r = self.s.get(self.url, timeout=40)
except Exception as e:
return "exc:%s" % e
m = re.search(r"'id':'([0-9A-F]{32})'", r.text)
if not m:
return "no-id"
self.rec = m.group(1)
return "ok"
def start(self):
try:
r = self.s.post(EP_STUDY, data={"id": self.rec, "order": "server_start",
"updatePassPerc": "true"}, timeout=40)
return r.text.strip()[:40]
except Exception as e:
return "EXC:%s" % e
def beat(self):
try:
r = self.s.post(EP_STUDY, data={"id": self.rec, "order": "server_time",
"updatePassPerc": "true",
"videoSchedule": 30}, timeout=40)
return r.text.strip()[:60]
except Exception as e:
return "EXC:%s" % e
def submit_feedback(s, href, key):
url = href if href.startswith("http") else JSPX + href
r = s.get(url, timeout=40)
soup = BeautifulSoup(r.text, "lxml")
fields = {i.get("name"): i.get("value") for i in soup.select("form input[type=hidden]") if i.get("name")}
if fields.get("allowStudy") == "false":
log(key, "反馈表:allowStudy=false,跳过")
return
r2 = s.post(JSPX + "/train/trainee/doFeedbackList.action", data=fields, timeout=40)
qs = BeautifulSoup(r2.text, "lxml")
answers = []
for it in qs.select("div.job-item"):
qtype = it.get("questype")
opts = [sp.get("data-name") for sp in it.select(".job-item-a span")]
ans = ("课程内容充实,讲师讲解清晰,对我的教学实践很有帮助,希望今后能增加更多一线教学案例。"
if qtype == "04" else (opts[0] if opts else "A"))
answers.append({"id": it.get("traineeid"), "quesId": it.get("qumid"),
"quesType": qtype, "quesNo": it.get("quesno"), "answer": ans})
payload = {"planId": fields.get("planId"), "objectId": fields.get("objectId"),
"answers": json.dumps(answers, ensure_ascii=False, separators=(",", ":")),
"submitStatus": "finish"}
rr = s.post(JSPX + "/train/trainee/saveFeedback.action", data=payload, timeout=40)
log(key, "反馈表:%d 题,%s" % (len(answers), "提交成功" if "MSG" not in rr.text else "返回 " + rr.text[:80]))
def submit_quiz(s, href, key, answer_key=None):
url = href if href.startswith("http") else JSPX + href
plan, _ = parse_ids(url)
m = re.search(r"objectId=([0-9A-Fa-f]+)", url)
obj = m.group(1) if m else ""
rr = s.get("%s/train/trainee/showTestQums.action?planId=%s&objectId=%s" % (JSPX, plan, obj), timeout=40)
qs = BeautifulSoup(rr.text, "lxml")
singles = (answer_key or {}).get("singles", {})
multis = (answer_key or {}).get("multis", {})
answers = []
for it in qs.select("div.job-item"):
t, qno = it.get("questype"), it.get("quesno")
opts = [sp.get("data-name") for sp in it.select(".job-item-a span")]
if t == "02":
ans = (multis.get(qno) or ("#".join(opts) if opts else "A"))
else:
ans = singles.get(qno) or (opts[0] if opts else "A")
answers.append({"id": it.get("traineeid"), "quesId": it.get("qumid"),
"quesType": t, "quesNo": qno, "answer": ans})
if not answers:
log(key, "测验:未取到题目,跳过")
return
payload = {"planId": plan, "objectId": obj,
"answers": json.dumps(answers, ensure_ascii=False, separators=(",", ":"))}
r3 = s.post(JSPX + "/train/trainee/endTest.action", data=payload, timeout=40)
r4 = s.get("%s/train/trainee/doTest.action?planId=%s&objectId=%s" % (JSPX, plan, obj), timeout=40)
sc = re.search(r"得分:\s*(\d+)\s*分",
re.sub(r"\s+", " ", BeautifulSoup(r4.text, "lxml").get_text(" ", strip=True)))
log(key, "测验:%d 题,%s,得分 %s" %
(len(answers), "提交成功" if "MSG" not in r3.text else r3.text[:60],
sc.group(1) if sc else "?"))
def _study_builtin(key, s, d, workers=1, cap=30, enable_quiz=False, enable_feedback=False,
mode="fast", parallel=1, quiz_key="", cookies=None, plan="", project="",
name=""):
"""旧版 jspx 的内置流程:反馈表 + 测验 + 刷课件。"""
if enable_feedback:
for fb in d["feedback"]:
if fb["done"]:
log(key, "反馈表已完成,跳过")
continue
try:
submit_feedback(s, fb["href"], key)
except Exception as e:
log(key, "反馈表异常:%s" % e)
else:
for fb in d["feedback"]:
log(key, "反馈表:已按设置跳过(%s)" % ("已完成" if fb["done"] else "未完成"))
if enable_quiz:
for t in d["test"]:
if t["done"]:
log(key, "测验已完成,跳过")
continue
try:
submit_quiz(s, t["href"], key, quiz_key)
except Exception as e:
log(key, "测验异常:%s" % e)
else:
for t in d["test"]:
if t["done"]:
log(key, "测验:已完成,跳过")
else:
log(key, "测验:⚠ 已按设置跳过答题(避免因正确率不足卡住「完成」),"
"需要时请在页面上手动作答,或打开「自动交测验」开关")
objs = [CwItem(c, cookies) for c in d["courseware"]]
todo = [o for o in objs if o.status != "done"]
done0 = len(objs) - len(todo)
set_state(key, status="studying", total=len(objs), done=done0)
if todo:
log(key, "解析 %d 个学习记录…" % len(todo))
with ThreadPoolExecutor(max_workers=min(workers, len(todo))) as ex:
list(ex.map(lambda x: x.resolve(), todo))
live = [o for o in todo if o.rec]
log(key, "已解析 %d/%d,并发启动会话" % (len(live), len(todo)))
with ThreadPoolExecutor(max_workers=min(workers, len(live))) as ex:
list(ex.map(lambda x: x.start(), live))
t0 = time.time()
while time.time() - t0 < cap * 60:
with RUNS_LOCK:
if (RUNS.get(key) or {}).get("status") == "stopped":
log(key, "已按要求停止")
return
pending = [o for o in live if o.status != "done"]
if not pending:
break
with ThreadPoolExecutor(max_workers=min(workers, len(pending))) as ex:
resps = list(ex.map(lambda x: x.beat(), pending))
newly = []
for o, resp in zip(pending, resps):
o.last = resp
if resp == "study_over":
o.status = "done"
newly.append(o)
el = int(time.time() - t0)
dc = len([o for o in live if o.status == "done"]) + done0
set_state(key, done=dc, elapsed=el)
log(key, "第 %d 轮 %dm%02ds 完成 %d/%d" % (el // 60 + 1, el // 60, el % 60, dc, len(objs)))
for o in newly:
log(key, " ✓ %s" % o.title[:40])
time.sleep(60)
else:
log(key, "所有课件均已完成")
try:
d2 = discover(s, plan, project, name)
set_state(key, progress=d2["progress"], info=d2["info"], cookies=dump_cookies(s))
log(key, "课程结束:%s | %s" % (d2["progress"], d2["info"]))
except Exception as e:
log(key, "复查失败:%s" % e)
# ---------------------------------------------------------------- 平台注册
def _study(key, s, d, **opts):
"""旧版页面:反馈表 + 测验 + 刷课件。"""
return _study_builtin(key, s, d, **opts)
-46
View File
@@ -1,46 +0,0 @@
"""莞易学平台:jspx.dgjy.net(登录入口)与 study.dgjy.net(学习入口)是同一个平台。
账号在「添加账号」里选择平台(`accounts.platform`),登录 / 刷新课程 / 发现课程都会
走这里的 `login` / `list_courses` / `discover`;学习时 `discover` 会把页面代际写进
`course["generation"]`(legacy / spa),由 `study` 分发到对应实现。
新增其他平台时,照本文件写一个 `Platform` 子类并在 `entries` 声明登录 / 学习入口。
"""
from study_tool.platforms import registry
from study_tool.pugins.guanyixue import jspx, study
from study_tool.platforms.base import Platform
class Guanyixue(Platform):
id = "guanyixue"
label = "莞易学"
# 登录入口 / 学习入口;命中任意一个就归到莞易学
entries = ("jspx.dgjy.net", "study.dgjy.net", "dgjy.net")
def session(self, cookies=None):
return jspx.new_session(cookies)
def dump_cookies(self, s):
return jspx.dump_cookies(s)
def login(self, username, password):
return jspx.login(username, password)
def list_courses(self, s):
return jspx.scrape_courses(s)
def discover(self, s, plan_id, project_id, name=""):
return jspx.discover(s, plan_id, project_id, name)
def renew_session(self, s):
return jspx.ensure_jspx_session(s)
def profile(self, s):
return jspx.whoami(jspx.fetch_admin(s))
def study(self, key, s, d, **opts):
if d.get("generation") == "spa":
return study._study(key, s, d, **opts)
return jspx._study(key, s, d, **opts)
PLATFORM = registry.register(Guanyixue())
+12 -1
View File
@@ -1 +1,12 @@
"""HTTP 路由包:按业务域拆分,导入即注册到 study_tool.webapp.app。"""
"""HTTP 路由包:按业务域拆分,导入即注册到 study_tool.webapp.app。
新增路由文件不用改任何地方:load_routes() 会扫描本目录并导入全部模块。
"""
import importlib
import pkgutil
def load_routes():
for m in pkgutil.iter_modules(__path__):
importlib.import_module(f"{__name__}.{m.name}")
+40 -32
View File
@@ -1,29 +1,30 @@
"""账号 / 登录 / 登录记录接口。"""
import time
from fastapi.responses import JSONResponse
from study_tool.webapp import app
from study_tool import store
from study_tool.config import *
from study_tool.models import *
from study_tool import database, notify
from study_tool.models import AccountIn, LoginIn
from study_tool.platforms import registry
from study_tool.runtime import RUNS, RUNS_LOCK, log
from study_tool.webapp import app
def _log_login(username, ok, code, error, elapsed, stage=""):
"""每次登录尝试:统一日志 + login_log 表(登录记录页用)。"""
log(username or "-", "登录%s code=%s%s %s %.1fs" % (
"成功" if ok else "失败", code, (" " + stage) if stage else "",
(error or "")[:120], elapsed or 0))
log(
username or "-",
"登录%s code=%s%s %s %.1fs"
% ("成功" if ok else "失败", code, (" " + stage) if stage else "", (error or "")[:120], elapsed or 0),
)
try:
store.login_log_add(username, ok, code, elapsed, stage, error)
database.login_log_add(username, ok, code, elapsed, stage, error)
except Exception:
pass
_RUN_FIELDS = ("status", "error", "progress", "done", "total", "course",
"started", "finished_at")
_RUN_FIELDS = ("status", "error", "progress", "done", "total", "course", "started", "finished_at", "cookies")
def _clear_done_run(account_id):
@@ -45,14 +46,14 @@ def _clear_done_run(account_id):
for field in _RUN_FIELDS:
st.pop(field, None)
st["updated"] = time.time()
store.run_state_delete(key)
database.run_state_delete(key)
def _login_account(b: LoginIn):
"""解析出一组可登录的凭据:显式传入优先,否则按账号 id 从库里取。"""
acc = store.account_get(b.account_id) if b.account_id else None
acc = database.account_get(b.account_id) if b.account_id else None
username = (b.username or "").strip() or ((acc or {}).get("username") or "")
password = b.password or (store.account_password(b.account_id) if b.account_id else "")
password = b.password or (database.account_password(b.account_id) if b.account_id else "")
return acc, username, password
@@ -62,13 +63,13 @@ def api_login(b: LoginIn):
acc, username, password = _login_account(b)
if not username or not password:
err = "没有可用的账号密码:新账号请在「添加账号」里填密码,服务端会把它存进 SQLite"
_log_login(username or b.account_id, False, "no_credential", err, time.time() - t0,
"LoginIn")
_log_login(username or b.account_id, False, "no_credential", err, time.time() - t0, "LoginIn")
return JSONResponse({"ok": False, "code": "no_credential", "error": err}, status_code=200)
pf = registry.get((acc or {}).get("platform") or b.platform)
if pf is None or pf.login is None:
err = "平台「%s」还没有实现登录,请换一个平台或先在 platforms/ 里补上实现" % (
(acc or {}).get("platform") or b.platform)
err = "平台「%s」还没有实现登录,请换一个平台或先在 plugins/ 里补上实现" % (
(acc or {}).get("platform") or b.platform
)
_log_login(username, False, "no_platform", err, time.time() - t0, "LoginIn")
return JSONResponse({"ok": False, "code": "no_platform", "error": err}, status_code=200)
try:
@@ -76,10 +77,17 @@ def api_login(b: LoginIn):
except Exception as e:
err = str(e)[:300]
_log_login(username, False, "login_failed", err, time.time() - t0, type(e).__name__)
notify.fault("login", (acc or {}).get("id") or "system", "登录失败", "账号:%s\n原因:%s" % (username, err))
if acc:
store.account_upsert({"id": acc["id"], "username": acc["username"],
"status": "login_failed", "lastError": err,
"lastErrorAt": time.time()})
database.account_upsert(
{
"id": acc["id"],
"username": acc["username"],
"status": "login_failed",
"lastError": err,
"lastErrorAt": time.time(),
}
)
return JSONResponse({"ok": False, "code": "login_failed", "error": err}, status_code=200)
warn = ""
try:
@@ -89,23 +97,24 @@ def api_login(b: LoginIn):
cookies = pf.dump_cookies(s) if pf.dump_cookies else []
_log_login(username, True, "ok", warn, time.time() - t0, "user=%s" % user)
if acc:
store.account_save_login(acc["id"], user, cookies, courses)
_clear_done_run(acc["id"]) # 上一轮已结束的运行状态不再挂在新会话上
database.account_save_login(acc["id"], user, cookies, courses)
_clear_done_run(acc["id"]) # 上一轮已结束的运行状态不再挂在新会话上
return {"ok": True, "user": user, "cookies": cookies, "courses": courses, "warn": warn}
@app.get("/api/loginlog")
def api_loginlog(tail: int = 40):
"""登录尝试回放:前端点「登录」后,这里能看到平台到底回了什么。"""
return {"ok": True, "lines": store.login_log_tail(tail)}
return {"ok": True, "lines": database.login_log_tail(tail)}
# ------------------------------------------------------------------ accounts
@app.get("/api/accounts")
def api_accounts():
"""全部账号(含会话 cookie / 课程缓存 / 勾选),**不含密码**。"""
return {"ok": True, "accounts": store.account_list()}
return {"ok": True, "accounts": database.account_list()}
@app.post("/api/accounts")
@@ -117,16 +126,15 @@ def api_account_save(b: AccountIn):
if not rec.get("id") and not rec.get("username"):
return JSONResponse({"ok": False, "error": "缺少 username"}, status_code=200)
try:
aid = store.account_upsert(rec)
aid = database.account_upsert(rec)
except Exception as e:
return JSONResponse({"ok": False, "error": "写入 SQLite 失败:%s" % str(e)[:200]},
status_code=200)
return {"ok": True, "id": aid, "account": store.account_get(aid)}
return JSONResponse({"ok": False, "error": "写入 SQLite 失败:%s" % str(e)[:200]}, status_code=200)
return {"ok": True, "id": aid, "account": database.account_get(aid)}
@app.get("/api/accounts/{account_id}")
def api_account_get(account_id: str):
acc = store.account_get(account_id)
acc = database.account_get(account_id)
if not acc:
return JSONResponse({"ok": False, "error": "账号不存在"}, status_code=200)
return {"ok": True, "account": acc}
@@ -134,8 +142,8 @@ def api_account_get(account_id: str):
@app.delete("/api/accounts/{account_id}")
def api_account_del(account_id: str):
acc = store.account_get(account_id) or {}
store.account_delete(account_id)
acc = database.account_get(account_id) or {}
database.account_delete(account_id)
with RUNS_LOCK:
RUNS.pop(account_id, None)
# 登录日志挂在用户名下,一起清掉,不然删完账号日志还在
+29
View File
@@ -0,0 +1,29 @@
"""大模型连通性测试接口。"""
import time
from study_tool.webapp import app
from study_tool.config.settings import load_settings
from study_tool.llm import llm_chat
from study_tool.models import LlmTestIn
@app.post("/api/llm/test")
def api_llm_test(b: LlmTestIn):
cfg = dict(load_settings()["llm"])
if b.base_url.strip():
cfg["base_url"] = b.base_url.strip()
if b.model.strip():
cfg["model"] = b.model.strip()
if b.api_key.strip():
cfg["api_key"] = b.api_key.strip()
cfg["timeout"] = max(10, min(120, int(b.timeout or 60)))
if not cfg.get("api_key"):
return {"ok": False, "error": "没有 api_key"}
t0 = time.time()
try:
reply = llm_chat([{"role": "user", "content": "只回复两个字符:ok"}], cfg,
max_tokens=8, json_mode=False)
return {"ok": True, "ms": int((time.time() - t0) * 1000), "reply": (reply or "")[:80]}
except Exception as e:
return {"ok": False, "error": str(e)[:300]}
+11 -4
View File
@@ -1,15 +1,22 @@
"""/api/log、/api/runs:前端轮询日志与运行状态。"""
from study_tool.webapp import app
from study_tool.runtime import RUNS, RUNS_LOCK
from study_tool.webapp import app
@app.get("/api/log")
def api_log(key: str, since: int = 0):
st = RUNS.get(key) or {}
log = st.get("log", [])
out = {"lines": log[since:], "next": len(log), "status": st.get("status", ""),
"done": st.get("done", 0), "total": st.get("total", 0),
"course": st.get("course", ""), "progress": st.get("progress", "")}
out = {
"lines": log[since:],
"next": len(log),
"status": st.get("status", ""),
"done": st.get("done", 0),
"total": st.get("total", 0),
"course": st.get("course", ""),
"progress": st.get("progress", ""),
}
# discover 可能给账号补上了 study.dgjy.net 的 Middle-Token,回传给前端写回 IndexedDB
if st.get("cookies"):
out["cookies"] = st["cookies"]
+118 -62
View File
@@ -1,58 +1,60 @@
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志 / 插件管理接口。"""
import threading
import time
from fastapi.responses import HTMLResponse, JSONResponse
from study_tool.webapp import app
from study_tool import pugins, store
from study_tool.capture import *
from study_tool.config import *
from study_tool import plugins, database
from study_tool.config.settings import load_settings
from study_tool.engine import study_worker
from study_tool.models import *
from study_tool.models import CaptureIn, CookiesIn, DiscoverIn, StopIn, StudyIn
from study_tool.platforms import registry
from study_tool.platforms.errors import SessionExpired
from study_tool.runtime import RUNS, RUNS_LOCK, log, set_state
from study_tool.settings_store import load_settings
from study_tool.webapp import app
# ------------------------------------------------------------------ 平台
@app.get("/api/platforms")
def api_platforms():
"""已注册平台;label 给界面展示,capabilities 说明该平台实现了哪些阶段。"""
return {"ok": True, "platforms": [{"id": p.id, "label": p.label,
"capabilities": p.capabilities()}
for p in registry.all()]}
return {
"ok": True,
"platforms": [{"id": p.id, "label": p.label, "capabilities": p.capabilities()} for p in registry.all()],
}
# ------------------------------------------------------------------ 插件管理
# 插件目录 pugins/<名字>/,实现见 study_tool.pugins。
# 插件目录 plugins/<名字>/,实现见 study_tool.plugins。
@app.get("/api/plugins")
def api_plugins():
"""磁盘上的插件清单:是否启用、是否已加载、加载后的 label 与 capabilities。"""
return {"ok": True, "plugins": pugins.discover()}
return {"ok": True, "plugins": plugins.discover()}
@app.post("/api/plugins/{name}/enable")
def api_plugin_enable(name: str):
return _plugin_call(lambda: pugins.set_enabled(name, True))
return _plugin_call(lambda: plugins.set_enabled(name, True))
@app.post("/api/plugins/{name}/disable")
def api_plugin_disable(name: str):
return _plugin_call(lambda: pugins.set_enabled(name, False))
return _plugin_call(lambda: plugins.set_enabled(name, False))
@app.post("/api/plugins/reload")
def api_plugins_reload():
"""重载全部已启用插件:改完插件代码不用重启服务。"""
return _plugin_call(pugins.reload)
return _plugin_call(plugins.reload)
@app.post("/api/plugins/{name}/reload")
def api_plugin_reload(name: str):
return _plugin_call(lambda: pugins.reload(name))
return _plugin_call(lambda: plugins.reload(name))
def _plugin_call(fn):
@@ -60,23 +62,24 @@ def _plugin_call(fn):
try:
return {"ok": True, "plugins": fn()}
except Exception as e:
return {"ok": False, "error": str(e), "plugins": pugins.discover()}
return {"ok": False, "error": str(e), "plugins": plugins.discover()}
# ------------------------------------------------------------------ 运行日志 / 快照
@app.get("/api/log/history")
def api_log_history(key: str = "", tail: int = 200):
"""从 SQLite 读历史日志;不带 key 时列出每个账号存了多少行。"""
if not key:
return {"ok": True, "keys": store.run_log_keys()}
return {"ok": True, "key": key, "lines": store.run_log_tail(key, tail)}
return {"ok": True, "keys": database.run_log_keys()}
return {"ok": True, "key": key, "lines": database.run_log_tail(key, tail)}
@app.delete("/api/log/history")
def api_log_history_clear(key: str = ""):
"""清历史日志;带 key 时连内存里的那一段也清掉,前端不用刷新页面。"""
store.run_log_clear(key)
database.run_log_clear(key)
with RUNS_LOCK:
st = RUNS.get(key)
if st is not None:
@@ -87,38 +90,50 @@ def api_log_history_clear(key: str = ""):
@app.get("/api/snapshots")
def api_snapshots(limit: int = 50):
"""站点改版时存下的课程页原始 HTML 列表。"""
return {"ok": True, "snapshots": store.snapshot_list(limit)}
return {"ok": True, "snapshots": database.snapshot_list(limit)}
@app.get("/api/snapshot/{sid}", response_class=HTMLResponse)
def api_snapshot(sid: int):
row = store.snapshot_get(sid)
row = database.snapshot_get(sid)
if not row:
return HTMLResponse("<h3>快照不存在</h3>", status_code=404)
head = ("<!-- id=%s planId=%s projectId=%s final url: %s created=%s -->\n"
% (row["id"], row["plan_id"], row["project_id"], row["url"],
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(row["created"] or 0))))
head = "<!-- id=%s planId=%s projectId=%s final url: %s created=%s -->\n" % (
row["id"],
row["plan_id"],
row["project_id"],
row["url"],
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(row["created"] or 0)),
)
return HTMLResponse(head + (row["html"] or ""))
@app.post("/api/courses")
def api_courses(b: CookiesIn):
if not b.cookies:
return JSONResponse({"ok": False, "code": "no_cookie",
"error": "本机没有该账号的会话 cookie,请先点「登录」"}, status_code=200)
return JSONResponse(
{"ok": False, "code": "no_cookie", "error": "本机没有该账号的会话 cookie,请先点「登录」"}, status_code=200
)
pf = registry.get(b.platform)
if pf is None or pf.list_courses is None:
return JSONResponse({"ok": False, "code": "no_platform",
"error": "平台「%s」还没有实现刷新课程,请先在 platforms/ 里补上"
% b.platform}, status_code=200)
return JSONResponse(
{
"ok": False,
"code": "no_platform",
"error": "平台「%s」还没有实现刷新课程,请先在 plugins/ 里补上" % b.platform,
},
status_code=200,
)
if pf.session is None:
return JSONResponse({"ok": False, "code": "no_platform",
"error": "平台「%s」还没有实现 session" % b.platform}, status_code=200)
return JSONResponse(
{"ok": False, "code": "no_platform", "error": "平台「%s」还没有实现 session" % b.platform}, status_code=200
)
s = pf.session(b.cookies)
if not s.cookies:
return JSONResponse({"ok": False, "code": "no_cookie",
"error": "保存的 cookie 全部被过滤(站点域名不匹配),请重新登录"},
status_code=200)
return JSONResponse(
{"ok": False, "code": "no_cookie", "error": "保存的 cookie 全部被过滤(站点域名不匹配),请重新登录"},
status_code=200,
)
try:
courses = pf.list_courses(s)
except SessionExpired as e:
@@ -126,28 +141,39 @@ def api_courses(b: CookiesIn):
except Exception as e:
return JSONResponse({"ok": False, "code": "fetch_failed", "error": str(e)[:300]}, status_code=200)
if not courses:
return JSONResponse({"ok": False, "code": "no_course",
"error": "登录态有效,但该账号名下三个板块(在学/助学/督学)"
"的进行中、未开始、已结束里都没有课程"}, status_code=200)
return JSONResponse(
{
"ok": False,
"code": "no_course",
"error": "登录态有效,但该账号名下三个板块(在学/助学/督学)的进行中、未开始、已结束里都没有课程",
},
status_code=200,
)
try:
user = pf.profile(s) if pf.profile else ""
except Exception:
user = ""
return {"ok": True, "courses": courses, "user": user,
"in_study": len([c for c in courses if c["in_study"]]),
# 补 jspx 会话时 cookie 会换新(JSESSIONID/route),回传让前端写回
"cookies": (pf.dump_cookies(s) if pf.dump_cookies else [])}
return {
"ok": True,
"courses": courses,
"user": user,
"in_study": len([c for c in courses if c["in_study"]]),
# 补 jspx 会话时 cookie 会换新(JSESSIONID/route),回传让前端写回
"cookies": (pf.dump_cookies(s) if pf.dump_cookies else []),
}
@app.post("/api/discover")
def api_discover(b: DiscoverIn):
pf = registry.get(b.platform)
if pf is None or pf.discover is None:
return JSONResponse({"ok": False, "code": "no_platform",
"error": "平台「%s」还没有实现课程发现" % b.platform}, status_code=200)
return JSONResponse(
{"ok": False, "code": "no_platform", "error": "平台「%s」还没有实现课程发现" % b.platform}, status_code=200
)
if pf.session is None:
return JSONResponse({"ok": False, "code": "no_platform",
"error": "平台「%s」还没有实现 session" % b.platform}, status_code=200)
return JSONResponse(
{"ok": False, "code": "no_platform", "error": "平台「%s」还没有实现 session" % b.platform}, status_code=200
)
s = pf.session(b.cookies)
try:
d = pf.discover(s, b.plan_id, b.project_id, b.name)
@@ -170,17 +196,33 @@ def api_study(b: StudyIn):
return JSONResponse({"ok": False, "error": "没有选中任何课程"}, status_code=200)
set_state(b.key, status="discovering", log=[], done=0, total=0, started=time.time())
# 会话失效时后台要能自动重登:优先用请求里的密码,没有就按 key 从 SQLite 取
pw = b.password or store.account_password(b.key) or ""
pw = b.password or database.account_password(b.key) or ""
st = load_settings()["study"]
threading.Thread(target=study_worker,
args=(b.key, b.username, b.cookies, courses,
max(1, min(b.workers, 95)), b.cap_minutes, b.quiz_key,
b.enable_quiz, b.enable_feedback, pw,
st.get("mode") or "fast", int(st.get("parallel") or 2)),
daemon=True).start()
return {"ok": True, "courses": len(courses),
"enable_quiz": b.enable_quiz, "enable_feedback": b.enable_feedback,
"study": st}
threading.Thread(
target=study_worker,
args=(
b.key,
b.username,
b.cookies,
courses,
max(1, min(b.workers, 95)),
b.cap_minutes,
b.quiz_key,
b.enable_quiz,
b.enable_feedback,
pw,
st.get("mode") or "fast",
int(st.get("parallel") or 2),
),
daemon=True,
).start()
return {
"ok": True,
"courses": len(courses),
"enable_quiz": b.enable_quiz,
"enable_feedback": b.enable_feedback,
"study": st,
}
@app.post("/api/stop")
@@ -190,6 +232,12 @@ def api_stop(b: StopIn):
return {"ok": True}
def _capture_platform(key):
"""抓包是平台自己的能力:按账号所属平台取插件。"""
acc = database.account_get(key) or {}
return registry.get(acc.get("platform"))
@app.post("/api/capture")
def api_capture(b: CaptureIn):
"""开一个可见的浏览器让用户手动走一遍,记录所有 /train/** 请求与响应。"""
@@ -197,12 +245,15 @@ def api_capture(b: CaptureIn):
return JSONResponse({"ok": False, "error": "正在抓包"}, status_code=200)
if not b.cookies:
return JSONResponse({"ok": False, "error": "尚未登录"}, status_code=200)
CAPTURES[b.key] = []
pf = _capture_platform(b.key)
if pf is None or pf.capture is None:
return JSONResponse({"ok": False, "error": "该平台还不支持抓包"}, status_code=200)
pf.capture_reset(b.key)
set_state(b.key, capturing=True, capture_stop=False, capture_file="")
log(b.key, "正在打开浏览器…")
threading.Thread(target=capture_worker,
args=(b.key, b.cookies, max(1, min(int(b.minutes or 10), 30))),
daemon=True).start()
threading.Thread(
target=pf.capture, args=(b.key, b.cookies, max(1, min(int(b.minutes or 10), 30))), daemon=True
).start()
return {"ok": True}
@@ -216,6 +267,11 @@ def api_capture_stop(b: StopIn):
@app.get("/api/capture")
def api_capture_get(key: str = ""):
st = RUNS.get(key) or {}
return {"ok": True, "capturing": bool(st.get("capturing")),
"file": st.get("capture_file") or "",
"records": CAPTURES.get(key) or []}
pf = _capture_platform(key)
records = pf.captures(key) if pf is not None and pf.capture is not None else []
return {
"ok": True,
"capturing": bool(st.get("capturing")),
"file": st.get("capture_file") or "",
"records": records,
}
+35
View File
@@ -0,0 +1,35 @@
"""公开查询接口:不需要 token,按用户名返回课程列表与学习进度(不含密码 / cookie / 账号 id)。"""
from study_tool import database
from study_tool.platforms import registry
from study_tool.runtime import RUNS, RUNS_LOCK
from study_tool.webapp import app
_COURSE_FIELDS = ("name", "section", "status_label", "info", "progress")
_RUN_FIELDS = ("status", "done", "total", "course", "progress", "error")
@app.get("/api/query")
def api_query(username: str = ""):
name = username.strip()
if not name:
return {"ok": False, "error": "请输入用户名"}
items = []
for a in database.account_list():
if a["username"] != name:
continue
with RUNS_LOCK:
run = dict(RUNS.get(a["id"]) or {})
pf = registry.get(a["platform"])
items.append(
{
"username": a["username"],
"user": a["user"],
"platform": pf.label if pf else a["platform"],
"status": a["status"],
"loginAt": a["loginAt"],
"courses": [{k: c.get(k) for k in _COURSE_FIELDS} for c in a["courses"]],
"run": {k: run[k] for k in _RUN_FIELDS if k in run},
}
)
return {"ok": True, "accounts": items}
+92
View File
@@ -0,0 +1,92 @@
"""系统设置接口(大模型 / 学习方式 / 故障推送)。"""
from study_tool import notify
from study_tool.webapp import app
from study_tool.config.settings import load_settings, save_settings
from study_tool.models import LlmCfgIn
from study_tool.runtime import log
def _mask(value):
"""密钥只回显前后几位,前端不回显明文。"""
value = value or ""
if len(value) > 10:
return value[:4] + "…" + value[-4:]
return "已设置" if value else ""
@app.get("/api/settings")
def api_get_settings():
cfg = load_settings()
llm = cfg["llm"]
key = llm.get("api_key") or ""
out = dict(llm)
out["api_key"] = ""
out["has_key"] = bool(key)
out["key_hint"] = (key[:4] + "…" + key[-4:]) if len(key) > 10 else ("已设置" if key else "")
nt = dict(cfg["notify"])
secret = nt.get("app_secret") or ""
nt["app_secret"] = ""
nt["has_secret"] = bool(secret)
nt["secret_hint"] = _mask(secret)
return {"ok": True, "llm": out, "study": cfg["study"], "notify": nt}
@app.post("/api/settings")
def api_set_settings(b: LlmCfgIn):
cur = load_settings()
llm = cur["llm"]
if b.base_url.strip():
llm["base_url"] = b.base_url.strip()
if b.clear_api_key: # 显式清空(前端「清空」按钮),留空仍然表示保留
llm["api_key"] = ""
elif b.api_key.strip(): # 留空 = 保留原 key,前端不回显明文
llm["api_key"] = b.api_key.strip()
if b.model.strip():
llm["model"] = b.model.strip()
if b.temperature > 0: # 0 = 不改,避免前端留空把温度清成 0
llm["temperature"] = max(0.0, min(2.0, float(b.temperature)))
llm["timeout"] = max(10, min(600, int(b.timeout or 120)))
llm["prompt"] = b.prompt or ""
if b.study_mode in ("fast", "normal"): # 空串 = 不改
cur["study"]["mode"] = b.study_mode
if b.study_parallel: # 0 = 不改
cur["study"]["parallel"] = max(1, min(int(b.study_parallel), 5))
if b.notify is not None:
nt = cur["notify"]
nb = b.notify
if nb.base_url.strip():
nt["base_url"] = nb.base_url.strip()
if nb.app_key.strip():
nt["app_key"] = nb.app_key.strip()
if nb.clear_app_secret: # 显式清空(前端「清空」按钮)
nt["app_secret"] = ""
elif nb.app_secret.strip(): # 留空 = 保留原密钥
nt["app_secret"] = nb.app_secret.strip()
nt["channel_name"] = nb.channel_name.strip()
for flag in ("enabled", "on_error", "on_login", "on_progress", "on_retry"):
value = getattr(nb, flag)
if value is not None:
nt[flag] = bool(value)
if nb.retry_threshold: # 0 = 不改
nt["retry_threshold"] = max(2, min(int(nb.retry_threshold), 10))
if nb.cooldown_min:
nt["cooldown_min"] = max(1, min(int(nb.cooldown_min), 1440))
save_settings(cur)
log("system", "设置已更新:模型 %s(%s),学习方式 %s/%s 路,故障推送 %s" % (
cur["llm"]["model"], cur["llm"]["base_url"],
cur["study"]["mode"], cur["study"]["parallel"],
"开" if cur["notify"]["enabled"] else "关"))
return {"ok": True, "study": cur["study"]}
@app.post("/api/notify/test")
def api_notify_test():
"""测试推送:走当前启用的渠道插件,直接发一条。"""
ok, message = notify.send(
"【测试】学习控制台推送",
"这是一条测试推送。\n收到说明 base_url / app_key / app_secret / channel_name 都配好了。",
{"kind": "test", "status": "success"},
)
log("system", "测试推送%s:%s" % ("成功" if ok else "失败", message), level="info" if ok else "warn")
return {"ok": ok, "message": message}
+8 -9
View File
@@ -3,18 +3,19 @@
日志只有一个出口 log():
* 控制台:带上时间/级别/key,docker compose logs 也能看
* 内存:RUNS[key]["log"],/api/log 供前端轮询
* SQLite:store.run_log_add,/api/log/history 重启后还能查
* SQLite:database.run_log_add,/api/log/history 重启后还能查
新代码不要再自己 print(),也不要直接写 run_log 表。
"""
import threading
import time
from study_tool import store
from study_tool import database
RUNS = {}
RUNS_LOCK = threading.Lock()
MAX_LINES = 1000 # 每个 key 内存里最多留 1000 行
TRIM_LINES = 300 # 超了先丢最旧的 300 行
MAX_LINES = 1000 # 每个 key 内存里最多留 1000 行
TRIM_LINES = 300 # 超了先丢最旧的 300 行
def log(key, msg, level="info", persist=True):
@@ -23,7 +24,7 @@ def log(key, msg, level="info", persist=True):
line = "%s [%s] %s" % (time.strftime("%Y-%m-%d %H:%M:%S"), level, msg)
try:
print("%s [%s]" % (line, key), flush=True)
except UnicodeEncodeError: # Windows 控制台 GBK 编不了 ⚠ 之类
except UnicodeEncodeError: # Windows 控制台 GBK 编不了 ⚠ 之类
print(("%s [%s]" % (line, key)).encode("gbk", "replace").decode("gbk"), flush=True)
with RUNS_LOCK:
st = RUNS.setdefault(key, {})
@@ -34,7 +35,7 @@ def log(key, msg, level="info", persist=True):
st["updated"] = time.time()
if persist:
try:
store.run_log_add(key, line)
database.run_log_add(key, line)
except Exception:
pass
return line
@@ -53,6 +54,4 @@ def set_state(key, **kw):
st.pop(stale, None)
st.update(kw)
st["updated"] = time.time()
store.run_state_set(key, RUNS.get(key) or {})
database.run_state_set(key, RUNS.get(key) or {})
-90
View File
@@ -1,90 +0,0 @@
"""系统设置(大模型 + 学习方式)读写。"""
from study_tool import store
from study_tool.webapp import app
from study_tool.config import *
from study_tool.models import *
from study_tool.runtime import log
# ------------------------------------------------------------------ settings
SETTINGS_NAME = "llm"
DEFAULT_LLM = {
"base_url": "https://api.openai.com/v1",
"api_key": "",
"model": "gpt-4o-mini",
"temperature": 0.2,
"timeout": 120,
"prompt": "",
}
# 学习方式:fast = 只调 study/log 接口,不开浏览器;normal = 真播视频(可多路并发)
DEFAULT_STUDY = {
"mode": "fast",
"parallel": 2,
}
DEFAULT_ANSWER_PROMPT = (
"你是考试答题助手。根据题干和选项判断正确答案。\n"
"单选题返回一个选项字母;多选题返回多个字母,用 # 连接(如 A#C);判断题只返回 A 或 B(A=正确,B=错误)。\n"
"只输出 JSON:{\"answers\":{\"题号\":\"答案\"}},不要输出解释或多余文字。"
)
TYPE_LABEL = {"01": "单选", "02": "多选", "03": "判断", "04": "问答"}
def load_settings():
data = store.get_setting(SETTINGS_NAME) or {}
llm = dict(DEFAULT_LLM)
study = dict(DEFAULT_STUDY)
if isinstance(data, dict):
raw = data.get("llm")
if isinstance(raw, dict):
llm.update({k: v for k, v in raw.items() if k in DEFAULT_LLM and v is not None})
st = data.get("study")
if isinstance(st, dict):
study.update({k: v for k, v in st.items() if v is not None})
study["mode"] = study.get("mode") if study.get("mode") in ("fast", "normal") else "fast"
try:
study["parallel"] = max(1, min(int(study.get("parallel") or 2), 5))
except (TypeError, ValueError):
study["parallel"] = 2
return {"llm": llm, "study": study}
def save_settings(data):
store.set_setting(SETTINGS_NAME, {"llm": data.get("llm") or {},
"study": data.get("study") or {}})
@app.get("/api/settings")
def api_get_settings():
cfg = load_settings()
llm = cfg["llm"]
key = llm.get("api_key") or ""
out = dict(llm)
out["api_key"] = ""
out["has_key"] = bool(key)
out["key_hint"] = (key[:4] + "…" + key[-4:]) if len(key) > 10 else ("已设置" if key else "")
return {"ok": True, "llm": out, "study": cfg["study"]}
@app.post("/api/settings")
def api_set_settings(b: LlmCfgIn):
cur = load_settings()
llm = cur["llm"]
if b.base_url.strip():
llm["base_url"] = b.base_url.strip()
if b.api_key.strip(): # 留空 = 保留原 key,前端不回显明文
llm["api_key"] = b.api_key.strip()
if b.model.strip():
llm["model"] = b.model.strip()
llm["temperature"] = max(0.0, min(2.0, float(b.temperature or 0)))
llm["timeout"] = max(10, min(600, int(b.timeout or 120)))
llm["prompt"] = b.prompt or ""
if b.study_mode in ("fast", "normal"): # 空串 = 不改
cur["study"]["mode"] = b.study_mode
if b.study_parallel: # 0 = 不改
cur["study"]["parallel"] = max(1, min(int(b.study_parallel), 5))
save_settings(cur)
log("system", "设置已更新:模型 %s(%s),学习方式 %s/%s 路" % (
cur["llm"]["model"], cur["llm"]["base_url"],
cur["study"]["mode"], cur["study"]["parallel"]))
return {"ok": True, "study": cur["study"]}
-514
View File
@@ -1,514 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""SQLite3 存储层 —— 账号 / 设置 / 登录记录 / 运行日志 / 运行状态 / 缓存 / 页面快照
为什么用 SQLite 而不是散落的文件
* 远程管理:Docker 里只挂一个 data 卷,所有状态都在这一个文件里,备份 = 拷一个文件
* 并发:running 时有几十个线程写日志,文件追加要自己加锁,SQLite WAL 直接解决
* 查询:/api/log/history 能按账号取最近 N 行,不需要自己维护文件轮转
表
settings 系统配置(name -> JSON),目前只有 llm
kv 通用缓存(任意 key -> JSON),例如 StudyClient 试出来的接口前缀
accounts 账号 / 密码 / 会话 cookie / 课程缓存 / 勾选 / 开关
login_log 每次登录尝试(平台原话)
run_log 每个账号(key)的运行日志
run_state 运行状态快照,进程重启后 /api/runs 还能看到上次跑到哪
snapshots 课程页原始 HTML(站点改版时定位选择器用)
并发模型
* WAL + busy_timeout=15s:读写互不阻塞,写锁竞争自动重试
* 每个线程一条连接(threading.local):ThreadPoolExecutor 里多线程写日志不会串
* 本进程内的写操作再用一把 RLock 串起来,避免多线程 commit 抖动(读不加锁)
* 首次调用自动建库建表,并把旧的 data/settings.json、data/logs/*.log 导入后改名
(原文件保留为 *.imported,回滚旧版本时改名回去即可)
"""
import json
import os
import re
import sqlite3
import threading
import time
HERE = os.path.dirname(os.path.abspath(__file__))
# 包在 src/study_tool 下,数据目录仍是仓库根目录的 data/
ROOT = os.path.dirname(os.path.dirname(HERE))
DATA_DIR = os.environ.get("DATA_DIR") or os.path.join(ROOT, "data")
DB_PATH = os.environ.get("DB_PATH") or os.path.join(DATA_DIR, "study.db")
# 旧的文件式存储位置(只在首次启动时导入一次)
LEGACY_SETTINGS = os.path.join(DATA_DIR, "settings.json")
LEGACY_LOGDIR = os.path.join(DATA_DIR, "logs")
# 每个账号保留的日志行数,超出就丢最旧的(远程长期挂机,不能无限涨)
RUN_LOG_KEEP = int(os.environ.get("RUN_LOG_KEEP", "2000") or 2000)
# 页面快照保留条数
SNAPSHOT_KEEP = int(os.environ.get("SNAPSHOT_KEEP", "200") or 200)
SCHEMA = """
CREATE TABLE IF NOT EXISTS settings (
name TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value TEXT,
updated REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
username TEXT,
platform TEXT DEFAULT 'guanyixue',
password TEXT,
note TEXT,
user TEXT,
cookies TEXT,
courses TEXT,
selected TEXT,
status TEXT,
last_error TEXT,
last_error_at REAL,
opt_quiz INTEGER DEFAULT 0,
opt_feedback INTEGER DEFAULT 1,
login_at REAL,
created REAL,
updated REAL
);
CREATE TABLE IF NOT EXISTS login_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT,
username TEXT,
ok INTEGER,
code TEXT,
elapsed REAL,
stage TEXT,
error TEXT
);
CREATE TABLE IF NOT EXISTS run_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
ts REAL,
line TEXT
);
CREATE INDEX IF NOT EXISTS idx_run_log_key ON run_log(key, id);
CREATE TABLE IF NOT EXISTS run_state (
key TEXT PRIMARY KEY,
state TEXT,
updated REAL
);
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id TEXT,
project_id TEXT,
url TEXT,
html TEXT,
created REAL
);
"""
_local = threading.local()
_lock = threading.RLock() # 只保护“写”和一次性初始化
_ready = False
_write_count = {}
# ------------------------------------------------------------------ 基础
def dumps(o):
return json.dumps(o, ensure_ascii=False, separators=(",", ":"), default=str)
def loads(s, default=None):
if not s:
return default
try:
return json.loads(s)
except Exception:
return default
def conn():
"""取当前线程的连接(没有就建一条),顺带保证表结构已建好。"""
global _ready
c = getattr(_local, "conn", None)
if c is None:
os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True)
# check_same_thread=False:连接跟着线程走,线程退出后由 GC 关闭,
# 关闭动作可能发生在别的线程里,默认的 same-thread 检查会直接报错。
c = sqlite3.connect(DB_PATH, timeout=15, check_same_thread=False)
c.row_factory = sqlite3.Row
c.execute("PRAGMA journal_mode=WAL")
c.execute("PRAGMA synchronous=NORMAL")
c.execute("PRAGMA busy_timeout=15000")
_local.conn = c
if not _ready:
with _lock:
if not _ready:
c.executescript(SCHEMA)
_ensure_columns(c)
c.commit()
_migrate(c)
_ready = True
return c
def query(sql, params=()):
return conn().execute(sql, params).fetchall()
def query_one(sql, params=()):
rows = query(sql, params)
return rows[0] if rows else None
def write(sql, params=()):
with _lock:
c = conn()
cur = c.execute(sql, params)
c.commit()
return cur
def init():
"""显式初始化(启动时调用一次,确保库文件已建好)。"""
conn()
def db_info():
try:
size = os.path.getsize(DB_PATH) if os.path.exists(DB_PATH) else 0
except Exception:
size = 0
counts = {}
for t in ("accounts", "run_log", "login_log", "snapshots", "run_state", "kv", "settings"):
try:
counts[t] = (query_one("SELECT COUNT(*) AS n FROM %s" % t) or {"n": 0})["n"]
except Exception:
counts[t] = -1
return {"path": DB_PATH, "size": size, "counts": counts}
# ------------------------------------------------------------------ 迁移
def _ensure_columns(c):
"""SQLite 没有 ADD COLUMN IF NOT EXISTS;老库缺字段时补上。"""
cols = {r["name"] for r in c.execute("PRAGMA table_info(accounts)")}
if "platform" not in cols:
c.execute("ALTER TABLE accounts ADD COLUMN platform TEXT DEFAULT 'guanyixue'")
def _migrate(c):
"""把旧的文件式存储导入 SQLite;成功后把原文件改名成 *.imported。"""
# 1) data/settings.json -> settings
if os.path.exists(LEGACY_SETTINGS):
try:
with open(LEGACY_SETTINGS, encoding="utf-8") as f:
data = json.load(f) or {}
if isinstance(data.get("llm"), dict) and get_setting("llm") is None:
set_setting("llm", data["llm"])
os.replace(LEGACY_SETTINGS, LEGACY_SETTINGS + ".imported")
except Exception:
pass
# 2) data/logs/login.log -> login_log
if os.path.isdir(LEGACY_LOGDIR):
for name in sorted(os.listdir(LEGACY_LOGDIR)):
if not name.endswith(".log"):
continue
path = os.path.join(LEGACY_LOGDIR, name)
try:
with open(path, encoding="utf-8", errors="replace") as f:
lines = [x.rstrip("\n") for x in f if x.strip()]
except Exception:
continue
if name == "login.log":
for ln in lines:
p = ln.split("\t")
if len(p) < 4:
continue
el = re.sub(r"[^0-9.]", "", p[4]) if len(p) > 4 else "0"
login_log_add(p[1], p[2] == "OK", p[3],
float(el or 0), p[5] if len(p) > 5 else "",
p[6] if len(p) > 6 else "", ts=p[0])
else:
# data/logs/<key>.log -> run_log(key 是文件名去掉 .log)
key = name[:-4]
for ln in lines:
run_log_add(key, ln, ts=None)
try:
os.replace(path, path + ".imported")
except Exception:
pass
# 3) data/study_session.json(study_video.py 的会话)-> kv
legacy_session = os.path.join(DATA_DIR, "study_session.json")
if os.path.exists(legacy_session) and kv_get("study_session") is None:
try:
with open(legacy_session, encoding="utf-8") as f:
kv_set("study_session", json.load(f))
os.replace(legacy_session, legacy_session + ".imported")
except Exception:
pass
# ------------------------------------------------------------------ settings
def get_setting(name, default=None):
r = query_one("SELECT value FROM settings WHERE name=?", (name,))
return loads(r["value"], default) if r else default
def set_setting(name, value):
write("INSERT INTO settings(name, value, updated) VALUES(?,?,?) "
"ON CONFLICT(name) DO UPDATE SET value=excluded.value, updated=excluded.updated",
(name, dumps(value), time.time()))
# ------------------------------------------------------------------ kv 缓存
def kv_get(key, default=None):
r = query_one("SELECT value FROM kv WHERE key=?", (key,))
return loads(r["value"], default) if r else default
def kv_set(key, value):
write("INSERT INTO kv(key, value, updated) VALUES(?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated=excluded.updated",
(key, dumps(value), time.time()))
def kv_del(key):
write("DELETE FROM kv WHERE key=?", (key,))
def kv_all():
return {r["key"]: loads(r["value"]) for r in query("SELECT key, value FROM kv")}
# ------------------------------------------------------------------ accounts
def _acc_row(r):
"""DB 行 -> 前端用的记录(不含密码)。"""
return {
"id": r["id"],
"username": r["username"] or "",
"platform": r["platform"] or "guanyixue",
"note": r["note"] or "",
"user": r["user"] or "",
"cookies": loads(r["cookies"], []) or [],
"courses": loads(r["courses"], []) or [],
"selected": loads(r["selected"], []) or [],
"status": r["status"] or "idle",
"lastError": r["last_error"] or "",
"lastErrorAt": r["last_error_at"] or 0,
"optQuiz": bool(r["opt_quiz"]),
"optFeedback": bool(r["opt_feedback"]),
"loginAt": r["login_at"] or 0,
"created": r["created"] or 0,
"updated": r["updated"] or 0,
"hasPassword": bool(r["password"]),
}
def account_list():
return [_acc_row(r) for r in query("SELECT * FROM accounts ORDER BY created, id")]
def account_get(account_id):
if not account_id:
return None
r = query_one("SELECT * FROM accounts WHERE id=?", (str(account_id),))
return _acc_row(r) if r else None
def account_password(account_id):
if not account_id:
return ""
r = query_one("SELECT password FROM accounts WHERE id=?", (str(account_id),))
return (r["password"] or "") if r else ""
def account_exists(account_id):
return bool(account_id) and query_one("SELECT 1 FROM accounts WHERE id=?",
(str(account_id),)) is not None
def account_upsert(rec):
"""新增/更新账号。rec['password'] 为空(None)时保留库里原有密码。
明文密码要留在库里:会话失效时后台要能自己重登,不能等用户回来输密码。
"""
now = time.time()
aid = str(rec.get("id") or "").strip()
if not aid:
aid = "a%s%04d" % (int(now * 1000), int(time.time() * 10000) % 10000)
pwd = rec.get("password")
if pwd is not None:
pwd = str(pwd)
platform = (rec.get("platform") or "").strip() or None
selected = loads(dumps(rec.get("selected") or []), []) or []
write(
"""INSERT INTO accounts
(id, username, platform, password, note, user, cookies, courses, selected, status,
last_error, last_error_at, opt_quiz, opt_feedback, login_at, created, updated)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
username = excluded.username,
platform = COALESCE(excluded.platform, accounts.platform),
password = COALESCE(excluded.password, accounts.password),
note = excluded.note,
user = excluded.user,
cookies = excluded.cookies,
courses = excluded.courses,
selected = excluded.selected,
status = excluded.status,
last_error = excluded.last_error,
last_error_at = excluded.last_error_at,
opt_quiz = excluded.opt_quiz,
opt_feedback = excluded.opt_feedback,
login_at = excluded.login_at,
updated = excluded.updated""",
(aid, rec.get("username") or "", platform, pwd, rec.get("note") or "", rec.get("user") or "",
dumps(rec.get("cookies") or []), dumps(rec.get("courses") or []), dumps(selected),
rec.get("status") or "idle", rec.get("lastError") or "",
float(rec.get("lastErrorAt") or 0),
1 if rec.get("optQuiz") is True else 0,
0 if rec.get("optFeedback") is False else 1,
float(rec.get("loginAt") or 0),
float(rec.get("created") or now), now))
return aid
def account_save_login(account_id, user, cookies, courses):
"""登录成功后把新的会话写回账号(服务端自己记,不依赖前端回传)。"""
rec = account_get(account_id)
if not rec:
return
courses = courses or []
plans = {c.get("plan_id") for c in courses}
rec.update({
"user": user or rec.get("user") or "",
"cookies": cookies or [],
"courses": courses,
"selected": [p for p in (rec.get("selected") or []) if p in plans],
"status": "logged_in",
"lastError": "",
"lastErrorAt": 0,
"loginAt": time.time(),
})
rec.pop("hasPassword", None)
account_upsert(rec)
def account_delete(account_id):
"""删账号,连同它的运行状态、课程日志、登录记录一起删。
登录日志的 key 和 login_log.username 记的是用户名(手机号),不是账号 id,
两边都要清,否则删完账号旧日志还留在页面上。
"""
aid = str(account_id or "")
acc = account_get(aid) or {}
uname = str(acc.get("username") or "")
write("DELETE FROM accounts WHERE id=?", (aid,))
write("DELETE FROM run_state WHERE key=?", (aid,))
for key in (aid, uname):
if key:
write("DELETE FROM run_log WHERE key=?", (key,))
write("DELETE FROM login_log WHERE username=?", (key,))
# ------------------------------------------------------------------ 登录记录
def login_log_add(username, ok, code, elapsed, stage="", error="", ts=None):
write("INSERT INTO login_log(ts, username, ok, code, elapsed, stage, error) VALUES(?,?,?,?,?,?,?)",
(ts or time.strftime("%Y-%m-%d %H:%M:%S"), username, 1 if ok else 0,
str(code or ""), float(elapsed or 0), str(stage or ""),
str(error or "").replace("\n", " ")[:500]))
def login_log_tail(n=40):
"""返回旧版文本行格式(ts\\t用户名\\tOK\\tcode\\t耗时\\tstage\\t错误),前端不用改。"""
n = max(1, min(int(n or 40), 500))
rows = query("SELECT * FROM login_log ORDER BY id DESC LIMIT ?", (n,))
out = []
for r in reversed(rows):
out.append("%s\t%s\t%s\t%s\t%.1fs\t%s\t%s" % (
r["ts"], r["username"], "OK" if r["ok"] else "FAIL", r["code"],
r["elapsed"] or 0, r["stage"] or "", r["error"] or ""))
return out
# ------------------------------------------------------------------ 运行日志
def run_log_add(key, line, ts=None):
key = str(key or "")
write("INSERT INTO run_log(key, ts, line) VALUES(?,?,?)", (key, ts or time.time(), line))
n = _write_count.get("run_log", 0) + 1
_write_count["run_log"] = n
if n % 200 == 0: # 每 200 行清一次,避免每行都跑一遍 DELETE
write("""DELETE FROM run_log WHERE key=? AND id NOT IN
(SELECT id FROM run_log WHERE key=? ORDER BY id DESC LIMIT ?)""",
(key, key, RUN_LOG_KEEP))
def run_log_tail(key, n=200):
n = max(1, min(int(n or 200), RUN_LOG_KEEP))
rows = query("SELECT line FROM run_log WHERE key=? ORDER BY id DESC LIMIT ?", (str(key), n))
return [r["line"] for r in reversed(rows)]
def run_log_keys():
return [{"key": r["key"], "lines": r["n"],
"updated": r["ts"]} for r in query(
"SELECT key, COUNT(*) AS n, MAX(ts) AS ts FROM run_log GROUP BY key ORDER BY ts DESC")]
def run_log_clear(key):
write("DELETE FROM run_log WHERE key=?", (str(key),))
# ------------------------------------------------------------------ 运行状态
def run_state_set(key, state):
st = dict(state or {})
st.pop("log", None) # 日志单独进 run_log 表,不在这里重复一份
write("INSERT INTO run_state(key, state, updated) VALUES(?,?,?) "
"ON CONFLICT(key) DO UPDATE SET state=excluded.state, updated=excluded.updated",
(str(key), dumps(st), time.time()))
def run_state_all():
out = {}
for r in query("SELECT key, state FROM run_state"):
st = loads(r["state"], None)
if isinstance(st, dict):
out[r["key"]] = st
return out
def run_state_delete(key):
write("DELETE FROM run_state WHERE key=?", (str(key or ""),))
# ------------------------------------------------------------------ 页面快照
def snapshot_add(plan_id, project_id, url, html):
cur = write("INSERT INTO snapshots(plan_id, project_id, url, html, created) VALUES(?,?,?,?,?)",
(str(plan_id or ""), str(project_id or ""), url or "", html or "", time.time()))
sid = cur.lastrowid
write("""DELETE FROM snapshots WHERE id NOT IN
(SELECT id FROM snapshots ORDER BY id DESC LIMIT ?)""", (SNAPSHOT_KEEP,))
return sid
def snapshot_get(sid):
r = query_one("SELECT * FROM snapshots WHERE id=?", (int(sid),))
return dict(r) if r else None
def snapshot_list(limit=50):
limit = max(1, min(int(limit or 50), 500))
rows = query("SELECT id, plan_id, project_id, url, created, LENGTH(html) AS size "
"FROM snapshots ORDER BY id DESC LIMIT ?", (limit,))
return [dict(r) for r in rows]
+2 -3
View File
@@ -3,8 +3,7 @@
单独一个模块,路由模块从这里 `from study_tool.webapp import app` 注册接口,
避免 app.py <-> 路由模块循环导入。
"""
from fastapi import FastAPI
app = FastAPI(title="多账号学习控制台")
app = FastAPI(title="学习控制台")