refactor: React 前端重构,新旧平台合并为莞易学
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""HTTP 路由包:按业务域拆分,导入即注册到 study_tool.webapp.app。"""
|
||||
@@ -0,0 +1,105 @@
|
||||
"""账号 / 登录 / 登录记录接口。"""
|
||||
import time
|
||||
|
||||
from study_tool.webapp import app
|
||||
|
||||
from study_tool import store
|
||||
from study_tool.config import *
|
||||
from study_tool.models import *
|
||||
from study_tool.platforms.jspx import *
|
||||
from study_tool.runtime import log
|
||||
|
||||
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))
|
||||
try:
|
||||
store.login_log_add(username, ok, code, elapsed, stage, error)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _login_account(b: LoginIn):
|
||||
"""解析出一组可登录的凭据:显式传入优先,否则按账号 id 从库里取。"""
|
||||
acc = store.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 "")
|
||||
return acc, username, password
|
||||
|
||||
|
||||
@app.post("/api/login")
|
||||
def api_login(b: LoginIn):
|
||||
t0 = time.time()
|
||||
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")
|
||||
return JSONResponse({"ok": False, "code": "no_credential", "error": err}, status_code=200)
|
||||
try:
|
||||
s, user = login(username, password)
|
||||
except Exception as e:
|
||||
err = str(e)[:300]
|
||||
_log_login(username, False, "login_failed", err, time.time() - t0, type(e).__name__)
|
||||
if acc:
|
||||
store.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:
|
||||
courses = scrape_courses(s)
|
||||
except Exception as e:
|
||||
courses, warn = [], str(e)[:200]
|
||||
cookies = dump_cookies(s)
|
||||
_log_login(username, True, "ok", warn, time.time() - t0, "user=%s" % user)
|
||||
if acc:
|
||||
store.account_save_login(acc["id"], user, cookies, courses)
|
||||
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)}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ accounts
|
||||
|
||||
@app.get("/api/accounts")
|
||||
def api_accounts():
|
||||
"""全部账号(含会话 cookie / 课程缓存 / 勾选),**不含密码**。"""
|
||||
return {"ok": True, "accounts": store.account_list()}
|
||||
|
||||
|
||||
@app.post("/api/accounts")
|
||||
def api_account_save(b: AccountIn):
|
||||
"""新增或更新一个账号。password 不传(或传空)表示保留库里已有的密码。"""
|
||||
rec = b.model_dump()
|
||||
if not (rec.get("password") or "").strip():
|
||||
rec["password"] = None
|
||||
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)
|
||||
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)}
|
||||
|
||||
|
||||
@app.get("/api/accounts/{account_id}")
|
||||
def api_account_get(account_id: str):
|
||||
acc = store.account_get(account_id)
|
||||
if not acc:
|
||||
return JSONResponse({"ok": False, "error": "账号不存在"}, status_code=200)
|
||||
return {"ok": True, "account": acc}
|
||||
|
||||
|
||||
@app.delete("/api/accounts/{account_id}")
|
||||
def api_account_del(account_id: str):
|
||||
store.account_delete(account_id)
|
||||
with RUNS_LOCK:
|
||||
RUNS.pop(account_id, None)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,22 @@
|
||||
"""/api/log、/api/runs:前端轮询日志与运行状态。"""
|
||||
from study_tool.webapp import app
|
||||
|
||||
from study_tool.runtime import RUNS, RUNS_LOCK
|
||||
|
||||
@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", "")}
|
||||
# discover 可能给账号补上了 study.dgjy.net 的 Middle-Token,回传给前端写回 IndexedDB
|
||||
if st.get("cookies"):
|
||||
out["cookies"] = st["cookies"]
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/runs")
|
||||
def api_runs():
|
||||
with RUNS_LOCK:
|
||||
return {k: {kk: vv for kk, vv in v.items() if kk != "log"} for k, v in RUNS.items()}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志接口。"""
|
||||
import time
|
||||
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from study_tool.webapp import app
|
||||
|
||||
from study_tool import store
|
||||
from study_tool.capture import *
|
||||
from study_tool.config import *
|
||||
from study_tool.engine import *
|
||||
from study_tool.models import *
|
||||
from study_tool.platforms import registry
|
||||
from study_tool.platforms.jspx import *
|
||||
from study_tool.platforms.study import *
|
||||
from study_tool.runtime import RUNS, RUNS_LOCK, log
|
||||
|
||||
# ------------------------------------------------------------------ 平台
|
||||
|
||||
@app.get("/api/platforms")
|
||||
def api_platforms():
|
||||
"""已注册平台;前端用 label 展示,例如「莞易学」。"""
|
||||
return {"ok": True, "platforms": [{"id": p.id, "label": p.label} for p in registry.all()]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 运行日志 / 快照
|
||||
|
||||
@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)}
|
||||
|
||||
|
||||
@app.delete("/api/log/history")
|
||||
def api_log_history_clear(key: str = ""):
|
||||
"""清历史日志;带 key 时连内存里的那一段也清掉,前端不用刷新页面。"""
|
||||
store.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": store.snapshot_list(limit)}
|
||||
|
||||
|
||||
@app.get("/api/snapshot/{sid}", response_class=HTMLResponse)
|
||||
def api_snapshot(sid: int):
|
||||
row = store.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)
|
||||
s = new_session(b.cookies)
|
||||
if not s.cookies:
|
||||
return JSONResponse({"ok": False, "code": "no_cookie",
|
||||
"error": "保存的 cookie 全部被过滤(domain 不含 dgjy),请重新登录"},
|
||||
status_code=200)
|
||||
try:
|
||||
courses = scrape_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 = whoami(fetch_admin(s))
|
||||
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),回传让前端写回 IndexedDB
|
||||
"cookies": dump_cookies(s)}
|
||||
|
||||
|
||||
@app.post("/api/discover")
|
||||
def api_discover(b: DiscoverIn):
|
||||
s = new_session(b.cookies)
|
||||
try:
|
||||
d = discover(s, b.plan_id, b.project_id, b.name)
|
||||
return {"ok": True, **d, "cookies": dump_cookies(s)}
|
||||
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 store.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}
|
||||
|
||||
|
||||
@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)
|
||||
CAPTURES[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()
|
||||
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 {}
|
||||
return {"ok": True, "capturing": bool(st.get("capturing")),
|
||||
"file": st.get("capture_file") or "",
|
||||
"records": CAPTURES.get(key) or []}
|
||||
Reference in New Issue
Block a user