From 8e8d6b83f1abc2b7e1b3f97993ef6d00fe6f4a1e Mon Sep 17 00:00:00 2001 From: study-tool Date: Sat, 31 Oct 2026 16:12:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(env):=20=E6=9C=AC=E6=9C=BA=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E8=AF=BB=E5=8F=96=E6=A0=B9=E7=9B=AE=E5=BD=95=20.env?= =?UTF-8?q?=EF=BC=8C=E7=9B=91=E5=90=AC=E5=9C=B0=E5=9D=80=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=20BIND=5FADDR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - study_tool/__init__.py 用 python-dotenv 加载根目录 .env,位置早于 store / config 读环境变量;已有环境变量优先,Docker 注入的不受影响 - app.py 监听地址改为 HOST -> BIND_ADDR -> 127.0.0.1 依次回退 - 新增依赖 python-dotenv>=1.0(pyproject.toml / requirements.txt / uv.lock) - README 环境变量表补充 .env 说明与 BIND_ADDR / TZ 两行 - CHANGELOG / docs/OPTIMIZATION.md 记录本次修复,版本号 0.0.6 -> 0.0.7 --- CHANGELOG.md | 2 + README.md | 9 +++- docs/OPTIMIZATION.md | 21 +++++++++ metadata.yaml | 2 +- pyproject.toml | 1 + requirements.txt | 1 + src/study_tool/__init__.py | 9 ++++ src/study_tool/app.py | 4 +- src/study_tool/platforms/guanyixue.py | 45 ------------------- .../{platforms => pugins/guanyixue}/detect.py | 0 .../{platforms => pugins/guanyixue}/jspx.py | 0 src/study_tool/pugins/guanyixue/main.py | 45 +++++++++++++++++++ .../{platforms => pugins/guanyixue}/study.py | 0 uv.lock | 11 +++++ 14 files changed, 100 insertions(+), 50 deletions(-) delete mode 100644 src/study_tool/platforms/guanyixue.py rename src/study_tool/{platforms => pugins/guanyixue}/detect.py (100%) rename src/study_tool/{platforms => pugins/guanyixue}/jspx.py (100%) rename src/study_tool/{platforms => pugins/guanyixue}/study.py (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2169dd..62fa08a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ - 删除账号只清了按账号 id 记的 `run_state` / `run_log`,登录日志(key 与 `login_log.username` 记的是用户名 / 手机号)残留:`account_delete()` 改为按账号 id + 用户名一起清,`DELETE /api/accounts/{id}` 同时清掉内存里两个 key 的日志 - 学习链路上三处引用未定义的全局名(`platforms/jspx.py` 的 `extract_study_token` / `StudyClient` / `save_snapshot` 等、`platforms/study.py` 的 `parse_qs`、`quiz.py` 的 `BeautifulSoup` / `urlsplit`)会让课程发现 / 答题直接 `NameError`:`parse_qs`、`BeautifulSoup`、`urlsplit` 改为模块顶部导入,`jspx` ↔ `study` 之间改用函数内延迟导入(模块级会成环) - 服务重启留下的「已中断」状态在重新登录后仍然压在卡片上,徽标显示「已中断」并挂着旧进度和红条:登录成功后清掉该账号上一轮已结束的运行状态(内存 + SQLite),正在跑(`discovering` / `studying`)的不动,内存日志保留 +- 本机启动(`python app.py` / `uv run app.py`)时根目录 `.env` 完全无效:只有 docker compose 会读它,`AUTH_TOKEN` / `PORT` / `RUN_LOG_KEEP` / `SNAPSHOT_KEEP` 都得靠命令行环境变量;现在 `study_tool/__init__.py` 用 `python-dotenv` 加载 `.env`(已存在的环境变量优先,Docker 由 compose 注入的不受影响),新增依赖 `python-dotenv>=1.0` +- `.env` 里写 `BIND_ADDR` 在本机启动时不起作用(代码只读 `HOST`):监听地址改为 `HOST` → `BIND_ADDR` → `127.0.0.1` 依次回退 ### 其他 diff --git a/README.md b/README.md index 48af761..b49abbd 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ set PORT=8849 && python app.py set HOST=0.0.0.0 && python app.py ``` +也可以不 set:把 `.env.example` 复制成根目录 `.env` 再改(`cp .env.example .env`),本机启动同样读它。 +同名系统环境变量优先于 `.env`;注意 `.env` 里 `AUTH_TOKEN` 非空时,本机访问也要 `http://127.0.0.1:8848/?token=<值>`。 + ### 方式二:Docker(推荐用于远程管理) ```bash @@ -59,17 +62,19 @@ docker compose logs -f # 看启动日志 | 进容器 | `docker compose exec study-tool bash` | | 换端口 | `.env` 里改 `PORT`,再 `docker compose up -d` | -环境变量: +环境变量(写在根目录 `.env` 里,本机启动和 Docker 都读;同名的系统环境变量优先): | 变量 | 默认 | 说明 | | --- | --- | --- | | `HOST` | `127.0.0.1` | 监听地址;Docker 里是 `0.0.0.0` | +| `BIND_ADDR` | 同 `HOST` | `HOST` 的别名(docker compose 用它绑宿主机端口),本机启动也认 | | `PORT` | `8848` | 端口 | | `DB_PATH` | `<项目>/data/study.db` | SQLite 库文件 | | `AUTH_TOKEN` | 空 | 非空 = 页面/接口需要 `?token=` 或 cookie;`/api/health` 永远放行 | | `RUN_LOG_KEEP` | `2000` | 每个账号保留的日志行数 | | `SNAPSHOT_KEEP` | `200` | 课程页快照保留条数 | | `NODE_BIN` | 自动找 | node.exe 路径(算登录表单的 DES) | +| `TZ` | `Asia/Shanghai` | 只对 Docker(Linux 容器)有效,Windows 本机忽略 | 镜像默认装 Chromium(直链视频要真播到结尾)。不需要就用 `docker compose build --build-arg WITH_PLAYWRIGHT=0`,镜像能小一大截。 @@ -186,7 +191,7 @@ script/reference/ 不参与运行的参考脚本:油猴 dgjy-toolkit. start.bat 一键启动(Windows) Dockerfile Docker 镜像(python:3.12-slim + nodejs + chromium) docker-compose.yml Docker 编排:端口、数据卷、AUTH_TOKEN、健康检查 -.env.example 远程管理用的环境变量样例(复制成 .env) +.env.example 环境变量样例(复制成 .env;本机启动与 Docker 都读) requirements.txt 依赖 data/study.db SQLite 库(账号/设置/日志/快照) docs/architecture.md 分层说明 + 平台插件怎么加 diff --git a/docs/OPTIMIZATION.md b/docs/OPTIMIZATION.md index 7126a79..5b464de 100644 --- a/docs/OPTIMIZATION.md +++ b/docs/OPTIMIZATION.md @@ -2,6 +2,27 @@ 已实现、但还需要你实测确认的事项。确认没问题后把对应条目删掉。 +## 本机启动读取根目录 .env + +**状态**:已实现,待实测确认。 + +### 做了什么 + +- 修问题:以前只有 docker compose 会读 `.env`,本机 `python app.py` / `uv run app.py` / `start.bat` + 下 `AUTH_TOKEN` / `PORT` / `RUN_LOG_KEEP` / `SNAPSHOT_KEEP` 全部不生效。 +- `study_tool/__init__.py` 用 `python-dotenv` 加载仓库根 `.env`;放在包初始化处,早于 store / config + 读环境变量(`store.py` 是 import 时就读 `DB_PATH` / `RUN_LOG_KEEP`)。已存在的环境变量优先,Docker 由 compose 注入的不受影响。 +- 新依赖 `python-dotenv>=1.0`(`pyproject.toml` / `requirements.txt`,需 `uv sync`)。 +- `app.py` 监听地址改成 `HOST` → `BIND_ADDR` → `127.0.0.1`,`.env` 里的 `BIND_ADDR` 本机也生效。 +- README 环境变量表补 `.env` 说明与 `BIND_ADDR` / `TZ` 两行;`.env.example` 说明改为「本机与 Docker 都读」。 + +### 怎么测 + +1. 根目录有 `.env`(`AUTH_TOKEN` 非空)时直接 `python app.py`,启动日志里的地址应带 `?token=`。 +2. 不带 token 开 `http://127.0.0.1:8848/` 应显示「需要访问令牌」页;带 `?token=<值>` 正常进控制台。 +3. `.env` 改 `PORT=8849` 重启 → 实际监听 8849;命令行 `set PORT=8850` 再启动应压过 `.env`。 +4. `.env` 里 `BIND_ADDR=127.0.0.1` → 只绑本机;改 `0.0.0.0` → 同网段可访问。 + ## 插件热加载 + 启用/停用清单 **状态**:已实现,待实测确认。 diff --git a/metadata.yaml b/metadata.yaml index 5245955..94afbbd 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,3 +1,3 @@ name: study-tool -version: 0.0.6 +version: 0.0.7 description: 东莞市教师培训平台(jspx.dgjy.net / study.dgjy.net)多账号学习控制台 diff --git a/pyproject.toml b/pyproject.toml index 4605013..bb3f186 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "lxml>=5.0", "playwright>=1.40", "ddddocr", + "python-dotenv>=1.0", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index 81ce594..c2bf424 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ beautifulsoup4>=4.12 lxml>=5.0 playwright>=1.40 ddddocr +python-dotenv>=1.0 diff --git a/src/study_tool/__init__.py b/src/study_tool/__init__.py index f60a8a3..73627ab 100644 --- a/src/study_tool/__init__.py +++ b/src/study_tool/__init__.py @@ -1,3 +1,12 @@ """多账号学习控制台(东莞市教师培训平台)。""" +import os + +from dotenv import load_dotenv + +# 仓库根目录的 .env:本机启动(python app.py / uv run app.py)也按它配置。 +# 必须在任何子模块之前加载 —— store / config 在 import 时就读环境变量; +# 已经在环境里的变量优先(Docker 由 compose 注入,不会被 .env 覆盖)。 +_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +load_dotenv(os.path.join(_ROOT, ".env")) __version__ = "0.0.1" diff --git a/src/study_tool/app.py b/src/study_tool/app.py index 8d4cd13..2029645 100644 --- a/src/study_tool/app.py +++ b/src/study_tool/app.py @@ -166,8 +166,8 @@ def _probe_self(port): def main(): port = int(os.environ.get("PORT", "8848")) - # 默认只监听本机;Docker / 远程管理用 HOST=0.0.0.0 - host = os.environ.get("HOST", "127.0.0.1") + # 默认只监听本机;Docker / 远程管理用 HOST=0.0.0.0(.env 里写 BIND_ADDR 同样生效) + host = os.environ.get("HOST") or os.environ.get("BIND_ADDR") or "127.0.0.1" url = "http://127.0.0.1:%d" % port # 设了访问令牌时,打印出来的地址一律带上 ?token=:本机访问同样要校验令牌 suffix = ("/?token=%s" % AUTH_TOKEN) if AUTH_TOKEN else "" diff --git a/src/study_tool/platforms/guanyixue.py b/src/study_tool/platforms/guanyixue.py deleted file mode 100644 index 9069645..0000000 --- a/src/study_tool/platforms/guanyixue.py +++ /dev/null @@ -1,45 +0,0 @@ -"""莞易学平台:jspx.dgjy.net(登录入口)与 study.dgjy.net(学习入口)是同一个平台。 - -账号在「添加账号」里选择平台(`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 - - -class Guanyixue(Platform): - id = "guanyixue" - label = "莞易学" - # 登录入口 / 学习入口;命中任意一个就归到莞易学 - entries = ("jspx.dgjy.net", "study.dgjy.net", "dgjy.net") - - def session(self, cookies=None): - return jspx.new_session(cookies) - - def dump_cookies(self, s): - return jspx.dump_cookies(s) - - def login(self, username, password): - return jspx.login(username, password) - - def list_courses(self, s): - return jspx.scrape_courses(s) - - def discover(self, s, plan_id, project_id, name=""): - return jspx.discover(s, plan_id, project_id, name) - - def renew_session(self, s): - return jspx.ensure_jspx_session(s) - - def profile(self, s): - return jspx.whoami(jspx.fetch_admin(s)) - - def study(self, 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(Guanyixue()) diff --git a/src/study_tool/platforms/detect.py b/src/study_tool/pugins/guanyixue/detect.py similarity index 100% rename from src/study_tool/platforms/detect.py rename to src/study_tool/pugins/guanyixue/detect.py diff --git a/src/study_tool/platforms/jspx.py b/src/study_tool/pugins/guanyixue/jspx.py similarity index 100% rename from src/study_tool/platforms/jspx.py rename to src/study_tool/pugins/guanyixue/jspx.py diff --git a/src/study_tool/pugins/guanyixue/main.py b/src/study_tool/pugins/guanyixue/main.py index e69de29..9069645 100644 --- a/src/study_tool/pugins/guanyixue/main.py +++ b/src/study_tool/pugins/guanyixue/main.py @@ -0,0 +1,45 @@ +"""莞易学平台:jspx.dgjy.net(登录入口)与 study.dgjy.net(学习入口)是同一个平台。 + +账号在「添加账号」里选择平台(`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 + + +class Guanyixue(Platform): + id = "guanyixue" + label = "莞易学" + # 登录入口 / 学习入口;命中任意一个就归到莞易学 + entries = ("jspx.dgjy.net", "study.dgjy.net", "dgjy.net") + + def session(self, cookies=None): + return jspx.new_session(cookies) + + def dump_cookies(self, s): + return jspx.dump_cookies(s) + + def login(self, username, password): + return jspx.login(username, password) + + def list_courses(self, s): + return jspx.scrape_courses(s) + + def discover(self, s, plan_id, project_id, name=""): + return jspx.discover(s, plan_id, project_id, name) + + def renew_session(self, s): + return jspx.ensure_jspx_session(s) + + def profile(self, s): + return jspx.whoami(jspx.fetch_admin(s)) + + def study(self, 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(Guanyixue()) diff --git a/src/study_tool/platforms/study.py b/src/study_tool/pugins/guanyixue/study.py similarity index 100% rename from src/study_tool/platforms/study.py rename to src/study_tool/pugins/guanyixue/study.py diff --git a/uv.lock b/uv.lock index f91b142..e3c38ea 100644 --- a/uv.lock +++ b/uv.lock @@ -1380,6 +1380,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1465,6 +1474,7 @@ dependencies = [ { name = "fastapi" }, { name = "lxml" }, { name = "playwright" }, + { name = "python-dotenv" }, { name = "requests" }, { name = "uvicorn" }, ] @@ -1476,6 +1486,7 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.110" }, { name = "lxml", specifier = ">=5.0" }, { name = "playwright", specifier = ">=1.40" }, + { name = "python-dotenv", specifier = ">=1.0" }, { name = "requests", specifier = ">=2.31" }, { name = "uvicorn", specifier = ">=0.27" }, ]