feat: 添加账号支持选择学习平台

账号新增 platform 字段,登录/刷新课程/发现课程按平台分发;同时把 JS 脚本收进 script/(目录规范调整)。
This commit is contained in:
study-tool
2026-10-30 01:13:47 +08:00
parent e8634a6317
commit 1a3347f7f9
29 changed files with 449 additions and 297 deletions
+2 -2
View File
@@ -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
View File
@@ -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")
+5
View File
@@ -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 = "" # 指定要重考/查看的课时(不传就按原逻辑挑第一个)
+8
View File
@@ -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=""):
"""登录入口或学习入口命中任意片段,就认为页面属于本平台。"""
+14 -4
View File
@@ -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,
))
+6 -2
View File
@@ -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:
+13 -4
View File
@@ -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)
+17 -8
View File
@@ -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
View File
@@ -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),