feat: 添加账号支持选择学习平台
账号新增 platform 字段,登录/刷新课程/发现课程按平台分发;同时把 JS 脚本收进 script/(目录规范调整)。
This commit is contained in:
@@ -6,7 +6,7 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
# 包内路径 -> 项目根目录:data / static / des*.js 这些运行时资源都留在仓库根
|
||||
# 包内路径 -> 项目根目录:data / static / script 这些运行时资源都留在仓库根
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(HERE))
|
||||
DATA = os.path.join(ROOT, "data")
|
||||
@@ -26,7 +26,7 @@ def _find_node():
|
||||
|
||||
|
||||
NODE = _find_node()
|
||||
DESENC = os.path.join(ROOT, "desenc.js")
|
||||
DESENC = os.path.join(ROOT, "script", "desenc.js")
|
||||
|
||||
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0")
|
||||
|
||||
+16
-10
@@ -8,6 +8,7 @@ import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
|
||||
from study_tool import store
|
||||
from study_tool.config import *
|
||||
from study_tool.platforms import registry
|
||||
from study_tool.platforms.jspx import *
|
||||
@@ -158,28 +159,33 @@ def study_worker(key, username, cookies, courses, workers, cap, quiz_key,
|
||||
def run_one_course(key, username, cookies, plan, project, workers, cap, quiz_key,
|
||||
enable_quiz=False, enable_feedback=True, name="", password="",
|
||||
mode="fast", parallel=2):
|
||||
s = new_session(cookies)
|
||||
# 账号添加时选的平台决定登录 / 发现走哪套实现;新增平台不用改这里
|
||||
account = store.account_get(key) or {}
|
||||
adapter = registry.get(account.get("platform"))
|
||||
if adapter is None or adapter.discover is None:
|
||||
raise RuntimeError("平台 %s 没有可用的课程发现实现" % (account.get("platform") or "?"))
|
||||
s = (adapter.session or new_session)(cookies)
|
||||
try:
|
||||
d = discover(s, plan, project, name)
|
||||
d = adapter.discover(s, plan, project, name)
|
||||
except SessionExpired:
|
||||
# 先试无密码续期(CASTGC 通常还有效),不行再用浏览器里存的账号密码重登
|
||||
log(key, "会话失效,尝试用 SSO 票据续期…")
|
||||
renewed = ensure_jspx_session(s)
|
||||
log(key, "会话失效,尝试无密码续期…")
|
||||
renewed = adapter.renew_session(s) if adapter.renew_session else False
|
||||
d = None
|
||||
if renewed:
|
||||
try:
|
||||
d = discover(s, plan, project, name)
|
||||
d = adapter.discover(s, plan, project, name)
|
||||
except SessionExpired:
|
||||
d = None
|
||||
if d is None:
|
||||
if not password:
|
||||
if not password or adapter.login is None:
|
||||
raise
|
||||
log(key, "SSO 票据已过期,用账号密码重新登录…")
|
||||
s, _ = login(username, password)
|
||||
d = discover(s, plan, project, name)
|
||||
log(key, "续期失败,用账号密码重新登录…")
|
||||
s, _ = adapter.login(username, password)
|
||||
d = adapter.discover(s, plan, project, name)
|
||||
# 新平台可能刚在 discover 里换到 study.dgjy.net 的会话(Middle-Token),存回去给后续课程用
|
||||
set_state(key, course=d["course"], progress=d["progress"], user=d["user"],
|
||||
cookies=dump_cookies(s))
|
||||
cookies=(adapter.dump_cookies or dump_cookies)(s))
|
||||
log(key, "课程:%s | 进度 %s | %s" % (d["course"], d["progress"], d["info"]))
|
||||
pf = registry.get(d.get("platform"))
|
||||
gen = d.get("generation")
|
||||
|
||||
@@ -7,6 +7,7 @@ class LoginIn(BaseModel):
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
account_id: str = "" # 只给账号 id 时,用户名/密码从 SQLite 里取,密码不出库
|
||||
platform: str = "guanyixue" # 没传 account_id 时按它选平台
|
||||
|
||||
|
||||
class AccountsIn(BaseModel):
|
||||
@@ -16,6 +17,7 @@ class AccountsIn(BaseModel):
|
||||
class AccountIn(BaseModel):
|
||||
id: str = ""
|
||||
username: str = ""
|
||||
platform: str = "guanyixue" # 添加账号时选的平台
|
||||
password: str | None = None # 不传 = 保留库里已有的密码
|
||||
note: str = ""
|
||||
user: str = ""
|
||||
@@ -33,6 +35,7 @@ class AccountIn(BaseModel):
|
||||
|
||||
class CookiesIn(BaseModel):
|
||||
cookies: list
|
||||
platform: str = "guanyixue"
|
||||
|
||||
|
||||
class DiscoverIn(BaseModel):
|
||||
@@ -40,6 +43,7 @@ class DiscoverIn(BaseModel):
|
||||
plan_id: str
|
||||
project_id: str
|
||||
name: str = ""
|
||||
platform: str = "guanyixue"
|
||||
|
||||
|
||||
class StudyIn(BaseModel):
|
||||
@@ -72,6 +76,7 @@ class QuizIn(BaseModel):
|
||||
plan_id: str = ""
|
||||
project_id: str = ""
|
||||
name: str = ""
|
||||
platform: str = "guanyixue"
|
||||
# retake=True:交卷后点「再考一次」,让平台生成新答卷(消耗一次机会)
|
||||
retake: bool = False
|
||||
period_id: str = "" # 指定要重考/查看的课时(不传就按原逻辑挑第一个)
|
||||
|
||||
@@ -26,6 +26,14 @@ class Platform:
|
||||
label: str # 日志 / 界面里的展示名
|
||||
study: Optional[Callable] = None # 学一门课;None = 这个平台还没实现
|
||||
entries: tuple = () # 登录 / 学习入口 URL 片段,用于识别平台
|
||||
# 账号级能力:添加账号时选了哪个平台,登录 / 发现 / 刷新课程就走哪个平台的实现
|
||||
session: Optional[Callable] = None # (cookies) -> requests.Session
|
||||
dump_cookies: Optional[Callable] = None # (session) -> [cookie]
|
||||
login: Optional[Callable] = None # (username, password) -> (session, user)
|
||||
list_courses: Optional[Callable] = None # (session) -> [course]
|
||||
discover: Optional[Callable] = None # (session, plan_id, project_id, name) -> 课程描述
|
||||
renew_session: Optional[Callable] = None # (session) -> bool,无密码续期
|
||||
profile: Optional[Callable] = None # (session) -> 显示名
|
||||
|
||||
def matches(self, url="", html=""):
|
||||
"""登录入口或学习入口命中任意片段,就认为页面属于本平台。"""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""莞易学平台:jspx.dgjy.net(登录入口)与 study.dgjy.net(学习入口)是同一个平台。
|
||||
|
||||
课程页有两代前端,`discover` 会把代际写进 `course["generation"]`(legacy / spa),
|
||||
这里按代际分发到对应的学习实现。新增其他平台时,照本文件写一个模块:
|
||||
在 `Platform.entries` 声明登录 / 学习入口,注册表就能按入口识别平台。
|
||||
账号在「添加账号」里选择平台(`accounts.platform`),登录 / 刷新课程 / 发现课程都会
|
||||
走这里的 `login` / `list_courses` / `discover`;学习时 `discover` 会把页面代际写进
|
||||
`course["generation"]`(legacy / spa),由 `_study` 分发到对应实现。
|
||||
新增其他平台时,照本文件写一个模块并在 `Platform.entries` 声明登录 / 学习入口。
|
||||
"""
|
||||
from study_tool.platforms import jspx, registry, study
|
||||
from study_tool.platforms.base import Platform
|
||||
@@ -13,10 +14,19 @@ LABEL = "莞易学"
|
||||
ENTRIES = ("jspx.dgjy.net", "study.dgjy.net", "dgjy.net")
|
||||
|
||||
|
||||
def _profile(s):
|
||||
return jspx.whoami(jspx.fetch_admin(s))
|
||||
|
||||
|
||||
def _study(key, s, d, **opts):
|
||||
if d.get("generation") == "spa":
|
||||
return study._study(key, s, d, **opts)
|
||||
return jspx._study(key, s, d, **opts)
|
||||
|
||||
|
||||
PLATFORM = registry.register(Platform(id=ID, label=LABEL, study=_study, entries=ENTRIES))
|
||||
PLATFORM = registry.register(Platform(
|
||||
id=ID, label=LABEL, study=_study, entries=ENTRIES,
|
||||
session=jspx.new_session, dump_cookies=jspx.dump_cookies,
|
||||
login=jspx.login, list_courses=jspx.scrape_courses, discover=jspx.discover,
|
||||
renew_session=jspx.ensure_jspx_session, profile=_profile,
|
||||
))
|
||||
|
||||
@@ -10,6 +10,7 @@ from study_tool.config import *
|
||||
from study_tool.engine import *
|
||||
from study_tool.llm import *
|
||||
from study_tool.models import *
|
||||
from study_tool.platforms import registry
|
||||
from study_tool.runtime import log
|
||||
|
||||
# ------------------------------------------------------------------ quiz
|
||||
@@ -246,9 +247,12 @@ def api_quiz(b: QuizIn):
|
||||
"""取一次测验的题目(含选项),供页面表格展示。"""
|
||||
if not b.cookies:
|
||||
return {"ok": False, "code": "no_cookie", "error": "尚未登录"}
|
||||
s = new_session(b.cookies)
|
||||
pf = registry.get(b.platform)
|
||||
if pf is None or pf.discover is None:
|
||||
return {"ok": False, "error": "平台「%s」还没有实现课程发现" % b.platform}
|
||||
s = (pf.session or new_session)(b.cookies)
|
||||
try:
|
||||
d = discover(s, b.plan_id, b.project_id, b.name)
|
||||
d = pf.discover(s, b.plan_id, b.project_id, b.name)
|
||||
except SessionExpired as e:
|
||||
return {"ok": False, "code": "session_expired", "error": str(e)[:300]}
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""账号 / 登录 / 登录记录接口。"""
|
||||
import time
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
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 import registry
|
||||
from study_tool.platforms.jspx import *
|
||||
from study_tool.runtime import log
|
||||
from study_tool.runtime import RUNS, RUNS_LOCK, log
|
||||
|
||||
def _log_login(username, ok, code, error, elapsed, stage=""):
|
||||
"""每次登录尝试:统一日志 + login_log 表(登录记录页用)。"""
|
||||
@@ -37,8 +40,14 @@ def api_login(b: LoginIn):
|
||||
_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)
|
||||
pf = registry.get((acc or {}).get("platform") or b.platform)
|
||||
if pf is None or pf.login is None:
|
||||
err = "平台「%s」还没有实现登录,请换一个平台或先在 platforms/ 里补上实现" % (
|
||||
(acc or {}).get("platform") or b.platform)
|
||||
_log_login(username, False, "no_platform", err, time.time() - t0, "LoginIn")
|
||||
return JSONResponse({"ok": False, "code": "no_platform", "error": err}, status_code=200)
|
||||
try:
|
||||
s, user = login(username, password)
|
||||
s, user = pf.login(username, password)
|
||||
except Exception as e:
|
||||
err = str(e)[:300]
|
||||
_log_login(username, False, "login_failed", err, time.time() - t0, type(e).__name__)
|
||||
@@ -49,10 +58,10 @@ def api_login(b: LoginIn):
|
||||
return JSONResponse({"ok": False, "code": "login_failed", "error": err}, status_code=200)
|
||||
warn = ""
|
||||
try:
|
||||
courses = scrape_courses(s)
|
||||
courses = pf.list_courses(s) if pf.list_courses else []
|
||||
except Exception as e:
|
||||
courses, warn = [], str(e)[:200]
|
||||
cookies = dump_cookies(s)
|
||||
cookies = (pf.dump_cookies or 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)
|
||||
|
||||
@@ -65,13 +65,18 @@ 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)
|
||||
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)
|
||||
s = (pf.session or 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)
|
||||
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:
|
||||
@@ -81,21 +86,25 @@ def api_courses(b: CookiesIn):
|
||||
"error": "登录态有效,但该账号名下三个板块(在学/助学/督学)"
|
||||
"的进行中、未开始、已结束里都没有课程"}, status_code=200)
|
||||
try:
|
||||
user = whoami(fetch_admin(s))
|
||||
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),回传让前端写回 IndexedDB
|
||||
"cookies": dump_cookies(s)}
|
||||
# 补 jspx 会话时 cookie 会换新(JSESSIONID/route),回传让前端写回
|
||||
"cookies": (pf.dump_cookies or dump_cookies)(s)}
|
||||
|
||||
|
||||
@app.post("/api/discover")
|
||||
def api_discover(b: DiscoverIn):
|
||||
s = new_session(b.cookies)
|
||||
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)
|
||||
s = (pf.session or new_session)(b.cookies)
|
||||
try:
|
||||
d = discover(s, b.plan_id, b.project_id, b.name)
|
||||
return {"ok": True, **d, "cookies": dump_cookies(s)}
|
||||
d = pf.discover(s, b.plan_id, b.project_id, b.name)
|
||||
return {"ok": True, **d, "cookies": (pf.dump_cookies or dump_cookies)(s)}
|
||||
except Exception as e:
|
||||
return JSONResponse({"ok": False, "error": str(e)[:300]}, status_code=200)
|
||||
|
||||
|
||||
+15
-3
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS kv (
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT,
|
||||
platform TEXT DEFAULT 'guanyixue',
|
||||
password TEXT,
|
||||
note TEXT,
|
||||
user TEXT,
|
||||
@@ -145,6 +146,7 @@ def conn():
|
||||
with _lock:
|
||||
if not _ready:
|
||||
c.executescript(SCHEMA)
|
||||
_ensure_columns(c)
|
||||
c.commit()
|
||||
_migrate(c)
|
||||
_ready = True
|
||||
@@ -189,6 +191,13 @@ def db_info():
|
||||
|
||||
# ------------------------------------------------------------------ 迁移
|
||||
|
||||
def _ensure_columns(c):
|
||||
"""SQLite 没有 ADD COLUMN IF NOT EXISTS;老库缺字段时补上。"""
|
||||
cols = {r["name"] for r in c.execute("PRAGMA table_info(accounts)")}
|
||||
if "platform" not in cols:
|
||||
c.execute("ALTER TABLE accounts ADD COLUMN platform TEXT DEFAULT 'guanyixue'")
|
||||
|
||||
|
||||
def _migrate(c):
|
||||
"""把旧的文件式存储导入 SQLite;成功后把原文件改名成 *.imported。"""
|
||||
# 1) data/settings.json -> settings
|
||||
@@ -284,6 +293,7 @@ def _acc_row(r):
|
||||
return {
|
||||
"id": r["id"],
|
||||
"username": r["username"] or "",
|
||||
"platform": r["platform"] or "guanyixue",
|
||||
"note": r["note"] or "",
|
||||
"user": r["user"] or "",
|
||||
"cookies": loads(r["cookies"], []) or [],
|
||||
@@ -336,14 +346,16 @@ def account_upsert(rec):
|
||||
pwd = rec.get("password")
|
||||
if pwd is not None:
|
||||
pwd = str(pwd)
|
||||
platform = (rec.get("platform") or "").strip() or None
|
||||
selected = loads(dumps(rec.get("selected") or []), []) or []
|
||||
write(
|
||||
"""INSERT INTO accounts
|
||||
(id, username, password, note, user, cookies, courses, selected, status,
|
||||
(id, username, platform, password, note, user, cookies, courses, selected, status,
|
||||
last_error, last_error_at, opt_quiz, opt_feedback, login_at, created, updated)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
username = excluded.username,
|
||||
platform = COALESCE(excluded.platform, accounts.platform),
|
||||
password = COALESCE(excluded.password, accounts.password),
|
||||
note = excluded.note,
|
||||
user = excluded.user,
|
||||
@@ -357,7 +369,7 @@ def account_upsert(rec):
|
||||
opt_feedback = excluded.opt_feedback,
|
||||
login_at = excluded.login_at,
|
||||
updated = excluded.updated""",
|
||||
(aid, rec.get("username") or "", pwd, rec.get("note") or "", rec.get("user") or "",
|
||||
(aid, rec.get("username") or "", platform, pwd, rec.get("note") or "", rec.get("user") or "",
|
||||
dumps(rec.get("cookies") or []), dumps(rec.get("courses") or []), dumps(selected),
|
||||
rec.get("status") or "idle", rec.get("lastError") or "",
|
||||
float(rec.get("lastErrorAt") or 0),
|
||||
|
||||
Reference in New Issue
Block a user