aa48145557
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
278 lines
9.7 KiB
Python
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,
|
|
}
|