feat: 故障推送 / 公需课自动重学 / 插件解耦,发版 v0.0.8
体检修复:启动日志不再打印 AUTH_TOKEN、密钥可清空、推送失败不占整段冷却、目录名与文案统一成 plugins/。
This commit is contained in:
+118
-62
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user