fix: 运行状态与日志三处修复;依赖改用 uv 锁 + 国内镜像
- 重启标记不再污染新一轮运行:hydrate_runs() 标记「已中断」后立即落库,runtime.set_state() 在切入 discovering/studying 时清掉上一轮的 error/progress/finished_at - 服务重启后卡片「课程日志」能读到 SQLite 历史:前端兜底条件不再拿 status 是否为空做判断(重启后 status 恒为 interrupted) - 删除账号连带清日志:account_delete() 按账号 id + 用户名清 run_state/run_log/login_log,删除接口同时清内存里两个 key - 新增 uv.lock 依赖锁定;pyproject 走阿里云 PyPI 镜像,Dockerfile 支持 PIP_INDEX_URL 构建参数 - 文档移入 docs/,.gitignore 忽略 *.egg-info/(uv run 可编辑安装产物)
This commit is contained in:
@@ -9,6 +9,7 @@ data/
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
- AI 答题整段同步等待导致浏览器 / 反代超时:`/api/quiz/answer` 改为流式(text/event-stream)分批返回,每批失败自动重试一次
|
||||
- AI 答题解析不出 JSON 时直接判空:补 `max_tokens`、空内容 / 非 JSON 抛错重试,并支持从自由文本、截断 JSON 里抠答案,多选连写(`AC`)也能识别
|
||||
- 控制台是 GBK 时日志里含 `⚠` 之类字符会让工作线程抛 `UnicodeEncodeError`:`runtime.log` 现在编码失败会自动降级
|
||||
- 服务重启把上次没跑完的运行标成「已中断」后,那句话会被后续 `set_state` 一起写回库,新一轮运行一起步就顶着「服务重启,上次运行已中断」,库里还留着 `status=studying` 的幽灵记录:`runtime.set_state()` 在状态切入 `discovering` / `studying` 时清掉上一轮的 `error` / `progress` / `finished_at`,`hydrate_runs()` 标记后立即落库
|
||||
- 服务重启后卡片「课程日志」显示「暂无日志」,SQLite 里的历史读不出来:前端兜底条件原为「内存为空且 status 为空」,而重启后 status 恰好是 `interrupted`(非空),历史永远不读;现在只看内存是否为空
|
||||
- 删除账号只清了按账号 id 记的 `run_state` / `run_log`,登录日志(key 与 `login_log.username` 记的是用户名 / 手机号)残留:`account_delete()` 改为按账号 id + 用户名一起清,`DELETE /api/accounts/{id}` 同时清掉内存里两个 key 的日志
|
||||
|
||||
### 其他
|
||||
|
||||
- 新增 `uv.lock` 依赖锁定与 uv 安装方式;`pyproject.toml` 增加阿里云 PyPI 镜像(`[[tool.uv.index]]`),`Dockerfile` 增加 `PIP_INDEX_URL` 构建参数(默认阿里云,可回官方源),README 补充 uv / pip / 换源说明
|
||||
- `.gitignore` 忽略 `*.egg-info/`(`uv run` 可编辑安装的产物)
|
||||
- 文档整理:`OPTIMIZATION.md`、`todo.md` 移入 `docs/`;移除不再使用的 `start.bat`
|
||||
|
||||
## v0.0.1
|
||||
|
||||
|
||||
+4
-1
@@ -23,8 +23,11 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nodejs ca-certificates curl tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 国内镜像:默认走阿里云;要回官方源加 --build-arg PIP_INDEX_URL=https://pypi.org/simple
|
||||
ARG PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install -r requirements.txt
|
||||
RUN pip install -r requirements.txt -i "$PIP_INDEX_URL"
|
||||
|
||||
# 直链视频(fileType=other)必须用真浏览器播到结尾;不需要就 WITH_PLAYWRIGHT=0
|
||||
ARG WITH_PLAYWRIGHT=1
|
||||
|
||||
@@ -10,13 +10,24 @@
|
||||
start.bat
|
||||
```
|
||||
|
||||
或手动:
|
||||
或手动(uv,依赖版本已锁在 `uv.lock`):
|
||||
|
||||
```bat
|
||||
pip install -r requirements.txt
|
||||
uv sync
|
||||
uv run app.py
|
||||
```
|
||||
|
||||
没有 uv 就用 pip(Docker 镜像里也是走的这条):
|
||||
|
||||
```bat
|
||||
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple
|
||||
python app.py
|
||||
```
|
||||
|
||||
依赖默认走阿里云镜像:uv 的源配在 `pyproject.toml`,`uv.lock` 里记录的也是它;Dockerfile 用 `PIP_INDEX_URL` 构建参数(默认阿里云,`--build-arg PIP_INDEX_URL=https://pypi.org/simple` 可回官方源)。临时换源:`UV_DEFAULT_INDEX=https://mirrors.cloud.tencent.com/pypi/simple uv sync`。清华源 `pypi.tuna.tsinghua.edu.cn` 会 403 uv 的 JSON simple 请求,不要设。
|
||||
|
||||
`ddddocr` 依赖 `onnxruntime`,后者只对 Python 3.10–3.14 的正式版发轮子;3.15 / free-threaded / ARM 上会装不上(`ResolutionImpossible`)。缺它只影响广东公需课的验证码识别,其余平台不受影响。
|
||||
|
||||
浏览器打开 <http://127.0.0.1:8848>(`localhost` 也可以,服务同时监听 `::1`)。
|
||||
|
||||
换端口 / 对外开放:
|
||||
|
||||
@@ -208,3 +208,25 @@ GET /mod/school/wanpeng/syncSelf?courseCode=<courseCode>
|
||||
- 500 且空响应 → 少了 Referer,或 `md5Uid` 取错。
|
||||
- 课程列表为空 → jsxx 会话失效,重新登录(或等 `renew_session` 换票)。
|
||||
- 登录报「请输入正确的验证码!」→ OCR 没认对,重试即可;连续失败说明 `ddddocr` 没装好。
|
||||
|
||||
## 运行状态与日志修复(重启标记 / 历史日志 / 删账号清日志)
|
||||
|
||||
**状态**:已实现,待实测确认。
|
||||
|
||||
### 做了什么
|
||||
|
||||
- **重启标记不再污染新一轮运行**:`app.py` 的 `hydrate_runs()` 把上次没跑完的状态标成「已中断」后立刻落库(库里不再一直挂着 `studying`);`runtime.set_state()` 在状态从非运行态切到 `discovering` / `studying` 时清掉上一轮的 `error` / `progress` / `finished_at`,所以点「开始学习」后不会还挂着「服务重启,上次运行已中断」。
|
||||
- **重启后历史日志能读出来了**:前端拉日志的兜底条件原是「内存为空 **且** status 为空」,而重启后 status 恰好是 `interrupted`(非空),历史永远不读。现在只看内存是否为空(`web/src/App.tsx`)。
|
||||
- **删账号连带清日志**:`store.account_delete()` 除 run_state / run_log(账号 id)外,还会按**用户名**清 run_log 与 login_log(登录日志的 key 和 `login_log.username` 记的是手机号,不是账号 id);`DELETE /api/accounts/{id}` 同时把内存里这两个 key 的日志一起丢掉。
|
||||
|
||||
### 怎么测
|
||||
|
||||
1. 重启服务后打开页面:卡片应是新状态;点「开始学习」,红条与上一轮的旧进度立即被覆盖。
|
||||
2. 重启服务(内存日志清空)后展开某账号「课程日志」:应显示 SQLite 里的历史行,而不是「暂无日志」。
|
||||
3. 删掉一个账号再刷新:该账号的课程日志、登录记录(`/api/loginlog`)都不应再有它的行;`system` 与其它账号的日志必须保留。
|
||||
|
||||
### 如果没生效
|
||||
|
||||
- 红条还在 → 看 `run_state` 里那条是否仍带旧 `error`,删掉该行即可复位。
|
||||
- 历史日志还是空 → 确认 `static/` 已重新构建(`cd web && pnpm build`)并强刷页面。
|
||||
- 删完还有日志 → 平台的登录用户名与账号 `username` 字段不一致时清不掉,需按账号实际写入的 key 处理。
|
||||
@@ -51,6 +51,11 @@
|
||||
- 题库命中优先、LLM 兜底(省 token、命中时更准)
|
||||
- 强制播完才放开「下一节」—— 项目已有等价实现(播到 `ended` + `completeTime` 校验)
|
||||
|
||||
## 待办:日志 key 与 ddddocr 报错提示
|
||||
|
||||
- **登录日志的 key 用错维度**:`routes/accounts.py` 的 `_log_login()` 拿**用户名(手机号)**当 key 写 run_log,而卡片「课程日志」读的是**账号 id**,两边对不上——登录行只有登录记录页(`login_log` 表)能看到。要么统一成账号 id,要么卡片同时聚合两个 key。
|
||||
- **ddddocr 的报错提示会误导**:`platforms/gdgxk.py` 把任何导入失败都写成「需要先装 ddddocr(pip install ddddocr)」,`uv sync` 在应用运行期间改写包造成的 `DLL load failed` 也会被说成「没装」。应区分 ImportError 与 OSError(DLL),并把真实异常写进 `runtime.log`。
|
||||
|
||||
## 已完成
|
||||
|
||||
- 「首次进入课程弹『未加入课堂』」已修复,实测确认见 `OPTIMIZATION.md`。
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
name: study-tool
|
||||
version: 0.0.4
|
||||
version: 0.0.5
|
||||
description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台
|
||||
|
||||
@@ -25,3 +25,12 @@ package-dir = { "" = "src" }
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
# 国内镜像:uv 默认走阿里云源,不再直连 pypi.org。
|
||||
# 换源:UV_DEFAULT_INDEX=https://mirrors.cloud.tencent.com/pypi/simple uv sync
|
||||
# 想回官方:UV_DEFAULT_INDEX=https://pypi.org/simple uv sync
|
||||
# (清华源 pypi.tuna.tsinghua.edu.cn 会 403 uv 的 JSON simple 请求,别用)
|
||||
[[tool.uv.index]]
|
||||
name = "aliyun"
|
||||
url = "https://mirrors.aliyun.com/pypi/simple"
|
||||
default = true
|
||||
|
||||
@@ -100,6 +100,10 @@ def hydrate_runs():
|
||||
if st.get("status") in ("discovering", "studying"):
|
||||
st["status"] = "interrupted"
|
||||
st["error"] = "服务重启,上次运行已中断"
|
||||
try:
|
||||
store.run_state_set(k, st) # 落库,别让库里一直挂着 studying
|
||||
except Exception:
|
||||
pass
|
||||
RUNS[k] = st
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
`ssoItlmsGxkTicket.action` 换 ITLMS 票,带票进 `jsxx.gds.edu.cn` 拿学习会话;
|
||||
两个域名的 cookie 都留在同一个 requests.Session 里。
|
||||
|
||||
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度不靠播放器,靠
|
||||
`updateViewTime1` 心跳上报(60 秒一次,`lastViewTime` 是已看秒数),
|
||||
`viewTime` 追平视频总长就算学完,所以这里只跑心跳、不真播。
|
||||
学习页是服务端渲染的老系统,视频是 CDN 直链 MP4,进度靠 `updateViewTime1`
|
||||
心跳上报(`lastViewTime` 是已看秒数,页面上「要求观看视频 N 分钟」才是完成门槛)。
|
||||
平台按真实流逝时间放行(实测一次报 +300 只记 +60),所以只能 1 倍速慢慢刷。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
@@ -16,7 +16,7 @@ import uuid
|
||||
|
||||
import requests
|
||||
|
||||
from study_tool import runtime
|
||||
from study_tool import runtime, store
|
||||
from study_tool.platforms import registry
|
||||
from study_tool.platforms.base import Platform
|
||||
|
||||
@@ -174,7 +174,7 @@ def list_courses(s):
|
||||
|
||||
|
||||
def parse_course(html):
|
||||
"""从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、总长、已看秒数。"""
|
||||
"""从学习页里抠出心跳需要的一切:aid、课件 id、md5Uid、要求时长、已看秒数。"""
|
||||
d = {}
|
||||
m = re.search(r"/a_([0-9a-f]{32})/study/video/user/", html)
|
||||
if not m:
|
||||
@@ -189,8 +189,11 @@ def parse_course(html):
|
||||
if not m:
|
||||
raise RuntimeError("学习页里没有找到 md5Uid")
|
||||
d["uid"] = m.group(1)
|
||||
# `t` 是服务端记的「已看秒数」,不是总长;完成门槛是页面上的「要求观看视频 N 分钟」
|
||||
m = re.search(r"\bt\s*=\s*'(\d+)'", html)
|
||||
d["total"] = int(m.group(1)) if m else 0
|
||||
d["viewTime"] = int(m.group(1)) if m else 0
|
||||
m = re.search(r"要求观看视频<span>(\d+)</span>分钟", html)
|
||||
d["total"] = int(m.group(1)) * 60 if m else 0
|
||||
m = re.search(r"url1\s*=\s*'([^']+)'", html)
|
||||
d["video"] = m.group(1) if m else ""
|
||||
d["title"] = (re.search(r"<title>([^<]*)</title>", html).group(1).strip()
|
||||
@@ -220,6 +223,24 @@ def _view_time(s, cw, uid, sign):
|
||||
return int(rd or 0)
|
||||
|
||||
|
||||
def _progress(s, cid):
|
||||
"""学习进度页:活动总数、已完成数、每个活动(aid / 名称 / 是否完成)。"""
|
||||
html = s.get(X + "/" + cid + "/study/course/progess", timeout=30).text
|
||||
if "studyCourse(" not in html:
|
||||
raise _expired("学习平台会话已失效,请重新登录")
|
||||
m = re.search(r"\$\('#actNum'\)\.text\('(\d+)'\)", html)
|
||||
total = int(m.group(1)) if m else 0
|
||||
m = re.search(r"\$\('#completeActNum'\)\.text\('(\d+)'\)", html)
|
||||
done = int(m.group(1)) if m else 0
|
||||
acts = []
|
||||
for row in re.findall(r"<tr[^>]*>([\s\S]*?)</tr>", html):
|
||||
m = re.search(r"studyCourse\('[^']*','[^']*','(a_[0-9a-f]{32})'\)[^>]*>([^<]+)<", row)
|
||||
if m:
|
||||
acts.append({"aid": m.group(1), "title": m.group(2).strip(),
|
||||
"done": "u-ico-finish" in row})
|
||||
return {"total": total or len(acts), "done": done, "acts": acts}
|
||||
|
||||
|
||||
def discover(s, plan_id, project_id, name=""):
|
||||
cid = plan_id
|
||||
page = X + "/study/course/" + cid
|
||||
@@ -227,27 +248,21 @@ def discover(s, plan_id, project_id, name=""):
|
||||
r = s.get(page + "?v=1", timeout=30)
|
||||
if "loginBtn" in r.text and "md5Uid" not in r.text:
|
||||
raise _expired("学习平台会话已失效,请重新登录")
|
||||
cw = parse_course(r.text)
|
||||
sid = uuid.uuid4().hex
|
||||
cw["sid"] = sid
|
||||
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, cw["uid"])
|
||||
cur = _view_time(s, cw, cw["uid"], sign)
|
||||
# 完成与否以平台状态为准:要「所有教学活动 100% + 试卷考核 ≥60」,视频秒数够不代表完成
|
||||
status = ""
|
||||
for c in _parse_courses(s.get(X + "/uc/store/courseRegister", timeout=30).text):
|
||||
if c["plan_id"] == cid:
|
||||
status = c["status_label"]
|
||||
break
|
||||
done = status == "已完成"
|
||||
# 一门课是「章 / 节 / 活动」的目录,不是单个视频;活动清单和完成情况都从进度页取
|
||||
pr = _progress(s, cid)
|
||||
if not pr["acts"]:
|
||||
raise RuntimeError("进度页里没有找到活动列表,页面结构可能变了")
|
||||
courseware = [{
|
||||
"aid": a["aid"], "title": a["title"], "done": a["done"],
|
||||
"href": X + "/" + a["aid"] + "/study/course/" + cid,
|
||||
"total": 0, "viewTime": 0,
|
||||
} for a in pr["acts"]]
|
||||
return {
|
||||
"user": profile(s),
|
||||
"course": name or cw["title"],
|
||||
"progress": "%d/%d 秒%s" % (cur, cw["total"], "(课程已完成)" if done else ""),
|
||||
"info": cw["video"],
|
||||
"courseware": [{"id": cw["id"], "title": cw["title"], "href": page,
|
||||
"total": cw["total"], "viewTime": cur, "done": done,
|
||||
"status": status,
|
||||
"aid": cw["aid"], "uid": cw["uid"]}],
|
||||
"course": name or "公需课程",
|
||||
"progress": "已完成 %d/%d 个活动" % (pr["done"], pr["total"]),
|
||||
"info": page,
|
||||
"courseware": courseware,
|
||||
"feedback": [], "test": [],
|
||||
"platform": PLATFORM_ID, "generation": "gxk",
|
||||
"plan_id": cid, "project_id": "",
|
||||
@@ -259,63 +274,126 @@ def _stopped(key):
|
||||
return (runtime.RUNS.get(key) or {}).get("status") == "stopped"
|
||||
|
||||
|
||||
def _relogin(key):
|
||||
"""掉线后按库里存的账号密码重登,返回新会话。"""
|
||||
acc = store.account_get(key) or {}
|
||||
pw = store.account_password(key)
|
||||
if not pw:
|
||||
raise _expired("会话掉线,且没有保存密码,无法自动重连")
|
||||
runtime.log(key, "会话掉线,正在用账号密码自动重新登录…", level="warn")
|
||||
s, _ = login(acc.get("username") or "", pw)
|
||||
runtime.log(key, "重新登录成功,继续学习")
|
||||
return s
|
||||
|
||||
|
||||
def _study_one(key, s, cw, speed, beat):
|
||||
"""打开活动页 → 注册播放 → 1x 心跳;会话失效时抛 SessionExpired 交给上层重登。"""
|
||||
try:
|
||||
_sso(s) # 一门课要跑几十分钟到几小时,每个活动前续一次票
|
||||
except Exception:
|
||||
pass # 续票失败但老会话还能用就继续
|
||||
html = s.get(cw["href"], timeout=30).text
|
||||
if "loginBtn" in html and "md5Uid" not in html:
|
||||
raise _expired("学习页要求重新登录")
|
||||
try:
|
||||
info = parse_course(html)
|
||||
except Exception as e:
|
||||
raise RuntimeError("不是视频活动(%s)" % e)
|
||||
if not info["total"]:
|
||||
raise RuntimeError("页面上没有「要求观看视频 N 分钟」")
|
||||
cw.update(id=info["id"], uid=info["uid"], total=info["total"], viewTime=info["viewTime"])
|
||||
s.headers["Referer"] = cw["href"]
|
||||
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": uuid.uuid4().hex}, cw["uid"])
|
||||
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, cw["uid"], sign)
|
||||
runtime.log(key, "开始学习:%s(要求 %d 秒,已看 %d 秒)" % (cw["title"], cw["total"], cur))
|
||||
if cur < cw["total"]:
|
||||
_learn(key, s, cw, speed, beat)
|
||||
else:
|
||||
runtime.log(key, "服务端已记 %d 秒,达到要求,等平台刷新完成状态" % cur, level="warn")
|
||||
|
||||
|
||||
def study(key, s, d, **opts):
|
||||
"""只跑心跳上报:每 beat 秒把 lastViewTime 往前推 beat*speed 秒。"""
|
||||
speed = float(opts.get("speed") or 60)
|
||||
beat = int(opts.get("beat") or 2)
|
||||
"""按进度页的活动清单逐个心跳;平台只按真实时间放行,所以默认 1 倍速慢慢刷。"""
|
||||
speed = float(opts.get("speed") or 1)
|
||||
beat = int(opts.get("beat") or 60)
|
||||
cid = d["plan_id"]
|
||||
total = len(d["courseware"])
|
||||
runtime.set_state(key, status="studying", total=total, done=0)
|
||||
skipped = []
|
||||
for i, cw in enumerate(d["courseware"], 1):
|
||||
if _stopped(key):
|
||||
runtime.log(key, "已按要求停止")
|
||||
return
|
||||
if cw.get("done"):
|
||||
runtime.log(key, "课件 %s 已学完,跳过" % cw["title"])
|
||||
runtime.log(key, "活动 %d/%d 已完成,跳过:%s" % (i, total, cw["title"]))
|
||||
runtime.set_state(key, done=i)
|
||||
continue
|
||||
runtime.log(key, "开始学习:%s(总长 %d 秒,已看 %d 秒)"
|
||||
% (cw["title"], cw["total"], cw["viewTime"]))
|
||||
if cw["total"] and cw["viewTime"] >= cw["total"]:
|
||||
runtime.log(key, "视频已播完,但课程仍显示「%s」:还差试卷考核或其他教学活动"
|
||||
% (cw.get("status") or "未完成"), level="warn")
|
||||
runtime.set_state(key, done=i)
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
_study_one(key, s, cw, speed, beat)
|
||||
break
|
||||
except Exception as e:
|
||||
expired = e.__class__.__name__ == "SessionExpired"
|
||||
if expired and attempt == 1:
|
||||
s = _relogin(key) # 掉线:重登后把当前活动重新走一遍
|
||||
continue
|
||||
_learn(key, s, cw, speed, beat)
|
||||
if expired:
|
||||
raise
|
||||
runtime.log(key, "活动「%s」跳过:%s" % (cw["title"], e), level="warn")
|
||||
skipped.append(cw["title"])
|
||||
break
|
||||
runtime.set_state(key, done=i)
|
||||
runtime.set_state(key, status="done", done=total)
|
||||
runtime.log(key, "全部课件心跳上报完成")
|
||||
# 以平台进度页为准收尾:视频秒数够 ≠ 课程完成,考核没做就是没完成
|
||||
try:
|
||||
pr = _progress(s, cid)
|
||||
except Exception as e:
|
||||
if e.__class__.__name__ != "SessionExpired":
|
||||
raise
|
||||
s = _relogin(key)
|
||||
pr = _progress(s, cid)
|
||||
act_total = pr["total"] or total
|
||||
runtime.set_state(key, status="done", done=pr["done"], total=act_total)
|
||||
if pr["done"] >= act_total:
|
||||
runtime.log(key, "平台进度:%d/%d 个活动已完成" % (pr["done"], act_total))
|
||||
else:
|
||||
runtime.log(key, "平台进度:%d/%d 个活动已完成,剩余需人工处理%s"
|
||||
% (pr["done"], act_total,
|
||||
("(" + "、".join(skipped) + ")") if skipped else ""),
|
||||
level="warn")
|
||||
|
||||
|
||||
def _learn(key, s, cw, speed, beat):
|
||||
"""心跳上报。平台只按真实流逝时间放行(1x),所以目标值按墙钟推,不靠每拍累加。"""
|
||||
uid, total = cw["uid"], cw["total"]
|
||||
s.headers["Referer"] = cw.get("href") or X
|
||||
sid = uuid.uuid4().hex
|
||||
base = {"_method": "PUT", "aid": cw["aid"]}
|
||||
sign = _sign(s, {"aid": cw["aid"], "id": cw["id"], "sid": sid}, uid)
|
||||
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
|
||||
start, t0, stall = cur, time.time(), 0
|
||||
while cur < total:
|
||||
if _stopped(key):
|
||||
runtime.log(key, "已按要求停止")
|
||||
return
|
||||
time.sleep(beat)
|
||||
cur = min(total, cur + int(beat * speed))
|
||||
want = min(total, start + int((time.time() - t0) * speed))
|
||||
data = dict(base, isLimit="false", md5=sign.get("md5"), expires=sign.get("time"),
|
||||
md5Uid=uid, lastViewTime=str(cur), playbackSessionId=sid)
|
||||
md5Uid=uid, lastViewTime=str(max(want, cur)), playbackSessionId=sid)
|
||||
r = _xhr(s, "POST", X + "/" + cw["aid"] + "/study/video/user/" + cw["id"] + "/updateViewTime1",
|
||||
data=data)
|
||||
j = r.json() if r.text.strip().startswith("{") else {}
|
||||
if "loginBtn" in (r.text or "") or not (r.text or "").strip().startswith("{"):
|
||||
raise _expired("上报被踢回登录页(HTTP %s)" % r.status_code)
|
||||
j = r.json()
|
||||
rd = j.get("responseData") or {}
|
||||
if j.get("responseCode") == "00" and rd.get("md5"):
|
||||
sign = {"md5": rd["md5"], "time": rd.get("time")}
|
||||
server = int(rd.get("viewTime") or cur)
|
||||
else:
|
||||
server = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
|
||||
runtime.log(key, "进度上报:本地 %d 秒 / 服务端 %d 秒 / 总长 %d 秒" % (cur, server, total))
|
||||
if server < cur:
|
||||
# 服务端没认这个进度,按它的值继续,别把差距越拉越大
|
||||
cur = server
|
||||
if cur >= total:
|
||||
break
|
||||
before = cur
|
||||
cur = _view_time(s, {"aid": cw["aid"], "id": cw["id"]}, uid, sign)
|
||||
runtime.log(key, "进度上报:目标 %d 秒 / 服务端 %d 秒 / 要求 %d 秒" % (want, cur, total))
|
||||
stall = stall + 1 if cur <= before else 0
|
||||
if stall >= 5:
|
||||
runtime.log(key, "连续 5 次上报都没被平台接受,先停这个活动", level="warn")
|
||||
return
|
||||
runtime.log(key, "课件完成:%s(%d 秒)" % (cw["title"], total))
|
||||
|
||||
|
||||
|
||||
@@ -108,7 +108,10 @@ def api_account_get(account_id: str):
|
||||
|
||||
@app.delete("/api/accounts/{account_id}")
|
||||
def api_account_del(account_id: str):
|
||||
acc = store.account_get(account_id) or {}
|
||||
store.account_delete(account_id)
|
||||
with RUNS_LOCK:
|
||||
RUNS.pop(account_id, None)
|
||||
# 登录日志挂在用户名下,一起清掉,不然删完账号日志还在
|
||||
RUNS.pop(acc.get("username") or "", None)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""课程 / 发现 / 学习 / 停止 / 抓包 / 快照 / 历史日志接口。"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
@@ -13,6 +14,7 @@ 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
|
||||
from study_tool.settings_store import load_settings
|
||||
|
||||
# ------------------------------------------------------------------ 平台
|
||||
|
||||
|
||||
@@ -40,9 +40,17 @@ def log(key, msg, level="info", persist=True):
|
||||
return line
|
||||
|
||||
|
||||
ACTIVE = ("discovering", "studying")
|
||||
|
||||
|
||||
def set_state(key, **kw):
|
||||
with RUNS_LOCK:
|
||||
st = RUNS.setdefault(key, {})
|
||||
# 新一轮开始:上一轮留下的 error / progress / finished_at 不能跟着存进库,
|
||||
# 否则重启标记「服务重启,上次运行已中断」会粘在新运行上。
|
||||
if kw.get("status") in ACTIVE and st.get("status") not in ACTIVE:
|
||||
for stale in ("error", "progress", "finished_at"):
|
||||
st.pop(stale, None)
|
||||
st.update(kw)
|
||||
st["updated"] = time.time()
|
||||
store.run_state_set(key, RUNS.get(key) or {})
|
||||
|
||||
+11
-1
@@ -402,10 +402,20 @@ def account_save_login(account_id, user, cookies, courses):
|
||||
|
||||
|
||||
def account_delete(account_id):
|
||||
"""删账号,连同它的运行状态、课程日志、登录记录一起删。
|
||||
|
||||
登录日志的 key 和 login_log.username 记的是用户名(手机号),不是账号 id,
|
||||
两边都要清,否则删完账号旧日志还留在页面上。
|
||||
"""
|
||||
aid = str(account_id or "")
|
||||
acc = account_get(aid) or {}
|
||||
uname = str(acc.get("username") or "")
|
||||
write("DELETE FROM accounts WHERE id=?", (aid,))
|
||||
write("DELETE FROM run_state WHERE key=?", (aid,))
|
||||
write("DELETE FROM run_log WHERE key=?", (aid,))
|
||||
for key in (aid, uname):
|
||||
if key:
|
||||
write("DELETE FROM run_log WHERE key=?", (key,))
|
||||
write("DELETE FROM login_log WHERE username=?", (key,))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 登录记录
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title 多账号学习控制台 · 东莞市教师培训平台
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ============================================
|
||||
echo 多账号学习控制台 jspx.dgjy.net
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
where python >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo [错误] 未找到 python,请先安装 Python 3.10+ 并加入 PATH
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
where node >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
if not exist "%NODE_BIN%" (
|
||||
echo [警告] 未在 PATH 中找到 node。若登录报错,请设置环境变量 NODE_BIN 指向 node.exe
|
||||
)
|
||||
)
|
||||
|
||||
python -c "import fastapi, uvicorn, requests, bs4" >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo [安装依赖] fastapi uvicorn requests beautifulsoup4 lxml
|
||||
python -m pip install -r requirements.txt
|
||||
)
|
||||
|
||||
echo.
|
||||
echo 启动中... 浏览器请打开: http://127.0.0.1:8848
|
||||
echo 关闭本窗口即停止服务。
|
||||
echo.
|
||||
|
||||
start "" http://127.0.0.1:8848
|
||||
python app.py
|
||||
|
||||
pause
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="theme-color" content="#f6f8fc" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>莞易学 · 多账号学习控制台</title>
|
||||
<script type="module" crossorigin src="/assets/index-Dfv5RNeD.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DsTLd63E.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BubgUKHl.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+4
-2
@@ -234,8 +234,10 @@ export default function App() {
|
||||
response = await api<LogResponse>(`/api/log?key=${encodeURIComponent(id)}&since=0`)
|
||||
}
|
||||
let lines = response.lines || []
|
||||
// 服务重启后内存日志为空,退回读一次 SQLite 历史
|
||||
if (!lines.length && !response.status && !state.historyTried) {
|
||||
// 服务重启后内存日志为空,退回读一次 SQLite 历史。
|
||||
// 不能拿 status 当条件:重启会把上次的运行状态标成 interrupted,
|
||||
// status 非空但内存日志是空的,历史就永远读不出来。
|
||||
if (!lines.length && !state.historyTried) {
|
||||
state.historyTried = true
|
||||
try {
|
||||
const history = await api<{ lines?: string[] }>(
|
||||
|
||||
Reference in New Issue
Block a user