Files
study-tool/src/study_tool/routes/misc.py
T
study-tool aa48145557 feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
2026-11-01 17:47:22 +08:00

278 lines
9.7 KiB
Python

"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志 / 插件管理接口。"""
import threading
import time
from fastapi.responses import HTMLResponse, JSONResponse
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 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.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()],
}
# ------------------------------------------------------------------ 插件管理
# 插件目录 plugins/<名字>/,实现见 study_tool.plugins。
@app.get("/api/plugins")
def api_plugins():
"""磁盘上的插件清单:是否启用、是否已加载、加载后的 label 与 capabilities。"""
return {"ok": True, "plugins": plugins.discover()}
@app.post("/api/plugins/{name}/enable")
def api_plugin_enable(name: str):
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: plugins.set_enabled(name, False))
@app.post("/api/plugins/reload")
def api_plugins_reload():
"""重载全部已启用插件:改完插件代码不用重启服务。"""
return _plugin_call(plugins.reload)
@app.post("/api/plugins/{name}/reload")
def api_plugin_reload(name: str):
return _plugin_call(lambda: plugins.reload(name))
def _plugin_call(fn):
"""插件操作失败不抛 500,返回 ok=false 给前端提示。"""
try:
return {"ok": True, "plugins": fn()}
except Exception as e:
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": 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 时连内存里的那一段也清掉,前端不用刷新页面。"""
database.run_log_clear(key)
with RUNS_LOCK:
st = RUNS.get(key)
if st is not None:
st["log"] = []
return {"ok": True}
@app.get("/api/snapshots")
def api_snapshots(limit: int = 50):
"""站点改版时存下的课程页原始 HTML 列表。"""
return {"ok": True, "snapshots": database.snapshot_list(limit)}
@app.get("/api/snapshot/{sid}", response_class=HTMLResponse)
def api_snapshot(sid: int):
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)),
)
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
)
pf = registry.get(b.platform)
if pf is None or pf.list_courses is None:
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
)
s = pf.session(b.cookies)
if not s.cookies:
return JSONResponse(
{"ok": False, "code": "no_cookie", "error": "保存的 cookie 全部被过滤(站点域名不匹配),请重新登录"},
status_code=200,
)
try:
courses = pf.list_courses(s)
except SessionExpired as e:
return JSONResponse({"ok": False, "code": "session_expired", "error": str(e)[:300]}, status_code=200)
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,
)
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 []),
}
@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
)
if pf.session is None:
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)
return {"ok": True, **d, "cookies": (pf.dump_cookies(s) if pf.dump_cookies else [])}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)[:300]}, status_code=200)
@app.post("/api/study")
def api_study(b: StudyIn):
st = RUNS.get(b.key) or {}
if st.get("status") in ("discovering", "studying"):
return JSONResponse({"ok": False, "error": "该账号正在学习中"}, status_code=200)
if not b.cookies:
return JSONResponse({"ok": False, "error": "尚未登录"}, status_code=200)
courses = b.courses or []
if not courses and b.plan_id and b.project_id:
courses = [{"name": "", "plan_id": b.plan_id, "project_id": b.project_id}]
if not courses:
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 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,
}
@app.post("/api/stop")
def api_stop(b: StopIn):
set_state(b.key, status="stopped")
log(b.key, "用户请求停止")
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/** 请求与响应。"""
if (RUNS.get(b.key) or {}).get("capturing"):
return JSONResponse({"ok": False, "error": "正在抓包"}, status_code=200)
if not b.cookies:
return JSONResponse({"ok": False, "error": "尚未登录"}, status_code=200)
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=pf.capture, args=(b.key, b.cookies, max(1, min(int(b.minutes or 10), 30))), daemon=True
).start()
return {"ok": True}
@app.post("/api/capture/stop")
def api_capture_stop(b: StopIn):
set_state(b.key, capture_stop=True)
log(b.key, "用户请求停止抓包")
return {"ok": True}
@app.get("/api/capture")
def api_capture_get(key: str = ""):
st = RUNS.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,
}