"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志 / 插件管理接口。""" 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("

快照不存在

", status_code=404) head = "\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, }