From f2d981c5c1f7e454862e713f71d2d34adf256f17 Mon Sep 17 00:00:00 2001 From: chen <2535365189@qq.com> Date: Sun, 6 Sep 2026 19:04:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BC=BA=E5=8C=96=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=8F=AF=E9=9D=A0=E6=80=A7=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=99=BB=E5=BD=95=E4=B8=8E=E9=AA=8C=E8=AF=81=E7=A0=81?= =?UTF-8?q?=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 84 +- Dockerfile | 17 +- README.md | 71 +- answer_store.py | 240 ++++ api.py | 572 +++++++-- captcha.py | 1211 +++++++++++++++---- client.py | 2094 +++++++++++++++++++++++---------- config.example.toml | 38 +- entrypoint.sh | 36 +- errors.py | 160 +++ main.py | 1889 ++++++++++++++--------------- pyproject.toml | 5 + runtime_config.py | 1001 ++++++++++++++++ tests/conftest.py | 13 + tests/test_answer_store.py | 89 ++ tests/test_api_contract.py | 396 +++++++ tests/test_api_safety.py | 202 ++++ tests/test_captcha.py | 788 +++++++++++++ tests/test_client_protocol.py | 40 + tests/test_client_safety.py | 1221 +++++++++++++++++++ tests/test_errors.py | 30 + tests/test_main_runtime.py | 447 +++++++ tests/test_runtime_config.py | 413 +++++++ uv.lock | 49 +- 24 files changed, 9202 insertions(+), 1904 deletions(-) create mode 100644 answer_store.py create mode 100644 errors.py create mode 100644 runtime_config.py create mode 100644 tests/conftest.py create mode 100644 tests/test_answer_store.py create mode 100644 tests/test_api_contract.py create mode 100644 tests/test_api_safety.py create mode 100644 tests/test_captcha.py create mode 100644 tests/test_client_protocol.py create mode 100644 tests/test_client_safety.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_main_runtime.py create mode 100644 tests/test_runtime_config.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 74da00b..68b6d4e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: push: tags: - "v*" + pull_request: workflow_dispatch: env: @@ -47,9 +48,35 @@ jobs: echo "app_version=${app_version}" >> "$GITHUB_OUTPUT" echo "产物版本:${app_version}" + quality: + name: Quality gates + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b + with: + python-version: "3.12" + cache-python: true + enable-cache: true + env: + UV_PYTHON_PREFERENCE: only-managed + + - name: Install locked dependencies + run: uv sync --frozen + + - name: Run tests + run: uv run --frozen pytest + + - name: Run Ruff + run: uv run --frozen ruff check . + + - name: Run Pyright + run: uv run --frozen pyright + build: name: Build ${{ matrix.os }} - needs: version + needs: [version, quality] runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -84,7 +111,7 @@ jobs: env: UV_PYTHON_PREFERENCE: only-managed - - run: uv sync + - run: uv sync --frozen - name: Build executable shell: bash @@ -96,9 +123,16 @@ jobs: name="WeBan-${os_name}-${arch}${ext}" sep=$(python -c "import os; print(os.pathsep)") - uv run pyinstaller --noconfirm --onefile \ + model_args=() + if [[ -f captcha_model.onnx ]]; then + model_args+=(--add-data "captcha_model.onnx${sep}.") + else + echo "::warning file=captcha_model.onnx::模型缺失,冻结程序将禁用登录验证码 OCR 并按运行环境降级" + fi + + uv run --frozen pyinstaller --noconfirm --onefile \ --name "$name" \ - --add-data "captcha_model.onnx${sep}." \ + "${model_args[@]}" \ --add-data "answer/answer.json${sep}answer/answer.json" \ --add-data "config.example.toml${sep}." \ --add-data "pyproject.toml${sep}." \ @@ -111,15 +145,54 @@ jobs: echo "name=$name" >> "$GITHUB_ENV" + - name: Smoke test executable + shell: bash + run: | + "./dist/${name}" --help + - uses: actions/upload-artifact@v7 with: name: ${{ env.name }} path: dist/${{ env.name }}* if-no-files-found: error + docker-smoke: + name: Docker smoke tests + needs: [version, quality] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Build without-browser target + env: + APP_VERSION: ${{ needs.version.outputs.app_version }} + run: >- + docker build + --target without-browser + --build-arg "APP_VERSION=${APP_VERSION}" + --tag weban-smoke:without-browser + . + + - name: Smoke test without-browser target + run: docker run --rm weban-smoke:without-browser --help + + - name: Build with-browser target + env: + APP_VERSION: ${{ needs.version.outputs.app_version }} + run: >- + docker build + --target with-browser + --build-arg "APP_VERSION=${APP_VERSION}" + --tag weban-smoke:with-browser + . + + - name: Smoke test with-browser target + run: docker run --rm weban-smoke:with-browser --help + docker: name: Docker ${{ matrix.variant }} ${{ matrix.platform }} - needs: version + if: startsWith(github.ref, 'refs/tags/v') + needs: [version, quality, docker-smoke] runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -167,6 +240,7 @@ jobs: docker-merge: name: Docker merge ${{ matrix.variant }} + if: startsWith(github.ref, 'refs/tags/v') needs: docker runs-on: ubuntu-latest strategy: diff --git a/Dockerfile b/Dockerfile index 59b3e6a..47e6777 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1 + # WeBan Docker 镜像 # 目标: # with-browser — 内置浏览器,开箱即用 @@ -24,13 +26,22 @@ assert n==1, 'version field not found'; open(p,'w',encoding='utf-8').write(s2)" RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev --no-install-project -COPY *.py captcha_model.onnx config.example.toml ./ +COPY *.py config.example.toml ./ COPY answer/ answer/ RUN --mount=type=cache,target=/root/.cache/uv \ - uv run pyinstaller --noconfirm --onefile \ + --mount=type=bind,target=/context \ + set -eu; \ + if [ -f /context/captcha_model.onnx ]; then \ + set -- --add-data "/context/captcha_model.onnx:."; \ + echo "Including optional captcha_model.onnx"; \ + else \ + set --; \ + echo "WARNING: captcha_model.onnx not found; login OCR will use runtime fallback" >&2; \ + fi; \ + uv run --frozen pyinstaller --noconfirm --onefile \ --name WeBan \ - --add-data "captcha_model.onnx:." \ + "$@" \ --add-data "answer/answer.json:answer/answer.json" \ --add-data "config.example.toml:." \ --add-data "pyproject.toml:." \ diff --git a/README.md b/README.md index 0e8b81b..7bc621f 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,18 @@ 实现了课程学习和根据题库自动考试,支持多用户多线程运行,自动验证码识别等。 -运行前后会自动合并题库,如果一次没满分可以再考一次。可将 `answer/answer.json` 文件提交 PR 一起完善题库。 +运行前后会自动合并题库。为避免意外耗尽考试次数,每次运行对每个考试计划最多新开一张试卷;需要再次尝试时,请确认剩余次数后重新运行。可将 `answer/answer.json` 文件提交 PR 一起完善题库。 ## 功能特性 - **课程学习**:自动遍历项目 → 分类 → 课程,模拟翻页、答题、等待学习时长后完课;按项目交替完成课程与考试 - **自动考试**:基于题库自动答题,支持单选/多选,未匹配题目可随机作答或手动输入 -- **验证码识别**:登录滑块验证码自动识别;课程点选验证码自动识别(OpenCV,2 轮 × 3 次),失败才转手动 +- **验证码识别**:课程点选验证码自动识别(OpenCV,2 轮 × 3 次);可选 ONNX 模型用于登录字符验证码,缺失时明确降级 - **多账号并发**:支持配置多个账号,可多线程同时执行 - **题库同步**:考试前后自动从服务器同步题库,支持多用户共享 -- **断点续考**:追求满分模式下,一次未满分可再次考试 +- **单轮考试**:`perfect` / `force` 只影响本轮是否参加考试,不会在一次运行中连续重考 - **进度监控**:完课后自动检查进度是否更新,未更新则警告提示 -- **调试模式**:开启 `debug` 可查看完整请求/响应日志 +- **调试模式**:开启 `debug` 可查看额外请求/响应信息;日志可能含个人数据,分享前必须复查脱敏 - **无交互运行**:Docker / cron / 后台环境自动无交互,数据目录持久化 - **低配兼容**:numpy 1.26 + OpenCV 4.10 锁定,兼容无 AVX2 的 QEMU 虚拟 CPU(便宜 1H1G 云服务器可跑) @@ -71,6 +71,8 @@ 输入后程序会**自动验证账号**:登录成功就会把账号自动保存到配置文件 `config.toml`,然后开始学习和考试;**如果学校全称或用户名密码错了,会提示你重新输入,不会写坏配置文件**。之后每次运行都会接着上次的进度继续。 > 配置文件 `config.toml` 在程序旁边(Windows 是 exe 所在文件夹,Mac/Linux 是运行命令的目录),下次运行前也可以手动改它。用 `--data-dir` 可以指定固定位置(见下方参数表)。 +> +> `config.toml` 中的密码、Token 和 AI API Key 均为明文敏感信息。请限制文件权限,不要提交到版本库、上传网盘或随日志分享。 **不想交互输入?一条命令直接跑**(学校/学号/密码写在命令里,无需配置文件): @@ -82,7 +84,9 @@ $env:WB_TENANT_NAME="你的学校全称"; $env:WB_USERNAME="你的学号"; $env: WB_TENANT_NAME="你的学校全称" WB_USERNAME=你的学号 WB_PASSWORD=你的密码 ./WeBan-macos-arm64 ``` -> 全部参数对照表见下方"参数总览";账号想保密的、或一个文件管理多个账号的,用配置文件方式。 +> 命令行参数可能进入 Shell 历史,环境变量也可能被同机进程或运维平台读取。短期任务可用环境变量,长期使用请保护好配置文件和运行环境。 +> +> 全部参数对照表见下方"参数总览";一个文件管理多个账号时可用配置文件方式。 ### 参数总览 @@ -93,11 +97,11 @@ WB_TENANT_NAME="你的学校全称" WB_USERNAME=你的学号 WB_PASSWORD=你的 | — | `--config PATH` | `WB_CONFIG` | 配置文件路径(默认: 程序目录/config.toml) | | — | `--data-dir PATH` | `WB_DATA_DIR` | 数据目录(config/logs/answer 都在此,适合挂载) | | — | `--non-interactive` | — | 无交互模式(环境变量用 `ENVIRONMENT=docker`/`container` 或 stdin 非 TTY 自动判定) | -| `study_mode` | `--study-mode` | `WB_STUDY_MODE` | 学习模式(`false`/`true`/`force`) | -| `exam_mode` | `--exam-mode` | `WB_EXAM_MODE` | 考试模式(`false`/`true`/`perfect`/`force`) | +| `study_mode` | `--study-mode` | `WB_STUDY_MODE` | 学习模式;`force` 仅在本轮重学一次,不无限循环 | +| `exam_mode` | `--exam-mode` | `WB_EXAM_MODE` | 考试模式;`perfect`/`force` 每计划每轮最多新开一张试卷 | | `random_answer` | `--random-answer` | `WB_RANDOM_ANSWER` | 题库外题目是否随机作答(`true`/`false`) | | `study_time` | `--study-time SEC` | `WB_STUDY_TIME` | 每门课学习时长 `"基础,随机上限"`(秒),如 `"20,5"` | -| `video_speed` | `--video-speed N` | `WB_VIDEO_SPEED` | 视频课程倍速:`0`=不按视频时长等待、`1`=原速、`2`=半速 | +| `video_speed` | `--video-speed N` | `WB_VIDEO_SPEED` | 等待时间倍速:`0`=忽略视频时长、`1`=原时长、`2`=等待一半时长(2 倍速) | | `exam_question_time` | `--exam-question-time SEC` | `WB_EXAM_QUESTION_TIME` | 每道考试题答题等待时长 `"基础,随机上限"`(秒) | | `exam_submit_match_rate` | `--exam-submit-match-rate N` | `WB_EXAM_SUBMIT_MATCH_RATE` | 允许交卷的最低题库匹配率(百分比) | | `browser_path` | `--browser-path PATH` | `WB_BROWSER_PATH` | 浏览器可执行文件路径 | @@ -105,13 +109,13 @@ WB_TENANT_NAME="你的学校全称" WB_USERNAME=你的学号 WB_PASSWORD=你的 | `cdp_port` | `--cdp-port PORT` | `WB_CDP_PORT` | CDP 浏览器端口 | | `jupiter_fallback` | `--jupiter-fallback` | `WB_JUPITER_FALLBACK` | 对未加载 apicenext.js 的课程是否补发 jupiter 翻页轨迹 | | `max_workers` | `--max-workers N` | `WB_MAX_WORKERS` | 多账号最大并发数 | -| `debug` | `--debug` | `WB_DEBUG` | 启用调试日志 | +| `debug` | `--debug` | `WB_DEBUG` | 启用额外调试日志(可能含个人数据) | | `tenant_name` | `--tenant-name NAME` | `WB_TENANT_NAME` | 单账号学校全称(免配置文件) | | `username` | `--username USER` | `WB_USERNAME` | 单账号用户名 | | `password` | `--password PASS` | `WB_PASSWORD` | 单账号密码(默认同用户名) | | `user_id` | `--user-id ID` | `WB_USER_ID` | 单账号用户 ID(Token 登录) | | `token` | `--token TOKEN` | `WB_TOKEN` | 单账号登录 Token(配合 `--tenant-name --user-id`) | -| `[ai].enable` | `--ai-enable` | `WB_AI_ENABLE` | 是否启用 AI 搜题(`true`/`false`) | +| `[ai].enable` | `--ai-enable` | `WB_AI_ENABLE` | 是否启用 AI 搜题(默认关闭;启用会向第三方发送题干和选项) | | `[ai].base_url` | `--ai-base-url URL` | `WB_AI_BASE_URL` | AI 服务 API 基础路径 | | `[ai].api_key` | `--ai-api-key KEY` | `WB_AI_API_KEY` | AI 服务 API Key | | `[ai].model` | `--ai-model NAME` | `WB_AI_MODEL` | AI 模型名称 | @@ -120,6 +124,17 @@ WB_TENANT_NAME="你的学校全称" WB_USERNAME=你的学号 WB_PASSWORD=你的 无交互自动判定:`ENVIRONMENT=docker`(或 container)、stdin 非 TTY(cron/后台/管道)、或显式 `--non-interactive`。 +模式均按**单轮**执行:`study_mode=force` 会在本轮重新学习已完成课程一次;`exam_mode=perfect` 会以满分为目标、`force` 会忽略已及格状态,但两者都不会在同一次运行中自动连续开卷。需要再次考试时必须重新运行,以便人工确认剩余机会。 + +`video_speed` 只用于计算等待时间,不会操控网页播放器。大于 `0` 时,视频课程等待目标为 `max(study_time, 视频时长 / video_speed)`;设为 `0` 时忽略视频时长,只遵守 `study_time`。 + +#### 数据与隐私 + +- 启用 AI 搜题后,题干和选项会发送到 `base_url` 指向的服务商。使用前请确认数据允许外传,并接受对方的隐私与留存政策。 +- `password`、`token`、`api_key` 都属于凭据。不要写入镜像、公开仓库或截图;配置文件应仅允许运行账号读取。 +- 程序会尽量脱敏常见凭据,但 `debug` 日志和验证码调试文件仍可能含账号、课程、响应正文等个人信息。分享前二次打码,用完及时删除。 +- CDP 端点等同于浏览器完全控制权。仅使用无个人会话的专用浏览器配置,并通过回环地址或防火墙限制访问,切勿暴露到公网。 + **完全不写 config.toml 也能运行**(单账号 + 全部设置走 CLI/env): ```bash @@ -135,7 +150,7 @@ WB_STUDY_TIME="20,5" WB_VIDEO_SPEED=0 ./WeBan-macos-arm64 不需要代码基础的用户**跳过本节**(直接下载二进制即可)。开发者/想改代码时用: -1. 安装 Python 3(建议使用 [uv](https://github.com/astral-sh/uv))和 Git +1. 安装 Python 3.12(项目要求 `>=3.12,<3.13`)、[uv](https://github.com/astral-sh/uv) 和 Git 2. 克隆本仓库 @@ -146,20 +161,20 @@ git clone --depth 1 https://github.com/hangone/WeBan 3. 安装依赖 ```bash -pip install -r requirements.txt # 或 uv sync +uv sync --frozen ``` 4. 运行 ```bash -python main.py # 或 uv run main.py +uv run python main.py ``` -运行 `python main.py --help` 可查看全部参数。 +运行 `uv run python main.py --help` 可查看全部参数。 ### Docker -提供两种镜像变体(多架构 amd64/arm64,发布时随版本推送): +提供两种镜像变体(多架构 amd64/arm64,仅版本 Tag 发布正式镜像标签): | 镜像 | Tag | 说明 | | ---------- | ---------------------------------------------- | ------------------------------ | @@ -178,8 +193,10 @@ docker run --rm \ - 建议 `--cpus 1`(详见下方"CPU 配额与验证码");首次运行会在 `./data/` 生成 `config.toml` 模板,填写账号后重新运行即可 - 日志在 `./data/logs/<账号>/`,题库在 `./data/answer/`,全部挂载持久化 -- 无交互:不弹编辑器、确认用默认值、验证码自动识别失败不等待手动输入(跳过该课)、末尾不等待回车 -- 需要交互(如手动输验证码)时用 `docker run -it`(容器检测到 TTY 自动进入交互模式) +- 官方镜像固定设置 `ENVIRONMENT=docker`:不弹编辑器、不等待终端输入、不打开可见浏览器,验证码无法自动处理时会跳过或失败,末尾不等待回车 +- `docker run -it` **不会**解除上述限制。需要手动答题、输入验证码或操作可见浏览器时,请在宿主机直接运行源码或原生可执行文件 +- 不要把 `random_answer=false` 视为 Docker 下的人工确认通道;如不接受无交互环境中的自动降级,请将 `exam_mode=false` +- 内置浏览器的 CDP 只监听容器内 `127.0.0.1:9222`,无需也不应发布 `9222` 端口 所有配置项均可覆盖(命令行参数 > 环境变量 > 配置文件,名称一一对应,见上方参数表)。示例: @@ -196,6 +213,8 @@ docker run --rm -v "$PWD/data":/app/data --cpus 1 \ --study-time "20,5" --video-speed 0 ``` +> 上述 `-e WB_PASSWORD=...` 适合临时演示,生产环境请使用受控的秘密注入机制,避免凭据出现在命令历史、部署清单或平台日志中。 + #### CPU 配额与验证码 - **docker 下多核正常**:实测(docker 29.x,2 核 1.9GB)`--cpus 1` / `--cpus 2` × 单进程/多进程全部跑通,真实课程点选验证码在 `--cpus 2` 下完整通过(识别 → 点击 → 提交 → 腾讯 SDK 回调成功),无挂起 @@ -208,7 +227,7 @@ docker run --rm -v "$PWD/data":/app/data --cpus 1 \ **第一步:在宿主机启动 Chrome 远程调试** -打开 Chrome,地址栏输入 `chrome://inspect/#remote-debugging`,勾选 **Allow remote debugging for this browser instance**。 +打开 Chrome,地址栏输入 `chrome://inspect/#remote-debugging`,勾选 **Allow remote debugging for this browser instance**。请使用不含个人账号、Cookie 或敏感标签页的专用浏览器配置。 或者直接命令行启动带远程调试的 Chrome: @@ -236,15 +255,27 @@ docker run --rm \ 如需自定义 CDP 地址,可用 `--cdp-host` / `--cdp-port` 参数或配置文件 `cdp_host` / `cdp_port`。 +> CDP 没有面向公网使用的安全边界,任何可访问该端口的程序都可能读取页面、Cookie 并执行脚本。不要使用 `-p 9222:9222` 暴露内置 CDP;外部 CDP 也必须限制在可信主机和网络内。 + ### 浏览器检测 程序按以下优先级自动检测可用的浏览器,无需手动配置: -1. **用户指定**:配置文件 `browser_path`(或 `--browser-path` / `WB_BROWSER_PATH`) -2. **CDP 远程调试**:配置文件 `cdp_host` + `cdp_port`(或 CLI/env),或 Docker 环境下自动尝试 `host.docker.internal:9222` +1. **CDP 远程调试**:配置文件 `cdp_host` + `cdp_port`(或 CLI/env),或自动探测默认 CDP 端点;完整 CDP 配置优先于 `browser_path` +2. **用户指定**:配置文件 `browser_path`(或 `--browser-path` / `WB_BROWSER_PATH`) 3. **Playwright 浏览器**:自动查找 `~/.cache/ms-playwright` 下的 Chromium 4. **系统浏览器**:自动查找已安装的 Chrome / Chromium / Edge +### 验证码模型与降级 + +`captcha_model.onnx` 是**可选**的登录字符验证码 OCR 资源,不是程序启动或打包的硬依赖。源码、冻结程序和两个 Docker target 在文件缺失时仍可构建并启动,构建日志会给出提示。 + +- 模型存在且有效:打包时自动嵌入,密码登录可尝试 OCR。 +- 模型缺失或加载失败:仅禁用登录字符 OCR;交互式原生运行会回退人工输入。 +- 无交互运行(包括官方 Docker 镜像):无法人工输入,密码登录会明确失败;已有 Token 的登录方式不受该模型影响。 + +不要从不可信来源下载或替换模型文件。 + ## 演示 ![study](images/study.png) diff --git a/answer_store.py b/answer_store.py new file mode 100644 index 0000000..6b0bd45 --- /dev/null +++ b/answer_store.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import copy +import json +import os +import tempfile +import threading +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Self, cast + + +class AnswerStoreError(RuntimeError): + """题库存储不可读或待写数据无效。""" + + +_THREAD_LOCKS: dict[str, threading.RLock] = {} +_THREAD_LOCKS_GUARD = threading.Lock() +_MISSING = object() + + +def _thread_lock_for(path: Path) -> threading.RLock: + key = os.path.normcase(str(path.resolve())) + with _THREAD_LOCKS_GUARD: + return _THREAD_LOCKS.setdefault(key, threading.RLock()) + + +class _ProcessFileLock: + """仅依赖标准库的 Windows/POSIX 跨进程排他锁。""" + + def __init__(self, path: Path) -> None: + self.path = path + self._file = None + + def __enter__(self) -> Self: + self.path.parent.mkdir(parents=True, exist_ok=True) + self._file = self.path.open("a+b", buffering=0) + self._file.seek(0, os.SEEK_END) + if self._file.tell() == 0: + self._file.write(b"\0") + self._file.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(self._file.fileno(), msvcrt.LK_LOCK, 1) + else: + import fcntl + + fcntl.flock(self._file.fileno(), fcntl.LOCK_EX) + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self._file is None: + return + try: + self._file.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(self._file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(self._file.fileno(), fcntl.LOCK_UN) + finally: + self._file.close() + self._file = None + + +class AnswerStore: + """原子、线程安全且跨进程安全的 JSON 题库存储。""" + + def __init__( + self, + path: str | os.PathLike[str], + *, + fallbacks: tuple[str | os.PathLike[str], ...] = (), + validator: Callable[[Any], bool] | None = None, + ) -> None: + self.path = Path(path) + self.backup_path = Path(f"{self.path}.bak") + self.lock_path = Path(f"{self.path}.lock") + self.fallbacks = tuple(Path(item) for item in fallbacks) + self.validator = validator or (lambda value: isinstance(value, dict)) + self._thread_lock = _thread_lock_for(self.path) + + @contextmanager + def locked(self) -> Iterator[None]: + """对一次完整的读改写事务加线程锁与文件锁。""" + + with self._thread_lock, _ProcessFileLock(self.lock_path): + yield + + def _decode(self, path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as file: + value = json.load(file) + if not self.validator(value): + raise AnswerStoreError(f"题库格式无效: {path}") + return value + + def _load_candidates_unlocked(self) -> tuple[dict[str, Any], Path]: + errors: list[str] = [] + seen: set[str] = set() + for candidate in (self.path, self.backup_path, *self.fallbacks): + key = os.path.normcase(str(candidate.resolve())) + if key in seen: + continue + seen.add(key) + if not candidate.exists(): + continue + try: + return self._decode(candidate), candidate + except ( + OSError, + UnicodeError, + json.JSONDecodeError, + AnswerStoreError, + ) as exc: + errors.append(f"{candidate}: {exc}") + detail = ";".join(errors) if errors else "没有可用文件" + raise AnswerStoreError(f"无法加载题库:{detail}") + + def load( + self, + *, + default: Mapping[str, Any] | object = _MISSING, + recover: bool = True, + ) -> dict[str, Any]: + """读取题库;主文件损坏时从最近备份或只读候选恢复。""" + + with self.locked(): + try: + value, source = self._load_candidates_unlocked() + except AnswerStoreError: + if default is _MISSING: + raise + value = dict(cast(Mapping[str, Any], default)) + source = self.path + if recover and source != self.path: + self._atomic_write_unlocked(value, create_backup=False) + return copy.deepcopy(value) + + def write(self, value: Mapping[str, Any]) -> None: + """校验后原子写入,并在覆盖前保留一份最近有效版本。""" + + data = copy.deepcopy(dict(value)) + if not self.validator(data): + raise AnswerStoreError("拒绝写入无效题库") + with self.locked(): + self._atomic_write_unlocked(data, create_backup=True) + + def update( + self, + mutator: Callable[[dict[str, Any]], Mapping[str, Any] | None], + *, + default: Mapping[str, Any] | object = _MISSING, + ) -> dict[str, Any]: + """在同一跨进程锁内完成读取、修改和原子写入。""" + + with self.locked(): + try: + current, _ = self._load_candidates_unlocked() + except AnswerStoreError: + if default is _MISSING: + raise + current = dict(cast(Mapping[str, Any], default)) + working = copy.deepcopy(current) + changed = mutator(working) + result = dict(changed) if changed is not None else working + if not self.validator(result): + raise AnswerStoreError("题库更新结果无效") + self._atomic_write_unlocked(result, create_backup=True) + return copy.deepcopy(result) + + def _atomic_write_unlocked( + self, + value: Mapping[str, Any], + *, + create_backup: bool, + ) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + if create_backup and self.path.exists(): + try: + current = self._decode(self.path) + except (OSError, UnicodeError, json.JSONDecodeError, AnswerStoreError): + current = None + if current is not None: + self._write_temp_and_replace( + self.backup_path, + json.dumps( + current, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + + "\n", + ) + + text = json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + self._write_temp_and_replace(self.path, text) + + @staticmethod + def _write_temp_and_replace(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp( + prefix=f".{path.name}.", + suffix=".tmp", + dir=path.parent, + ) + temp_path = Path(temp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as file: + file.write(text) + file.flush() + os.fsync(file.fileno()) + os.replace(temp_path, path) + AnswerStore._fsync_directory(path.parent) + except BaseException: + try: + temp_path.unlink() + except OSError: + pass + raise + + @staticmethod + def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + flags = os.O_RDONLY + if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + try: + fd = os.open(path, flags) + except OSError: + return + try: + os.fsync(fd) + finally: + os.close(fd) diff --git a/api.py b/api.py index 909f936..5d21bd3 100644 --- a/api.py +++ b/api.py @@ -1,16 +1,20 @@ +from __future__ import annotations + import hashlib import json +import re import time from base64 import b64encode, urlsafe_b64decode, urlsafe_b64encode from random import randint -from typing import Any, ClassVar +from typing import Any, ClassVar, Self +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from uuid import uuid4 import pyaes import requests from loguru import logger -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry + +from errors import AccountBlockedError, APIResponseError, TokenInvalidError def pkcs7_pad(data: bytes, block_size: int = 16) -> bytes: @@ -18,28 +22,194 @@ def pkcs7_pad(data: bytes, block_size: int = 16) -> bytes: return data + bytes([pad_len] * pad_len) -def handle_response(response: requests.Response) -> dict[str, Any]: - """处理接口响应""" +_SENSITIVE_KEYS = { + "account", + "authorization", + "cookie", + "keynumber", + "openid", + "password", + "randstr", + "realname", + "ticket", + "token", + "userid", + "username", + "uniquevalue", + "verifycode", + "x-token", +} +_MAX_LOG_BODY = 2048 +_SENSITIVE_PAIR_PATTERN = re.compile( + r'(?i)(["\']?(?:' + + "|".join(re.escape(key) for key in sorted(_SENSITIVE_KEYS)) + + r')["\']?\s*[:=]\s*)' + r'(?:"[^"]*"|\'[^\']*\'|[^,\s&}\]]+)' +) + + +def _is_sensitive_key(key: object) -> bool: + normalized = re.sub(r"[^a-z0-9-]", "", str(key).lower()) + return normalized in _SENSITIVE_KEYS or normalized.endswith("token") + + +def _redact_value(value: Any, *, parent_key: str = "") -> Any: + if _is_sensitive_key(parent_key): + return "" + if isinstance(value, dict): + return { + key: ( + "" + if _is_sensitive_key(key) + else _redact_value(item, parent_key=str(key)) + ) + for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [_redact_value(item, parent_key=parent_key) for item in value] + return value + + +def _redact_text(text: str, limit: int = _MAX_LOG_BODY) -> str: + """对响应摘要做结构化和文本两层脱敏,并限制长度。""" + + candidate = text + try: + parsed = json.loads(text) + except (json.JSONDecodeError, TypeError): + parsed = None + if parsed is not None: + candidate = json.dumps( + _redact_value(parsed), + ensure_ascii=False, + separators=(",", ":"), + ) + candidate = _SENSITIVE_PAIR_PATTERN.sub(r"\1", candidate) + if len(candidate) > limit: + return candidate[:limit] + "…" + return candidate + + +def _sanitize_url(url: str) -> str: + try: + parts = urlsplit(url) + query = [ + (key, "" if _is_sensitive_key(key) else value) + for key, value in parse_qsl(parts.query, keep_blank_values=True) + ] + return urlunsplit( + (parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment) + ) + except ValueError: + return _redact_text(url, 512) + + +def _response_summary(response: requests.Response) -> str: + return _redact_text(response.text or "") + + +def _raise_if_account_blocked( + payload: dict[str, Any], + *, + endpoint: str, + status_code: int | None = None, +) -> None: + detail = str(payload.get("detailCode", "")) + code = str(payload.get("code", "")) + message = str( + payload.get("msg") or payload.get("message") or payload.get("detail") or "" + ) + raw = str(payload.get("raw", "")) + if ( + detail in {"10018", "701"} + or code == "701" + or "Account locked" in message + or "Account locked" in raw + or "行为存在异常" in message + ): + raise AccountBlockedError( + message or "系统检测到行为异常或账号已锁定", + detail_code=detail or code, + status_code=status_code, + endpoint=endpoint, + ) + + +def handle_response( + response: requests.Response, + *, + endpoint: str = "", + strict: bool = False, + log: Any = logger, +) -> dict[str, Any]: + """处理接口响应;API 内部使用 strict=True 产生结构化错误。""" + + safe_endpoint = _sanitize_url( + endpoint or getattr(response, "url", "") or "" + ) + if response.status_code == 701 or "Account locked" in response.text: + raise AccountBlockedError( + "Account locked", + detail_code="701", + status_code=response.status_code, + endpoint=safe_endpoint, + ) if response.status_code != 200: if response.status_code == 403: - raise PermissionError("Token 无效,不允许同时登录,请重试") + raise TokenInvalidError( + "Token 无效,不允许同时登录,请重试", + status_code=403, + endpoint=safe_endpoint, + ) if response.status_code == 401: - raise PermissionError("Token 无效,请检查账号信息") - print(f"请求失败:{response.status_code} {response.text}") + raise TokenInvalidError( + "Token 无效,请检查账号信息", + status_code=401, + endpoint=safe_endpoint, + ) + summary = _response_summary(response) + if strict: + raise APIResponseError( + "请求失败", + status_code=response.status_code, + endpoint=safe_endpoint, + summary=summary, + ) + log.error(f"请求失败:{response.status_code} {summary}") return {} try: - return response.json() + result = response.json() except json.JSONDecodeError: - print(f"响应内容不是有效的 JSON:{response.text}") + summary = _response_summary(response) + if strict: + raise APIResponseError( + "响应内容不是有效的 JSON", + status_code=response.status_code, + endpoint=safe_endpoint, + summary=summary, + ) + log.error(f"响应内容不是有效的 JSON:{summary}") return {} + if not isinstance(result, dict): + summary = _redact_text(json.dumps(result, ensure_ascii=False, default=str)) + if strict: + raise APIResponseError( + "响应 JSON 顶层不是对象", + status_code=response.status_code, + endpoint=safe_endpoint, + summary=summary, + ) + return {} + _raise_if_account_blocked( + result, + endpoint=safe_endpoint, + status_code=response.status_code, + ) + return result class LoggingSession: - """统一的网络请求:自动重试、debug 日志、基本浏览器头。 - - 内部构造带 HTTPAdapter Retry 的 requests.Session(429/5xx 自动重试, - backoff_factor=1),外层捕获 RequestException 记日志后抛回。 - """ + """统一网络请求;只有显式标记的只读请求才会自动重试。""" DEFAULT_HEADERS: ClassVar[dict[str, str]] = { "User-Agent": ( @@ -49,54 +219,86 @@ class LoggingSession: "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", } + RETRY_STATUS: ClassVar[frozenset[int]] = frozenset({429, 500, 502, 503, 504}) + MAX_RETRIES: ClassVar[int] = 5 - def __init__(self, log=logger, debug: bool = False): + def __init__(self, log: Any = logger, debug: bool = False): session = requests.Session() - retry = Retry( - total=5, - backoff_factor=1, - status_forcelist=[429, 500, 502, 503, 504], - allowed_methods=["GET", "POST"], - ) - session.mount("https://", HTTPAdapter(max_retries=retry)) session.headers.update(self.DEFAULT_HEADERS) self._session = session self.log = log self.debug = debug + self._closed = False @property def headers(self): return self._session.headers - def request(self, method: str, url: str, **kwargs) -> requests.Response: - # debug 日志只记录 mycourse.cn 域名的请求/响应,其余域名(题库、 - # 腾讯 CDN、AI 等)不记录,避免无关噪音和二进制乱码 + def request( + self, + method: str, + url: str, + *, + _retryable: bool = False, + **kwargs, + ) -> requests.Response: + if self._closed: + raise RuntimeError("HTTP Session 已关闭") + safe_url = _sanitize_url(url) if self.debug and "mycourse.cn" in url: - parts = [f"{method} {url}"] + parts = [f"{method} {safe_url}"] for key in ("params", "data", "json"): val = kwargs.get(key) if val is not None: + if "/pharos/login/login.do" in url and key == "data": + val = "" + else: + val = _redact_value(val) parts.append(f"{key}={val}") self.log.debug(" | ".join(parts)) - try: - response = self._session.request(method, url, **kwargs) - except requests.RequestException as e: - self.log.error(f"{method} {url} 请求异常: {e}") - raise + + max_attempts = self.MAX_RETRIES + 1 if _retryable else 1 + response: requests.Response | None = None + for attempt in range(1, max_attempts + 1): + try: + response = self._session.request(method, url, **kwargs) + except requests.RequestException as exc: + if attempt >= max_attempts: + self.log.error( + f"{method} {safe_url} 请求异常: {_redact_text(str(exc), 512)}" + ) + raise + time.sleep(min(2 ** (attempt - 1), 8)) + continue + if response.status_code not in self.RETRY_STATUS or attempt >= max_attempts: + break + retry_after = response.headers.get("Retry-After", "") + try: + delay = max(0.0, min(float(retry_after), 30.0)) + except (TypeError, ValueError): + delay = min(2 ** (attempt - 1), 8) + try: + response.close() + except AttributeError: + # 轻量测试替身可能没有 raw;真实 requests.Response 会正常释放。 + pass + time.sleep(delay) + + assert response is not None if self.debug and "mycourse.cn" in url: content_type = response.headers.get("Content-Type", "") if not content_type or not content_type.lower().startswith( ("text/", "application/json", "application/javascript") ): self.log.debug( - f"{method} {url} | status={response.status_code} | " + f"{method} {safe_url} | status={response.status_code} | " f"content-type={content_type or 'unknown'} | " f"body={len(response.content)} bytes (binary, skipped)" ) else: self.log.debug( - f"{method} {url} | status={response.status_code} | " - f"response={response.text}" + f"{method} {safe_url} | status={response.status_code} | " + f"response={_response_summary(response)}" ) return response @@ -106,13 +308,34 @@ def get(self, url: str, **kwargs) -> requests.Response: def post(self, url: str, **kwargs) -> requests.Response: return self.request("POST", url, **kwargs) + def request_retryable(self, method: str, url: str, **kwargs) -> requests.Response: + return self.request(method, url, _retryable=True, **kwargs) + + def get_retryable(self, url: str, **kwargs) -> requests.Response: + return self.request_retryable("GET", url, **kwargs) + + def post_retryable(self, url: str, **kwargs) -> requests.Response: + return self.request_retryable("POST", url, **kwargs) + + def close(self) -> None: + if not self._closed: + self._session.cookies.clear() + for key in ("Authorization", "Cookie", "X-Token"): + self._session.headers.pop(key, None) + self._session.close() + self._closed = True + + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + class WeBanAPI: # 题库下载地址(jsDelivr CDN 稳定;gh-proxy 免费公共代理会限流 403, # github 官方 raw 域名在国内不稳定,均不用) - ANSWER_URL = ( - "https://cdn.jsdelivr.net/gh/hangone/WeBan@main/answer/answer.json" - ) + ANSWER_URL = "https://cdn.jsdelivr.net/gh/hangone/WeBan@main/answer/answer.json" def __init__( self, @@ -122,7 +345,7 @@ def __init__( user: dict[str, str] | None = None, timeout: int | tuple = (9.05, 15), debug: bool = False, - log=logger, + log: Any = logger, ): self.account = account self.password = password @@ -134,6 +357,47 @@ def __init__( self.session.headers["X-Token"] = self.user["token"] self.log = log + def _request( + self, + method: str, + url: str, + *, + retryable: bool = False, + **kwargs, + ) -> requests.Response: + """发送请求;测试替身无 retryable 接口时保持原请求契约。""" + + method_name = method.lower() + if retryable: + retry_sender = getattr(self.session, f"{method_name}_retryable", None) + if retry_sender is not None: + return retry_sender(url, **kwargs) + sender = getattr(self.session, method_name) + return sender(url, **kwargs) + + def _handle(self, response: requests.Response, endpoint: str) -> dict[str, Any]: + return handle_response( + response, + endpoint=endpoint, + strict=True, + log=self.log, + ) + + def close(self) -> None: + """关闭连接池并清除内存中的敏感登录字段。""" + + close = getattr(self.session, "close", None) + if close is not None: + close() + self.password = None + self.user.pop("token", None) + + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + @staticmethod def get_timestamp(int_len: int = 10, frac_len: int = 3) -> str: """ @@ -174,6 +438,8 @@ def _post( endpoint: str, data: dict | None = None, timestamp_args: tuple | None = None, + *, + retryable: bool = False, ) -> dict[str, Any]: """ 通用 POST 请求,封装所有端点共用的模板代码。 @@ -195,12 +461,19 @@ def _post( data.setdefault("tenantCode", self.tenant_code) if self.user.get("userId"): data.setdefault("userId", self.user["userId"]) - response = self.session.post( - url, params=params, data=data, timeout=self.timeout + response = self._request( + "POST", + url, + retryable=retryable, + params=params, + data=data, + timeout=self.timeout, ) - return handle_response(response) + return self._handle(response, url) - def _mercury_request(self, params: dict) -> dict[str, Any]: + def _mercury_request( + self, params: dict, *, retryable: bool = False + ) -> dict[str, Any]: """ mercuryprovider 通用请求。会将 appKey/format/v/timestamp/clientId 标准参数与传入 params 合并, 按 key 字母序拼接成 sign_str,用固定密钥 75uet0kwvnc90xo 做包装式 SHA1 签名。 @@ -222,12 +495,15 @@ def _mercury_request(self, params: dict) -> dict[str, Any]: sign_str += k + str(merged[k]) sign_str += secret_key merged["sign"] = hashlib.sha1(sign_str.encode()).hexdigest().upper() - response = self.session.post( - "https://resource.mycourse.cn/mercuryprovider/router", + url = "https://resource.mycourse.cn/mercuryprovider/router" + response = self._request( + "POST", + url, + retryable=retryable, data=merged, timeout=self.timeout, ) - return handle_response(response) + return self._handle(response, url) # ======================================================================== # 登录相关 @@ -259,10 +535,14 @@ def get_tenant_list_with_letter(self) -> dict[str, Any]: } """ url = f"{self.baseurl}/pharos/login/getTenantListWithLetter.do" - response = self.session.post( - url, params={"timestamp": self.get_timestamp()}, timeout=self.timeout + response = self._request( + "POST", + url, + retryable=True, + params={"timestamp": self.get_timestamp()}, + timeout=self.timeout, ) - return handle_response(response) + return self._handle(response, url) def get_tenant_config(self, tenant_code: str | None = None) -> dict[str, Any]: """ @@ -290,10 +570,15 @@ def get_tenant_config(self, tenant_code: str | None = None) -> dict[str, Any]: url = f"{self.baseurl}/pharos/login/getTenantConfig.do" params = {"timestamp": self.get_timestamp()} data = {"tenantCode": tenant_code or self.tenant_code} - response = self.session.post( - url, params=params, data=data, timeout=self.timeout + response = self._request( + "POST", + url, + retryable=True, + params=params, + data=data, + timeout=self.timeout, ) - return handle_response(response) + return self._handle(response, url) def get_simple_config(self, tenant_code: str | None = None) -> dict[str, Any]: """ @@ -304,6 +589,7 @@ def get_simple_config(self, tenant_code: str | None = None) -> dict[str, Any]: return self._post( "/pharos/tenantconfig/getSimpleConfig.do", {"tenantCode": tenant_code or self.tenant_code}, + retryable=True, ) def get_help(self, tenant_code: str | None = None) -> dict[str, Any]: @@ -319,7 +605,9 @@ def get_help(self, tenant_code: str | None = None) -> dict[str, Any]: } """ return self._post( - "/pharos/login/getHelp.do", {"tenantCode": tenant_code or self.tenant_code} + "/pharos/login/getHelp.do", + {"tenantCode": tenant_code or self.tenant_code}, + retryable=True, ) def rand_letter_image(self, verify_time: str | None) -> bytes: @@ -329,7 +617,15 @@ def rand_letter_image(self, verify_time: str | None) -> bytes: """ url = f"{self.baseurl}/pharos/login/randLetterImage.do" params = {"time": verify_time or self.get_timestamp(frac_len=0)} - response = self.session.get(url, params=params, timeout=self.timeout) + response = self._request( + "GET", + url, + retryable=True, + params=params, + timeout=self.timeout, + ) + if response.status_code != 200: + self._handle(response, url) return response.content def login(self, verify_code: str, verify_time: int | None) -> dict[str, Any]: @@ -384,13 +680,15 @@ def login(self, verify_code: str, verify_time: int | None) -> dict[str, Any]: "verifyCode": verify_code, } encrypted = self.encrypt(json.dumps(payload, separators=(",", ":"))) - response = self.session.post( - f"{self.baseurl}/pharos/login/login.do", + url = f"{self.baseurl}/pharos/login/login.do" + response = self._request( + "POST", + url, params={"timestamp": self.get_timestamp()}, data={"data": encrypted}, timeout=self.timeout, ) - result = handle_response(response) + result = self._handle(response, url) if result.get("data", {}).get("token"): self.user = result["data"] self.session.headers["X-Token"] = self.user["token"] @@ -443,7 +741,7 @@ def list_completion(self) -> dict[str, Any]: "detailCode": "0" } """ - return self._post("/pharos/index/listCompletion.do") + return self._post("/pharos/index/listCompletion.do", retryable=True) def lab_index(self) -> dict[str, Any]: """ @@ -480,7 +778,11 @@ def lab_index(self) -> dict[str, Any]: } """ # 该端点要求 timestamp 带 1 位小数(如 1234567890.1) - return self._post("/pharos/lab/index.do", timestamp_args=(10, 1)) + return self._post( + "/pharos/lab/index.do", + timestamp_args=(10, 1), + retryable=True, + ) def list_study_task(self) -> dict[str, Any]: """获取学习任务列表 @@ -519,7 +821,7 @@ def list_study_task(self) -> dict[str, Any]: "detailCode": "0" } """ - return self._post("/pharos/index/listStudyTask.do") + return self._post("/pharos/index/listStudyTask.do", retryable=True) def list_my_project(self, ended: int = 2) -> dict[str, Any]: """ @@ -550,7 +852,11 @@ def list_my_project(self, ended: int = 2) -> dict[str, Any]: "detailCode": "0" } """ - return self._post("/pharos/index/listMyProject.do", {"ended": ended}) + return self._post( + "/pharos/index/listMyProject.do", + {"ended": ended}, + retryable=True, + ) def show_progress(self, user_project_id: str) -> dict[str, Any]: """获取学习任务进度 @@ -585,7 +891,9 @@ def show_progress(self, user_project_id: str) -> dict[str, Any]: } """ return self._post( - "/pharos/project/showProgress.do", {"userProjectId": user_project_id} + "/pharos/project/showProgress.do", + {"userProjectId": user_project_id}, + retryable=True, ) def list_valve(self) -> dict[str, Any]: @@ -595,6 +903,7 @@ def list_valve(self) -> dict[str, Any]: return self._post( "/pharos/index/listValve.do", {"tenantCode": self.tenant_code, "userId": self.user.get("userId", "")}, + retryable=True, ) def get_next_task(self, user_project_id: str) -> dict[str, Any]: @@ -603,7 +912,9 @@ def get_next_task(self, user_project_id: str) -> dict[str, Any]: :return: 下一步状态 dict """ return self._post( - "/pharos/project/getNextTask.do", {"userProjectId": user_project_id} + "/pharos/project/getNextTask.do", + {"userProjectId": user_project_id}, + retryable=True, ) def get_project_simple(self, user_project_id: str) -> dict[str, Any]: @@ -612,7 +923,9 @@ def get_project_simple(self, user_project_id: str) -> dict[str, Any]: :return: 项目基础信息 dict """ return self._post( - "/pharos/project/getSimple.do", {"userProjectId": user_project_id} + "/pharos/project/getSimple.do", + {"userProjectId": user_project_id}, + retryable=True, ) # ---- H5 首页初始化(对齐官方页面登录后请求面) ------------------------- @@ -622,34 +935,36 @@ def carousel_list(self) -> dict[str, Any]: (官方:POST /carousel/list.do {"tenantCode"}) :return: 轮播列表 dict """ - return self._post("/pharos/carousel/list.do") + return self._post("/pharos/carousel/list.do", retryable=True) def get_project_stat(self) -> dict[str, Any]: """首页项目统计 (官方:POST /index/getProjectStat.do {}) :return: 项目统计 dict """ - return self._post("/pharos/index/getProjectStat.do") + return self._post("/pharos/index/getProjectStat.do", retryable=True) def notice_index(self) -> dict[str, Any]: """公告红点状态(官方:POST /notice/index.do {"userId","tenantCode"}) :return: 公告状态 dict """ - return self._post("/pharos/notice/index.do") + return self._post("/pharos/notice/index.do", retryable=True) def notice_list(self) -> dict[str, Any]: """公告列表第一页(官方:POST /notice/list.do 带 pageNo/pageSize) :return: 公告列表 dict """ return self._post( - "/pharos/notice/list.do", {"pageNo": 1, "pageSize": 10} + "/pharos/notice/list.do", + {"pageNo": 1, "pageSize": 10}, + retryable=True, ) def my_get_info(self) -> dict[str, Any]: """用户信息(官方:POST /my/getInfo.do {"userId","tenantCode"}) :return: 用户信息 dict """ - return self._post("/pharos/my/getInfo.do") + return self._post("/pharos/my/getInfo.do", retryable=True) def notice_list_must(self, batch_code: str = "") -> dict[str, Any]: """必读公告列表(官方:POST /notice/listMust.do {"batchCode"}, @@ -658,7 +973,9 @@ def notice_list_must(self, batch_code: str = "") -> dict[str, Any]: :return: 必读公告列表 dict,data 为公告数组 """ return self._post( - "/pharos/notice/listMust.do", {"batchCode": batch_code or ""} + "/pharos/notice/listMust.do", + {"batchCode": batch_code or ""}, + retryable=True, ) def view_must_notice(self, notice_id: str | int) -> dict[str, Any]: @@ -667,9 +984,7 @@ def view_must_notice(self, notice_id: str | int) -> dict[str, Any]: :param notice_id: 公告 ID :return: 确认结果 dict """ - return self._post( - "/pharos/notice/viewMust.do", {"noticeId": str(notice_id)} - ) + return self._post("/pharos/notice/viewMust.do", {"noticeId": str(notice_id)}) def questionnaire_list_by_user_id(self) -> dict[str, Any]: """按用户查问卷列表(官方:POST /questionnaire/listByUserId.do @@ -679,6 +994,7 @@ def questionnaire_list_by_user_id(self) -> dict[str, Any]: return self._post( "/pharos/questionnaire/listByUserId.do", {"batchCode": self.user.get("batchCode", "")}, + retryable=True, ) def get_ebook(self) -> dict[str, Any]: @@ -686,14 +1002,14 @@ def get_ebook(self) -> dict[str, Any]: {"userId","tenantCode"},首页协议弹窗数据源) :return: 协议内容 dict """ - return self._post("/pharos/login/getEbook.do") + return self._post("/pharos/login/getEbook.do", retryable=True) def ebook_record_list(self) -> dict[str, Any]: """协议阅读记录(官方:POST /record/ebook/list.do {"userId","tenantCode"},判断协议是否已读) :return: 阅读记录 dict """ - return self._post("/pharos/record/ebook/list.do") + return self._post("/pharos/record/ebook/list.do", retryable=True) # ======================================================================== # 课程 @@ -723,6 +1039,7 @@ def list_category( return self._post( "/pharos/usercourse/listCategory.do", {"userProjectId": user_project_id, "chooseType": choose_type}, + retryable=True, ) def list_course( @@ -760,6 +1077,7 @@ def list_course( "chooseType": choose_type, "categoryCode": category_code, }, + retryable=True, ) def list_flat_course( @@ -795,6 +1113,7 @@ def list_flat_course( "pageSize": page_size, "pageNo": page_no, }, + retryable=True, ) def init_index(self, user_project_id: str) -> dict[str, Any]: @@ -836,6 +1155,7 @@ def get_course_url(self, course_id: str, user_project_id: str) -> dict[str, Any] return self._post( "/pharos/usercourse/getCourseUrl.do", {"courseId": course_id, "userProjectId": user_project_id}, + retryable=True, ) def invoke_captcha( @@ -858,9 +1178,9 @@ def invoke_captcha( "userId": self.user["userId"], "tenantCode": self.tenant_code, } - response = self.session.get(fetch_url, params=params, timeout=self.timeout) + response = self._request("GET", fetch_url, params=params, timeout=self.timeout) params["questionId"] = ( - handle_response(response).get("captcha", {}).get("questionId", "") + self._handle(response, fetch_url).get("captcha", {}).get("questionId", "") ) # 三组固定基准坐标 + 随机 ±5px 抖动,服务端容差校验 coords = [ @@ -869,10 +1189,14 @@ def invoke_captcha( ] data = {"coordinateXYs": json.dumps(coords, separators=(",", ":"))} time.sleep(3) - response = self.session.post( - check_url, params=params, data=data, timeout=self.timeout + response = self._request( + "POST", + check_url, + params=params, + data=data, + timeout=self.timeout, ) - return handle_response(response) + return self._handle(response, check_url) def finish_by_token( self, @@ -914,22 +1238,25 @@ def finish_by_token( "Referer": referer or "https://mcwk.mycourse.cn/", "X-Token": None, } - response = self.session.get( + response = self._request( + "GET", url, params={**data, "callback": cb, "_": ts + 1}, headers=headers, timeout=self.timeout, ) else: - response = self.session.post(url, data=data, timeout=self.timeout) + response = self._request("POST", url, data=data, timeout=self.timeout) if response.status_code == 701 or "Account locked" in response.text: - return { - "code": "-1", - "detailCode": "701", - "msg": "Account locked", - "raw": response.text, - } + raise AccountBlockedError( + "Account locked", + detail_code="701", + status_code=response.status_code, + endpoint=url, + ) + if response.status_code != 200: + return self._handle(response, url) try: result = response.json() @@ -941,7 +1268,24 @@ def finish_by_token( try: result = json.loads(text) except json.JSONDecodeError: - return {"raw": response.text} + raise APIResponseError( + "完课响应不是有效的 JSON/JSONP", + status_code=response.status_code, + endpoint=_sanitize_url(url), + summary=_response_summary(response), + ) from None + if not isinstance(result, dict): + raise APIResponseError( + "完课响应 JSON 顶层不是对象", + status_code=response.status_code, + endpoint=_sanitize_url(url), + summary=_redact_text(str(result)), + ) + _raise_if_account_blocked( + result, + endpoint=_sanitize_url(url), + status_code=response.status_code, + ) return result def finish_lyra(self, user_activity_id: str) -> dict[str, Any]: @@ -950,12 +1294,14 @@ def finish_lyra(self, user_activity_id: str) -> dict[str, Any]: :return: 完成结果 dict {"msg":"ok","code":"0","detailCode":"0"} """ - response = self.session.post( - "https://lyra.mycourse.cn/lyraapi/study/course/finish.api", + url = "https://lyra.mycourse.cn/lyraapi/study/course/finish.api" + response = self._request( + "POST", + url, data={"userActivityId": user_activity_id}, timeout=self.timeout, ) - return handle_response(response) + return self._handle(response, url) # ======================================================================== # 考试 @@ -992,7 +1338,9 @@ def exam_list_plan(self, user_project_id: str) -> dict[str, Any]: } """ return self._post( - "/pharos/exam/listPlan.do", {"userProjectId": user_project_id} + "/pharos/exam/listPlan.do", + {"userProjectId": user_project_id}, + retryable=True, ) def exam_before_paper(self, user_exam_plan_id: str) -> dict[str, Any]: @@ -1008,7 +1356,9 @@ def exam_before_paper(self, user_exam_plan_id: str) -> dict[str, Any]: } """ return self._post( - "/pharos/exam/beforePaper.do", {"userExamPlanId": user_exam_plan_id} + "/pharos/exam/beforePaper.do", + {"userExamPlanId": user_exam_plan_id}, + retryable=True, ) def exam_prepare_paper(self, user_exam_plan_id: str) -> dict[str, Any]: @@ -1028,7 +1378,9 @@ def exam_prepare_paper(self, user_exam_plan_id: str) -> dict[str, Any]: } """ return self._post( - "/pharos/exam/preparePaper.do", {"userExamPlanId": user_exam_plan_id} + "/pharos/exam/preparePaper.do", + {"userExamPlanId": user_exam_plan_id}, + retryable=True, ) def exam_check( @@ -1083,10 +1435,14 @@ def course_check( "Referer": "https://mcwk.mycourse.cn/", "X-Token": None, } - response = self.session.post( - url, data=data, headers=headers, timeout=self.timeout + response = self._request( + "POST", + url, + data=data, + headers=headers, + timeout=self.timeout, ) - return handle_response(response) + return self._handle(response, url) def exam_check_verify_code( self, user_exam_plan_id: str, verfy_code: str, verify_time: int | None @@ -1319,6 +1675,7 @@ def exam_review_paper( return self._post( "/pharos/exam/reviewPaper.do", {"userExamId": user_exam_id, "isRetake": is_retake}, + retryable=True, ) def exam_list_history(self, exam_plan_id: str, exam_type: int) -> dict[str, Any]: @@ -1346,6 +1703,7 @@ def exam_list_history(self, exam_plan_id: str, exam_type: int) -> dict[str, Any] return self._post( "/pharos/exam/listHistory.do", {"examPlanId": exam_plan_id, "examType": exam_type}, + retryable=True, ) # ======================================================================== @@ -1367,7 +1725,12 @@ def download_answer(self) -> str: } } """ - resp = self.session.get(self.ANSWER_URL, timeout=self.timeout) + resp = self._request( + "GET", + self.ANSWER_URL, + retryable=True, + timeout=self.timeout, + ) resp.raise_for_status() return resp.text @@ -1421,12 +1784,14 @@ def apinext( # 双重 Base64:仿 JS 前端 CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(Base64(ciphertext))) # 后端先做 atob 再 AES-CBC 解密,因此需要两次编码 encrypted_b64 = b64encode(b64encode(encrypted)).decode() - response = self.session.post( - f"{self.baseurl}/jupiterapi/api/statusercourse/v1/next", + url = f"{self.baseurl}/jupiterapi/api/statusercourse/v1/next" + response = self._request( + "POST", + url, json={"data": encrypted_b64}, timeout=self.timeout, ) - return handle_response(response) + return self._handle(response, url) def list_question(self, course_id: str) -> dict[str, Any]: """获取课后习题列表(course_id 为 resourceId UUID) @@ -1468,7 +1833,8 @@ def list_question(self, course_id: str) -> dict[str, Any]: } """ return self._mercury_request( - {"service": "mercury.microlecture.listQuestion", "id": course_id} + {"service": "mercury.microlecture.listQuestion", "id": course_id}, + retryable=True, ) def save_question( diff --git a/captcha.py b/captcha.py index f0003c4..4705d1a 100644 --- a/captcha.py +++ b/captcha.py @@ -16,11 +16,16 @@ import platform import random import shutil +import socket import sys +import tempfile import threading import time +from collections.abc import AsyncIterator, Awaitable, Iterable +from contextlib import asynccontextmanager, suppress from pathlib import Path from typing import Any, ClassVar +from urllib.parse import urlsplit import cv2 import nodriver @@ -72,8 +77,6 @@ def _dsv_to_py(dsv): return dsv - - # 腾讯验证码 SDK 地址 TCAPTCHA_SDK_URL = "https://turing.captcha.qcloud.com/TJCaptcha.js" @@ -85,6 +88,97 @@ def _dsv_to_py(dsv): EXAM_ENTRY_URL = "https://weiban.mycourse.cn/#/course" COURSE_ENTRY_URL = "https://mcwk.mycourse.cn/" +# 所有浏览器/CDP 操作都必须有上限,避免远端端口“能连但不响应”时永久挂起。 +CDP_HEALTH_TIMEOUT = 5.0 +CDP_DISCOVERY_TIMEOUT = 2.0 +CDP_CALL_TIMEOUT = 15.0 +BROWSER_START_TIMEOUT = 30.0 +PAGE_LOAD_TIMEOUT = 30.0 +SDK_LOAD_TIMEOUT = 12.0 +IMAGE_WORK_TIMEOUT = 120.0 +CLOSE_TIMEOUT = 10.0 +ENDPOINT_LOCK_TIMEOUT = 960.0 +EXAM_FLOW_TIMEOUT = 180.0 +COURSE_FLOW_TIMEOUT = 900.0 + +_NODRIVER_START_LOCK = threading.Lock() + + +def _env_positive_int(name: str, default: int, *, maximum: int = 50) -> int: + """读取可调重试次数;非法或越界值回退默认,避免验证码流程因配置抛错。""" + raw = os.environ.get(name, "") + try: + value = int(raw.strip()) + except (TypeError, ValueError): + return default + if value < 1: + return default + return min(value, maximum) + + +def _close_step_timeout() -> float: + """为关闭流程各步骤分配总预算中的一小段。""" + return max(min(CLOSE_TIMEOUT / 6, 2.0), 0.01) + + +async def _bounded[T]( + awaitable: Awaitable[T], + *, + timeout: float, + label: str, + stop_event: threading.Event | None = None, +) -> T: + """执行有硬超时的异步操作,并允许共享停止事件立即取消。""" + task = asyncio.ensure_future(awaitable) + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + try: + while True: + if stop_event is not None and stop_event.is_set(): + raise InterruptedError("运行已被中断") + remaining = deadline - loop.time() + if remaining <= 0: + raise TimeoutError + poll_interval = min(0.1, remaining) if stop_event is not None else remaining + done, _ = await asyncio.wait({task}, timeout=poll_interval) + if task in done: + return await task + except TimeoutError as exc: + raise RuntimeError(f"{label}超时({timeout:g} 秒)") from exc + finally: + if not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + await task + + +@asynccontextmanager +async def _async_thread_lock( + lock: Any, + *, + timeout: float, + label: str, + stop_event: threading.Event | None = None, +) -> AsyncIterator[None]: + """不阻塞事件循环地获取跨线程锁,取消时不会遗留已获取的锁。""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + acquired = False + while not acquired: + if stop_event is not None and stop_event.is_set(): + raise InterruptedError("运行已被中断") + acquired = lock.acquire(blocking=False) + if acquired: + break + if loop.time() >= deadline: + raise RuntimeError(f"{label}等待超时({timeout:g} 秒)") + await asyncio.sleep(0.05) + try: + yield + finally: + lock.release() + + # ── JS 片段(自动识别用)────────────────────────────── _SHOW_JS = r""" @@ -176,7 +270,9 @@ def rotate_mask(mask: np.ndarray, angle: float) -> np.ndarray: h, w = mask.shape center = (w / 2, h / 2) matrix = cv2.getRotationMatrix2D(center, angle, 1.0) - return cv2.warpAffine(mask, matrix, (w, h), flags=cv2.INTER_NEAREST, borderValue=(0,)) + return cv2.warpAffine( + mask, matrix, (w, h), flags=cv2.INTER_NEAREST, borderValue=(0,) + ) def crop_foreground(mask: np.ndarray) -> np.ndarray | None: @@ -519,6 +615,43 @@ def render_debug( return vis +def _write_png_atomic(path: Path, image: np.ndarray) -> None: + """写入调试图片并保证失败时不遗留半成品临时文件。""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.stem}-", + suffix=path.suffix, + ) + os.close(fd) + temp_path = Path(temp_name) + try: + encoded_ok, encoded = cv2.imencode(".png", image) + if not encoded_ok: + raise OSError(f"无法写入验证码调试图片: {path}") + temp_path.write_bytes(encoded.tobytes()) + os.replace(temp_path, path) + finally: + temp_path.unlink(missing_ok=True) + + +def _save_debug_images( + debug_dir: Path, + stem: str, + main_img: np.ndarray, + prompt_img: np.ndarray, + ordered_points: list[tuple[int, int] | None], + candidates: list[dict], +) -> None: + """在线程中生成并原子保存一组验证码调试图片。""" + _write_png_atomic(debug_dir / f"{stem}_main.png", main_img) + _write_png_atomic(debug_dir / f"{stem}_prompt.png", prompt_img) + _write_png_atomic( + debug_dir / f"{stem}_debug.png", + render_debug(main_img, ordered_points, candidates), + ) + + def fetch_image(url: str) -> np.ndarray: """通过 HTTP 下载验证码图片并解码为 BGR 数组。 @@ -561,7 +694,9 @@ class LoginCaptchaSolver: _initialized: bool = False _lock = threading.Lock() _charset = "0123456789abcdefghijklmnopqrstuvwxyz" - _idx_to_char: ClassVar[dict[int, str]] = {i: c for c, i in {c: i for i, c in enumerate(_charset)}.items()} + _idx_to_char: ClassVar[dict[int, str]] = { + i: c for c, i in {c: i for i, c in enumerate(_charset)}.items() + } _char_size = 28 @classmethod @@ -581,13 +716,25 @@ def get_ocr(cls, log): model_path = Path(__file__).parent / "captcha_model.onnx" if not model_path.exists(): - log.warning(f"验证码模型文件不存在: {model_path}") + log.warning( + f"验证码模型文件不存在: {model_path}。" + "登录验证码自动识别已禁用;交互模式可人工输入," + "无交互模式将安全失败,不会猜测验证码" + ) cls._ocr = False else: - # OpenCV 失败只会抛 cv2.error(模型文件已提前检查存在) - cls._ocr = cv2.dnn.readNetFromONNX(str(model_path)) - except cv2.error: - log.warning("OpenCV DNN 初始化失败,自动验证码识别功能将不可用") + # Windows 下 OpenCV 的文件接口不能可靠处理中文路径。 + # 由 Python 读取字节,兼容源码目录和冻结程序解包目录。 + model_buffer = np.frombuffer( + model_path.read_bytes(), dtype=np.uint8 + ) + cls._ocr = cv2.dnn.readNetFromONNX(model_buffer) + except Exception as exc: # noqa: BLE001 -- 可选模型损坏必须安全降级 + log.warning( + "验证码模型加载失败,登录验证码自动识别已禁用;" + "交互模式可人工输入,无交互模式将安全失败" + f"({type(exc).__name__})" + ) cls._ocr = False cls._initialized = True return cls._ocr if cls._ocr is not False else None @@ -673,10 +820,22 @@ def _registry_candidates() -> list[str]: # Chrome/Edge 通过 App Paths 注册 # winreg 仅 Windows 存在,typeshed 按平台裁剪导致 Darwin 下属性不可见 for hive, subkey in [ - (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe"), # type: ignore[attr-defined] - (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe"), # type: ignore[attr-defined] - (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe"), # type: ignore[attr-defined] - (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe"), # type: ignore[attr-defined] + ( + winreg.HKEY_CURRENT_USER, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe", + ), # type: ignore[attr-defined] + ( + winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe", + ), # type: ignore[attr-defined] + ( + winreg.HKEY_CURRENT_USER, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe", + ), # type: ignore[attr-defined] + ( + winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe", + ), # type: ignore[attr-defined] ]: try: with winreg.OpenKey(hive, subkey) as key: # type: ignore[attr-defined] @@ -705,16 +864,36 @@ def detect_browser() -> str | None: if system == "Darwin": _apps = "/Applications" - _chrome = ["Google Chrome", "Google Chrome Beta", "Google Chrome Dev", "Google Chrome Canary"] + _chrome = [ + "Google Chrome", + "Google Chrome Beta", + "Google Chrome Dev", + "Google Chrome Canary", + ] _chromium = ["Chromium"] - _edge = ["Microsoft Edge", "Microsoft Edge Beta", "Microsoft Edge Dev", "Microsoft Edge Canary"] + _edge = [ + "Microsoft Edge", + "Microsoft Edge Beta", + "Microsoft Edge Dev", + "Microsoft Edge Canary", + ] for name in _chrome + _chromium + _edge: candidates.append(f"{_apps}/{name}.app/Contents/MacOS/{name}") elif system == "Linux": - _chrome = ["google-chrome", "google-chrome-stable", "google-chrome-beta", "google-chrome-unstable"] + _chrome = [ + "google-chrome", + "google-chrome-stable", + "google-chrome-beta", + "google-chrome-unstable", + ] _chromium = ["chromium", "chromium-browser"] - _edge = ["microsoft-edge-stable", "microsoft-edge-beta", "microsoft-edge-dev", "microsoft-edge"] + _edge = [ + "microsoft-edge-stable", + "microsoft-edge-beta", + "microsoft-edge-dev", + "microsoft-edge", + ] for name in _chrome + _chromium + _edge: candidates.append(f"/usr/bin/{name}") candidates.append(f"/snap/bin/{name}") @@ -737,7 +916,12 @@ def detect_browser() -> str | None: pf86 = os.environ.get("PROGRAMFILES(X86)", "") _chrome = ["Chrome", "Chrome Beta", "Chrome Dev", "Chrome SxS"] # SxS = Canary _chromium = ["Chromium"] - _edge = ["Microsoft/Edge", "Microsoft/Edge Beta", "Microsoft/Edge Dev", "Microsoft/Edge SxS"] + _edge = [ + "Microsoft/Edge", + "Microsoft/Edge Beta", + "Microsoft/Edge Dev", + "Microsoft/Edge SxS", + ] for base in dict.fromkeys((local, pf64, pf, pf86)): if not base: continue @@ -752,10 +936,20 @@ def detect_browser() -> str | None: # PATH 查找(Windows 下还会匹配 chrome.exe / msedge.exe 等) for name in ( - "google-chrome", "google-chrome-stable", "google-chrome-beta", "google-chrome-unstable", - "chromium", "chromium-browser", - "microsoft-edge-stable", "microsoft-edge-beta", "microsoft-edge-dev", "microsoft-edge", - "chrome", "chrome.exe", "msedge", "msedge.exe", + "google-chrome", + "google-chrome-stable", + "google-chrome-beta", + "google-chrome-unstable", + "chromium", + "chromium-browser", + "microsoft-edge-stable", + "microsoft-edge-beta", + "microsoft-edge-dev", + "microsoft-edge", + "chrome", + "chrome.exe", + "msedge", + "msedge.exe", ): found = shutil.which(name) if found: @@ -764,44 +958,181 @@ def detect_browser() -> str | None: return None -async def kill_stray_browsers() -> None: - """关闭 nodriver 残留实例,并在事件循环关闭前回收子进程 transport。""" - from nodriver.core import util as _nd_util +def _validated_browser_options( + browser_path: str | None, + cdp_host: str | None, + cdp_port: int | None, +) -> tuple[str | None, str | None, int | None]: + """规范化浏览器参数;配置不完整时禁止静默回退。""" + path = os.fspath(browser_path).strip() if browser_path is not None else None + path = path or None + host = str(cdp_host).strip() if cdp_host is not None else None + host = host or None + + if (host is None) != (cdp_port is None): + raise RuntimeError("CDP 配置不完整:cdp_host 和 cdp_port 必须同时提供") + if cdp_port is not None: + if isinstance(cdp_port, bool) or not isinstance(cdp_port, int): + raise RuntimeError("CDP 端口必须是 1 到 65535 的整数") + if not 1 <= cdp_port <= 65535: + raise RuntimeError("CDP 端口必须在 1 到 65535 之间") + if host is not None and ( + "://" in host or "/" in host or any(char.isspace() for char in host) + ): + raise RuntimeError("cdp_host 只能填写主机名或 IP,不应包含协议、路径或空格") + if host is not None and ":" in host and not host.startswith("["): + host = f"[{host}]" + + # CDP 是显式的浏览器来源;此时不应因为遗留的 browser_path 失效而 + # 阻止连接既有浏览器。调用方仍保留规范化后的 CDP 主机和端口。 + if host is not None and cdp_port is not None: + return None, host, cdp_port + + if path is not None: + expanded = Path(os.path.expandvars(path)).expanduser() + if not expanded.is_file(): + raise RuntimeError(f"显式指定的浏览器可执行文件不存在: {expanded}") + if os.name != "nt" and not os.access(expanded, os.X_OK): + raise RuntimeError(f"显式指定的浏览器文件不可执行: {expanded}") + path = str(expanded.resolve()) + + return path, host, cdp_port + + +def _cdp_http_host(host: str) -> str: + """为 HTTP URL 格式化主机名(兼容 IPv6 字面量)。""" + if ":" in host and not host.startswith("["): + return f"[{host}]" + return host + + +def _origin_from_url(url: str) -> str: + """提取 RFC origin,禁止把课程路径或 fragment 当成 origin。""" + parsed = urlsplit(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise RuntimeError(f"验证码入口 URL 无效: {url!r}") + if parsed.username or parsed.password: + raise RuntimeError("验证码入口 URL 不应包含用户名或密码") + return f"{parsed.scheme}://{parsed.netloc}" + + +def _check_cdp_health( + host: str, + port: int, + *, + timeout: float = CDP_HEALTH_TIMEOUT, +) -> None: + """请求标准 CDP 版本端点,拒绝普通 HTTP 服务冒充调试端口。""" + url = f"http://{_cdp_http_host(host)}:{port}/json/version" + try: + response = requests.get(url, timeout=timeout) + response.raise_for_status() + payload = response.json() + except (requests.RequestException, ValueError, TypeError) as exc: + raise RuntimeError( + f"CDP 健康检查失败 ({host}:{port}/json/version): {exc}" + ) from exc + + if ( + not isinstance(payload, dict) + or not isinstance(payload.get("Browser"), str) + or not payload["Browser"].strip() + ): + raise RuntimeError( + f"CDP 健康检查失败 ({host}:{port}/json/version): 响应缺少 Browser 字段" + ) + websocket_url = payload.get("webSocketDebuggerUrl") + if websocket_url is not None and not str(websocket_url).startswith( + ("ws://", "wss://") + ): + raise RuntimeError( + f"CDP 健康检查失败 ({host}:{port}/json/version): webSocketDebuggerUrl 无效" + ) - instances = list(_nd_util.get_registered_instances()) - for inst in instances: - proc = getattr(inst, "_process", None) + +def _detect_default_cdp_endpoint() -> tuple[str, int] | None: + """快速探测历史默认 CDP 端点,供验证码首次使用时懒发现。""" + candidates = ( + ("127.0.0.1", 9222), + ("127.0.0.1", 9223), + ("host.docker.internal", 9222), + ("host.docker.internal", 9223), + ) + for host, port in candidates: try: - await asyncio.wait_for(inst.aclose(), timeout=5) - except (Exception, asyncio.CancelledError): # noqa: BLE001 + with socket.create_connection((host, port), timeout=0.5): + pass + _check_cdp_health(host, port, timeout=CDP_DISCOVERY_TIMEOUT) + return host, port + except (OSError, RuntimeError): + continue + return None + + +async def _remove_temporary_profile(instance: Any) -> None: + config = getattr(instance, "config", None) + if config is None or getattr(config, "uses_custom_data_dir", True): + return + user_data_dir = getattr(config, "user_data_dir", None) + if not user_data_dir: + return + try: + await _bounded( + asyncio.to_thread(shutil.rmtree, user_data_dir, ignore_errors=True), + timeout=_close_step_timeout(), + label="清理浏览器临时目录", + ) + except RuntimeError: + # 删除目录失败不应阻止 unregister;系统临时目录后续仍可自行回收。 + pass + + +async def _close_browser_instance(instance: Any) -> None: + """只清理传入实例,不触碰其他账号或调用方注册的浏览器。""" + from nodriver.core import util as _nd_util + + proc = getattr(instance, "_process", None) + try: + with suppress(Exception): + await _bounded( + instance.aclose(), + timeout=_close_step_timeout(), + label="关闭浏览器连接", + ) + if proc is not None: + if getattr(proc, "returncode", None) is None: + with suppress(ProcessLookupError, OSError): + proc.kill() + with suppress(Exception): + await _bounded( + proc.wait(), + timeout=_close_step_timeout(), + label="等待浏览器进程退出", + ) + finally: + # Process.wait() 不保证 stdout/stderr pipe transport 已关闭。 + transport = getattr(proc, "_transport", None) + if transport is not None: + with suppress(Exception): + transport.close() + _nd_util.get_registered_instances().discard(instance) + await _remove_temporary_profile(instance) + for _ in range(3): await asyncio.sleep(0) - try: - if proc is not None: - if proc.returncode is None: - try: - proc.kill() - except (ProcessLookupError, OSError): - await asyncio.sleep(0) - try: - await asyncio.wait_for(proc.wait(), timeout=5) - except (Exception, asyncio.CancelledError): # noqa: BLE001 - await asyncio.sleep(0) - finally: - # Process.wait() 只等待子进程退出,不保证 stdout/stderr pipe - # transport 已关闭;显式关闭它,避免 asyncio.run() 收尾后由 - # BaseSubprocessTransport.__del__ 在已关闭 loop 上补清理。 - transport = getattr(proc, "_transport", None) - if transport is not None: - try: - transport.close() - except (Exception, asyncio.CancelledError): # noqa: BLE001 - await asyncio.sleep(0) - config = getattr(inst, "config", None) - if config is not None and not config.uses_custom_data_dir: - shutil.rmtree(config.user_data_dir, ignore_errors=True) - for _ in range(3): - await asyncio.sleep(0) - _nd_util.get_registered_instances().difference_update(instances) + + +async def kill_stray_browsers(instances: Iterable[Any] | None = None) -> None: + """关闭指定 nodriver 实例。 + + ``instances`` 留空保留旧接口语义;验证码内部始终显式传入本次创建的实例, + 避免并行账号之间互相关闭浏览器。 + """ + if instances is None: + from nodriver.core import util as _nd_util + + instances = tuple(_nd_util.get_registered_instances()) + for instance in tuple(instances): + await _close_browser_instance(instance) def check_browser_health( @@ -817,10 +1148,14 @@ def check_browser_health( :return: 浏览器路径或 CDP 地址 :raises RuntimeError: 无可用浏览器时 """ - if cdp_host and cdp_port: - return f"{cdp_host}:{cdp_port}" + browser_path, cdp_host, cdp_port = _validated_browser_options( + browser_path, cdp_host, cdp_port + ) + if cdp_host is not None and cdp_port is not None: + _check_cdp_health(cdp_host, cdp_port) + return f"{_cdp_http_host(cdp_host)}:{cdp_port}" - if browser_path and os.path.isfile(browser_path): + if browser_path is not None: resolved = browser_path else: resolved = detect_browser() @@ -833,33 +1168,49 @@ def check_browser_health( " 4. 安装 Chrome、Chromium 或 Edge" ) - # 健康探测也必须在同一个事件循环内完成浏览器进程和 pipe transport - # 的清理;nodriver.start() 连接失败时可能已启动但未返回 Browser。 async def _probe() -> None: - try: - browser = await nodriver.start( - headless=True, - browser_executable_path=resolved, - ) - await browser.get("data:text/html,

ok

") - finally: - await kill_stray_browsers() + from nodriver.core import util as _nd_util + + # nodriver 启动失败时不会返回 Browser,只能通过注册表差集定位残留。 + # 串行化“快照→启动→差集清理”,确保差集只属于本次探测。 + async with _async_thread_lock( + _NODRIVER_START_LOCK, + timeout=ENDPOINT_LOCK_TIMEOUT, + label="浏览器启动", + ): + before = set(_nd_util.get_registered_instances()) + browser = None + try: + browser = await _bounded( + nodriver.start( + headless=True, + browser_executable_path=resolved, + ), + timeout=BROWSER_START_TIMEOUT, + label="浏览器启动", + ) + await _bounded( + browser.get("data:text/html,

ok

"), + timeout=PAGE_LOAD_TIMEOUT, + label="浏览器探测页加载", + ) + finally: + owned = set(_nd_util.get_registered_instances()) - before + if browser is not None: + owned.add(browser) + await kill_stray_browsers(owned) last_exc: Exception | None = None for attempt in range(1, 4): try: - asyncio.run(asyncio.wait_for(_probe(), timeout=20)) + asyncio.run(_probe()) return resolved except FileNotFoundError as e: raise RuntimeError(f"浏览器可执行文件不存在: {resolved}") from e - except TimeoutError as e: - last_exc = e except Exception as e: # noqa: BLE001 -- 探测任何启动失败都要重试 last_exc = e if attempt < 3: time.sleep(1) - if isinstance(last_exc, TimeoutError): - raise RuntimeError(f"浏览器启动超时: {resolved}") from last_exc # noqa: TRY004 raise RuntimeError(f"浏览器启动失败: {last_exc}") from last_exc @@ -869,6 +1220,9 @@ async def _probe() -> None: class CaptchaHandler: """通过浏览器处理腾讯验证码""" + _endpoint_locks: ClassVar[dict[str, Any]] = {} + _endpoint_locks_guard: ClassVar[Any] = threading.Lock() + def __init__( self, tenant_code: str, @@ -879,6 +1233,8 @@ def __init__( cdp_host: str | None = None, cdp_port: int | None = None, debug_dir: Path | None = None, + non_interactive: bool | None = None, + stop_event: threading.Event | None = None, ) -> None: """初始化验证码处理器。 @@ -890,7 +1246,12 @@ def __init__( :param cdp_host: CDP 远程调试地址,配合 cdp_port 使用时不启动本地浏览器 :param cdp_port: CDP 远程调试端口,配合 cdp_host 使用时不启动本地浏览器 :param debug_dir: 调试图片保存目录,留空则默认 logs//captcha + :param non_interactive: 是否禁止人工验证码;留空则按当前运行环境判断 + :param stop_event: 进程级停止事件,用于中断浏览器和识别等待 """ + browser_path, cdp_host, cdp_port = _validated_browser_options( + browser_path, cdp_host, cdp_port + ) self._auth = { "userId": user_id, "token": token, @@ -901,13 +1262,147 @@ def __init__( self.cdp_host = cdp_host self.cdp_port = cdp_port self._debug_dir = debug_dir or Path("logs") / user_id / "captcha" + self.non_interactive = ( + is_non_interactive() if non_interactive is None else non_interactive + ) + self.stop_event = stop_event or threading.Event() + self._closed = False + self._browser_ready = False + self._health_lock: Any = threading.Lock() + self._browser_states: dict[int, dict[str, Any]] = {} + + @property + def _endpoint_key(self) -> str | None: + if self.cdp_host is None or self.cdp_port is None: + return None + host = self.cdp_host.casefold() + if host in {"localhost", "127.0.0.1", "[::1]"}: + host = "loopback" + return f"{host}:{self.cdp_port}" + + @asynccontextmanager + async def _exclusive_endpoint(self) -> AsyncIterator[None]: + """同一共享 CDP 端点一次只允许一个验证码流程修改页面状态。""" + self._raise_if_stopped() + endpoint = self._endpoint_key + if endpoint is None: + yield + return + with self._endpoint_locks_guard: + lock = self._endpoint_locks.setdefault(endpoint, threading.Lock()) + async with _async_thread_lock( + lock, + timeout=ENDPOINT_LOCK_TIMEOUT, + label=f"共享 CDP 端点 {endpoint}", + stop_event=self.stop_event, + ): + yield + + def _raise_if_stopped(self) -> None: + if self._closed: + raise RuntimeError("验证码处理器已关闭") + if self.stop_event.is_set(): + raise InterruptedError("运行已被中断") + + async def _sleep(self, seconds: float) -> None: + """异步等待并以较短轮询间隔响应线程停止事件。""" + + self._raise_if_stopped() + loop = asyncio.get_running_loop() + deadline = loop.time() + max(0.0, float(seconds)) + while True: + self._raise_if_stopped() + remaining = deadline - loop.time() + if remaining <= 0: + return + await asyncio.sleep(min(0.1, remaining)) + + async def _bound[T]( + self, + awaitable: Awaitable[T], + *, + timeout: float, + label: str, + ) -> T: + return await _bounded( + awaitable, + timeout=timeout, + label=label, + stop_event=self.stop_event, + ) + + async def _ensure_browser_ready(self) -> None: + """首次真正需要验证码时再探测最终账号配置。""" + self._raise_if_stopped() + if self._browser_ready: + return + async with _async_thread_lock( + self._health_lock, + timeout=ENDPOINT_LOCK_TIMEOUT, + label="浏览器健康检查", + stop_event=self.stop_event, + ): + if self._browser_ready: + return + if ( + self.browser_path is None + and self.cdp_host is None + and self.cdp_port is None + ): + discovered = await self._bound( + asyncio.to_thread(_detect_default_cdp_endpoint), + timeout=4 * (CDP_DISCOVERY_TIMEOUT + 0.5) + 1, + label="默认 CDP 端点探测", + ) + if discovered is not None: + self.cdp_host, self.cdp_port = discovered + self._browser_ready = True + self.log.info( + f"自动探测到 CDP 浏览器 {self.cdp_host}:{self.cdp_port}" + ) + return + timeout = ( + 3 * (BROWSER_START_TIMEOUT + PAGE_LOAD_TIMEOUT + CLOSE_TIMEOUT + 1) + 5 + ) + resolved = await self._bound( + asyncio.to_thread( + check_browser_health, + self.browser_path, + self.cdp_host, + self.cdp_port, + ), + timeout=timeout, + label="浏览器健康检查", + ) + if self.cdp_host is None: + self.browser_path = resolved + self._browser_ready = True # ── 浏览器 / 页面构建 ────────────────────────────── - @staticmethod - async def _eval_json(tab, expression: str) -> dict | None: + async def _evaluate( + self, + tab, + expression: str, + *, + return_by_value: bool = False, + interruptible: bool = True, + ): + if not interruptible: + return await _bounded( + tab.evaluate(expression, return_by_value=return_by_value), + timeout=CDP_CALL_TIMEOUT, + label="CDP 脚本执行", + ) + return await self._bound( + tab.evaluate(expression, return_by_value=return_by_value), + timeout=CDP_CALL_TIMEOUT, + label="CDP 脚本执行", + ) + + async def _eval_json(self, tab, expression: str) -> dict | None: """执行 JS 并将结果转为 Python dict(处理 nodriver 的 RemoteObject 反序列化)。""" - res: Any = await tab.evaluate(expression, return_by_value=True) + res: Any = await self._evaluate(tab, expression, return_by_value=True) if isinstance(res, cdp.runtime.RemoteObject): if ( res.deep_serialized_value @@ -928,11 +1423,10 @@ async def _create_browser(self, headless: bool = False) -> nodriver.Browser: 窗口尺寸 428x818 模拟移动端以匹配腾讯验证码的移动版 UI。 """ - # CDP 模式优先,不需要本地浏览器 - if self.cdp_host and self.cdp_port: - browser_path = self.browser_path or "cdp" - else: - browser_path = self.browser_path or detect_browser() + from nodriver.core import util as _nd_util + + cdp_mode = self.cdp_host is not None and self.cdp_port is not None + browser_path = None if cdp_mode else self.browser_path browser_args = [ "--window-size=428,818", "--mute-audio", @@ -946,31 +1440,48 @@ async def _create_browser(self, headless: bool = False) -> nodriver.Browser: # 非 CDP 模式:nodriver 启动 Chrome 的就绪窗口只有 ~2.75s,冷启动/ # 资源紧张时可能超时("Failed to connect to browser"),重试一次。 # CDP 模式连已有浏览器,失败是配置问题,不重试。 - attempts = 1 if (self.cdp_host and self.cdp_port) else 2 + attempts = 1 if cdp_mode else 2 last_exc: Exception | None = None for attempt in range(1, attempts + 1): try: - return await nodriver.start( - headless=headless, - browser_executable_path=browser_path or None, - browser_args=browser_args, - host=self.cdp_host, - port=self.cdp_port, - ) + async with _async_thread_lock( + _NODRIVER_START_LOCK, + timeout=ENDPOINT_LOCK_TIMEOUT, + label="浏览器启动", + stop_event=self.stop_event, + ): + before = set(_nd_util.get_registered_instances()) + try: + return await self._bound( + nodriver.start( + headless=headless, + browser_executable_path=browser_path, + browser_args=browser_args, + host=self.cdp_host, + port=self.cdp_port, + ), + timeout=BROWSER_START_TIMEOUT, + label="浏览器启动", + ) + except BaseException: + owned = set(_nd_util.get_registered_instances()) - before + await kill_stray_browsers(owned) + raise except Exception as e: last_exc = e - if "Failed to connect to browser" in str(e): - if self.cdp_host and self.cdp_port: + connection_failed = "Failed to connect to browser" in str( + e + ) or "浏览器启动超时" in str(e) + if connection_failed: + if cdp_mode: raise RuntimeError( f"无法连接 CDP 浏览器 ({self.cdp_host}:{self.cdp_port})。" "请检查:\n" " 1. 远程浏览器是否已启动并开放调试端口\n" " 2. config.toml 中 cdp_host 和 cdp_port 是否正确" ) from e - # nodriver 失败不清理已启动的 Chrome 子进程,清理后重试 - await kill_stray_browsers() if attempt < attempts: - await asyncio.sleep(1) + await self._sleep(1) continue raise RuntimeError( f"无法启动浏览器 ({browser_path or '自动检测'})。" @@ -983,37 +1494,99 @@ async def _create_browser(self, headless: bool = False) -> nodriver.Browser: raise raise RuntimeError(f"无法启动浏览器: {last_exc}") + async def _snapshot_local_storage(self, tab) -> tuple[str, dict[str, str]]: + """在同一次页面求值中保存数据及其实际 origin,包含规范化和跳转结果。""" + wrapped = await self._eval_json( + tab, + """\ + (() => { + const result = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key !== null) result[key] = localStorage.getItem(key); + } + return {origin: window.location.origin, items: result}; + })() + """, + ) + if wrapped is None: + raise RuntimeError("无法保存共享浏览器的 localStorage") + origin = wrapped.get("origin") + snapshot = wrapped.get("items") + if not isinstance(origin, str) or not isinstance(snapshot, dict): + raise RuntimeError( # noqa: TRY004 -- 对外统一为浏览器生命周期失败 + "浏览器返回了无效的 localStorage 快照" + ) + return _origin_from_url(origin), { + str(key): str(value) for key, value in snapshot.items() + } + + async def _restore_local_storage( + self, + tab, + snapshot: dict[str, str], + *, + interruptible: bool = True, + ) -> None: + encoded = json.dumps(snapshot, ensure_ascii=False) + await self._evaluate( + tab, + f"""\ + (() => {{ + const snapshot = {encoded}; + localStorage.clear(); + for (const [key, value] of Object.entries(snapshot)) {{ + localStorage.setItem(key, value); + }} + }})() + """, + interruptible=interruptible, + ) + async def _inject_auth(self, tab) -> None: """向页面注入 localStorage 认证信息。 :param tab: nodriver Tab """ - await tab.evaluate(f"""\ + await self._evaluate( + tab, + f"""\ const user = {json.dumps(self._auth)}; localStorage.setItem('user', JSON.stringify(user)); - """) + """, + ) async def _ensure_captcha_sdk(self, tab) -> None: """确保页面已加载腾讯验证码 SDK,轮询等待就绪。 :param tab: nodriver Tab """ - await tab.evaluate(f"""\ - if (typeof TencentCaptcha === 'undefined') {{ - const script = document.createElement('script'); - script.src = '{TCAPTCHA_SDK_URL}'; - script.async = false; - document.head.appendChild(script); - }} - """) - for _ in range(20): - loaded = await tab.evaluate( - "(() => typeof TencentCaptcha !== 'undefined')()", return_by_value=True - ) - if loaded is True: - return - await asyncio.sleep(0.5) - self.log.warning("腾讯验证码 SDK 加载超时") + try: + async with asyncio.timeout(SDK_LOAD_TIMEOUT): + await self._evaluate( + tab, + f"""\ + if (typeof TencentCaptcha === 'undefined') {{ + const script = document.createElement('script'); + script.src = '{TCAPTCHA_SDK_URL}'; + script.async = false; + document.head.appendChild(script); + }} + """, + ) + while True: + loaded = await self._evaluate( + tab, + "(() => typeof TencentCaptcha !== 'undefined')()", + return_by_value=True, + ) + if loaded is True: + return + await self._sleep(0.25) + except TimeoutError as exc: + raise RuntimeError( + f"腾讯验证码 SDK 加载超时({SDK_LOAD_TIMEOUT:g} 秒)" + ) from exc async def _build_page(self, entry_url: str, headless: bool = False): """启动浏览器,注入认证信息,加载 SDK。 @@ -1023,38 +1596,48 @@ async def _build_page(self, entry_url: str, headless: bool = False): :return: (browser, tab) 元组 :raises: 任何页面操作异常时自动关闭浏览器,避免进程泄漏 - 先用 about:blank 建立域名(避免加载完整 SPA),注入 localStorage - 认证后再导航到目标页面。 + 先打开入口 URL 的标准 origin,注入 localStorage 认证后再导航到目标页面。 """ + await self._ensure_browser_ready() self.log.info("正在打开验证码入口页面") - browser = await asyncio.wait_for(self._create_browser(headless), timeout=30) + origin = _origin_from_url(entry_url) + browser = await self._bound( + self._create_browser(headless), + timeout=BROWSER_START_TIMEOUT * 2 + CLOSE_TIMEOUT + 2, + label="创建验证码浏览器", + ) + cdp_mode = self.cdp_host is not None and self.cdp_port is not None + state: dict[str, Any] = { + "tab": None, + "storage": None, + "close_tab": cdp_mode, + "origin": None, + } + self._browser_states[id(browser)] = state try: - origin = entry_url.split("#")[0].rstrip("/") # CDP 模式(连已有 headless-shell/Chrome):浏览器可能没有默认 # page target(headless-shell 刚启动时 /json 为空),browser.get() # 会因 next(filter(...)) 无 page target 抛异常,必须 new_tab 创建。 - cdp_mode = bool(self.cdp_host and self.cdp_port) - try: - tab = await asyncio.wait_for( - browser.get(f"{origin}/", new_tab=cdp_mode), timeout=30 - ) - except RuntimeError: - if not cdp_mode: - raise - # CDP 模式下 new_tab 仍失败则再试一次(可能 target 已存在) - tab = await asyncio.wait_for( - browser.get(f"{origin}/", new_tab=True), timeout=30 - ) + tab = await self._bound( + browser.get(f"{origin}/", new_tab=cdp_mode), + timeout=PAGE_LOAD_TIMEOUT, + label="验证码 origin 页面加载", + ) + state["tab"] = tab + state["origin"], state["storage"] = await self._snapshot_local_storage(tab) await self._inject_auth(tab) self.log.info("正在加载入口页面") - await asyncio.wait_for(tab.get(entry_url), timeout=30) + await self._bound( + tab.get(entry_url), + timeout=PAGE_LOAD_TIMEOUT, + label="验证码入口页面加载", + ) await self._ensure_captcha_sdk(tab) self.log.info("页面准备完成") return browser, tab - except TimeoutError: - await self._quit_browser(browser, "页面构建超时") - raise RuntimeError("页面加载超时,请检查网络连接") - except Exception: + except BaseException: + # 外层流程超时会取消本协程;CancelledError 也必须先恢复共享 + # 状态并关闭本次浏览器,再把取消传播给调用方。 await self._quit_browser(browser, "页面构建") raise @@ -1066,7 +1649,7 @@ async def _trigger_captcha(self, tab, app_id: str) -> None: :param tab: 浏览器标签页对象 :param app_id: 腾讯验证码 appId """ - await tab.evaluate(_SHOW_JS.replace("__APP_ID__", json.dumps(app_id))) + await self._evaluate(tab, _SHOW_JS.replace("__APP_ID__", json.dumps(app_id))) async def _wait_captcha_result(self, tab, timeout: float = 120.0) -> dict[str, str]: """轮询等待验证码回调结果。 @@ -1078,18 +1661,21 @@ async def _wait_captcha_result(self, tab, timeout: float = 120.0) -> dict[str, s ret 值含义:0=验证通过,2=用户主动关闭,其他=验证失败。 """ - deadline = time.time() + timeout - while time.time() < deadline: - res = await self._eval_json(tab, "(() => window.__captchaResult)()") - if res is None: - await asyncio.sleep(0.3) - continue - if isinstance(res, dict) and res.get("ret") == 0 and res.get("ticket"): - return {"randstr": res["randstr"], "ticket": res["ticket"]} - raise RuntimeError( - f"验证码未通过: ret={res.get('ret') if isinstance(res, dict) else res}" - ) - raise RuntimeError("等待验证码回调超时") + try: + async with asyncio.timeout(timeout): + while True: + res = await self._eval_json(tab, "(() => window.__captchaResult)()") + if res is None: + await self._sleep(0.3) + continue + if res.get("ret") == 0 and res.get("ticket") and res.get("randstr"): + return { + "randstr": str(res["randstr"]), + "ticket": str(res["ticket"]), + } + raise RuntimeError(f"验证码未通过: ret={res.get('ret')}") + except TimeoutError as exc: + raise RuntimeError(f"等待验证码回调超时({timeout:g} 秒)") from exc async def _run_captcha(self, tab, app_id: str) -> dict[str, str]: """触发验证码并阻塞等待用户手动完成。 @@ -1104,8 +1690,9 @@ async def _run_captcha(self, tab, app_id: str) -> dict[str, str]: # ── 自动识别 ──────────────────────────────────────── - @staticmethod - async def _wait_until(predicate, timeout: float = 10.0, interval: float = 0.3): + async def _wait_until( + self, predicate, timeout: float = 10.0, interval: float = 0.3 + ): """轮询等待条件为真。 :param predicate: 无参异步函数,返回真值时停止等待 @@ -1113,22 +1700,31 @@ async def _wait_until(predicate, timeout: float = 10.0, interval: float = 0.3): :param interval: 轮询间隔秒数 :return: predicate 的最后一次返回值 """ - deadline = time.time() + timeout - while time.time() < deadline: - value = await predicate() + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + last_value = None + while loop.time() < deadline: + remaining = deadline - loop.time() + if remaining <= 0: + break + value = await self._bound( + predicate(), + timeout=min(CDP_CALL_TIMEOUT, remaining), + label="验证码状态查询", + ) if value: return value - await asyncio.sleep(interval) - return await predicate() + last_value = value + await self._sleep(interval) + return last_value - @staticmethod - async def _maybe_state(tab): + async def _maybe_state(self, tab): """检查验证码图片是否已加载就绪。 :param tab: 浏览器标签页对象 :return: 包含 bgUrl/ansUrl/bgRect 等字段的 dict,未就绪返回 None """ - s = await CaptchaHandler._eval_json(tab, _QUERY_JS) + s = await self._eval_json(tab, _QUERY_JS) if not s: return None if not ( @@ -1145,36 +1741,42 @@ async def _maybe_state(tab): return None return s - @staticmethod - async def _btn_enabled(tab): + async def _btn_enabled(self, tab): """检查提交按钮是否已启用。 :param tab: 浏览器标签页对象 :return: 按钮状态 dict,未启用返回 None """ - s = await CaptchaHandler._eval_json(tab, _QUERY_JS) + s = await self._eval_json(tab, _QUERY_JS) if not s: return None if "--disabled" in (s.get("btnCls") or ""): return None return s - @staticmethod - async def _click_refresh(tab) -> None: + async def _click_refresh(self, tab) -> None: """点击验证码刷新按钮换一组图片。 :param tab: 浏览器标签页对象 """ - state = await CaptchaHandler._eval_json(tab, _QUERY_JS) + state = await self._eval_json(tab, _QUERY_JS) rect = (state or {}).get("refreshRect") if not rect: return rx = int(rect["x"] + rect["w"] / 2) ry = int(rect["y"] + rect["h"] / 2) - await tab.mouse_move(rx, ry) - await asyncio.sleep(0.15) - await tab.mouse_click(rx, ry) - await asyncio.sleep(1.5) + await self._bound( + tab.mouse_move(rx, ry), + timeout=CDP_CALL_TIMEOUT, + label="移动验证码鼠标", + ) + await self._sleep(0.15) + await self._bound( + tab.mouse_click(rx, ry), + timeout=CDP_CALL_TIMEOUT, + label="点击验证码刷新按钮", + ) + await self._sleep(1.5) async def _auto_solve_once( self, tab, attempt: int, save_debug: bool @@ -1194,28 +1796,45 @@ async def _auto_solve_once( return None try: - main_img = fetch_image(state["bgUrl"]) - prompt_img = fetch_image(state["ansUrl"]) - except (OSError, RuntimeError) as exc: + main_img, prompt_img = await self._bound( + asyncio.gather( + asyncio.to_thread(fetch_image, state["bgUrl"]), + asyncio.to_thread(fetch_image, state["ansUrl"]), + ), + timeout=IMAGE_WORK_TIMEOUT, + label="下载验证码图片", + ) + except (OSError, RuntimeError, requests.RequestException) as exc: self.log.warning(f"自动识别: 抓图失败 - {exc}") return None nat_h, nat_w = main_img.shape[:2] bg_rect = state["bgRect"] - ordered, candidates = detect_points(prompt_img, main_img) + ordered, candidates = await self._bound( + asyncio.to_thread(detect_points, prompt_img, main_img), + timeout=IMAGE_WORK_TIMEOUT, + label="验证码图片识别", + ) if save_debug: - self._debug_dir.mkdir(parents=True, exist_ok=True) stamp = int(time.time() * 1000) - cv2.imwrite(str(self._debug_dir / f"{stamp}_a{attempt}_main.png"), main_img) - cv2.imwrite( - str(self._debug_dir / f"{stamp}_a{attempt}_prompt.png"), prompt_img - ) - cv2.imwrite( - str(self._debug_dir / f"{stamp}_a{attempt}_debug.png"), - render_debug(main_img, ordered, candidates), - ) + try: + await self._bound( + asyncio.to_thread( + _save_debug_images, + self._debug_dir, + f"{stamp}_a{attempt}", + main_img, + prompt_img, + ordered, + candidates, + ), + timeout=IMAGE_WORK_TIMEOUT, + label="保存验证码调试图片", + ) + except (OSError, RuntimeError) as exc: + self.log.warning(f"自动识别: 调试图片保存失败 - {exc}") if any(p is None for p in ordered): self.log.warning(f"自动识别: 识别有缺失 {ordered}") @@ -1234,14 +1853,27 @@ async def _auto_solve_once( for idx, (vx, vy) in enumerate(viewport_points, start=1): cx = vx + random.randint(-3, 3) cy = vy + random.randint(-3, 3) - await tab.mouse_move(cx, cy) - await asyncio.sleep(0.15 + random.random() * 0.15) - await tab.mouse_click(cx, cy) + await self._bound( + tab.mouse_move(cx, cy), + timeout=CDP_CALL_TIMEOUT, + label=f"移动到验证码第 {idx} 个目标", + ) + await self._sleep(0.15 + random.random() * 0.15) + await self._bound( + tab.mouse_click(cx, cy), + timeout=CDP_CALL_TIMEOUT, + label=f"点击验证码第 {idx} 个目标", + ) self.log.info(f"自动识别: 点击 #{idx} at ({cx}, {cy})") - await asyncio.sleep(0.25 + random.random() * 0.25) + await self._sleep(0.25 + random.random() * 0.25) # 等待提交按钮启用后点击 - await self._wait_until(lambda: self._btn_enabled(tab), timeout=3) + enabled_state = await self._wait_until( + lambda: self._btn_enabled(tab), timeout=3 + ) + if not enabled_state: + self.log.warning("自动识别: 提交按钮未在时限内启用") + return None final_state = await self._eval_json(tab, _QUERY_JS) btn_rect = (final_state or {}).get("btnRect") or state["btnRect"] @@ -1251,9 +1883,17 @@ async def _auto_solve_once( bx = int(btn_rect["x"] + btn_rect["w"] / 2) by = int(btn_rect["y"] + btn_rect["h"] / 2) - await tab.mouse_move(bx, by) - await asyncio.sleep(0.2) - await tab.mouse_click(bx, by) + await self._bound( + tab.mouse_move(bx, by), + timeout=CDP_CALL_TIMEOUT, + label="移动到验证码提交按钮", + ) + await self._sleep(0.2) + await self._bound( + tab.mouse_click(bx, by), + timeout=CDP_CALL_TIMEOUT, + label="点击验证码提交按钮", + ) # 等待验证码回调 try: @@ -1275,11 +1915,11 @@ async def _auto_solve_captcha( """ for attempt in range(1, max_retry + 1): # 清除上一轮的回调结果,避免 _wait_captcha_result 读到过期值 - await tab.evaluate("window.__captchaResult = null;") + await self._evaluate(tab, "window.__captchaResult = null;") if attempt == 1: await self._trigger_captcha(tab, app_id) - await asyncio.sleep(2) + await self._sleep(2) else: await self._click_refresh(tab) @@ -1291,35 +1931,118 @@ async def _auto_solve_captcha( # ── 公开方法 ──────────────────────────────────────── async def _quit_browser(self, browser: nodriver.Browser, label: str = "") -> None: - """关闭 websocket、Chrome 进程和 asyncio subprocess transport。""" - proc = getattr(browser, "_process", None) - transport = getattr(proc, "_transport", None) if proc is not None else None - try: - try: - await browser.aclose() - finally: - if proc is not None: + """恢复共享状态,并只关闭当前流程拥有的标签页和连接。""" + state = self._browser_states.pop(id(browser), None) + + async def _graceful_close() -> None: + tab = state.get("tab") if state else None + snapshot = state.get("storage") if state else None + if tab is not None and isinstance(snapshot, dict): + origin = state.get("origin") if state else None + restore_storage = False + try: + current_origin = await _bounded( + self._evaluate( + tab, + "(() => window.location.origin)()", + return_by_value=True, + interruptible=False, + ), + timeout=_close_step_timeout(), + label="确认 localStorage origin", + ) + if ( + isinstance(origin, str) + and isinstance(current_origin, str) + and current_origin == origin + ): + restore_storage = True + elif isinstance(origin, str) and origin: + await _bounded( + tab.get(f"{origin}/"), + timeout=_close_step_timeout(), + label="返回 localStorage origin", + ) + confirmed_origin = await _bounded( + self._evaluate( + tab, + "(() => window.location.origin)()", + return_by_value=True, + interruptible=False, + ), + timeout=_close_step_timeout(), + label="再次确认 localStorage origin", + ) + restore_storage = ( + isinstance(confirmed_origin, str) + and confirmed_origin == origin + ) + if not restore_storage: + self.log.warning( + "返回 localStorage origin 后校验不匹配,跳过恢复" + ) + except Exception as exc: # noqa: BLE001 -- 仍需尝试原地恢复 + self.log.warning( + f"确认浏览器 localStorage origin 失败,跳过恢复: {exc}" + ) + if restore_storage: try: - if proc.returncode is None: - proc.kill() - await asyncio.wait_for(proc.wait(), timeout=5) - except (Exception, asyncio.CancelledError): # noqa: BLE001 - await asyncio.sleep(0) - if transport is not None: - try: - transport.close() - except (Exception, asyncio.CancelledError): # noqa: BLE001 - await asyncio.sleep(0) - for _ in range(3): - await asyncio.sleep(0) - from nodriver.core import util as _nd_util - _nd_util.get_registered_instances().discard(browser) - config = getattr(browser, "config", None) - if config is not None and not config.uses_custom_data_dir: - shutil.rmtree(config.user_data_dir, ignore_errors=True) + await _bounded( + self._restore_local_storage( + tab, + snapshot, + interruptible=False, + ), + timeout=_close_step_timeout(), + label="恢复浏览器 localStorage", + ) + except Exception as exc: # noqa: BLE001 -- 清理必须继续 + self.log.warning(f"恢复浏览器 localStorage 失败: {exc}") + if tab is not None and state and state.get("close_tab"): + with suppress(Exception): + await _bounded( + tab.close(), + timeout=_close_step_timeout(), + label="关闭验证码标签页", + ) + await _close_browser_instance(browser) + + try: + await _bounded( + _graceful_close(), + timeout=CLOSE_TIMEOUT, + label="验证码浏览器关闭流程", + ) if label: self.log.info(f"已关闭浏览器 ({label})") except Exception as exc: # noqa: BLE001 -- nodriver 停止浏览器可能抛任意异常 + # 即使 websocket 不响应,也立即终止本次创建的本地进程并注销实例。 + from nodriver.core import util as _nd_util + + tab = state.get("tab") if state else None + if tab is not None and state and state.get("close_tab"): + with suppress(Exception): + await _bounded( + tab.close(), + timeout=_close_step_timeout(), + label="强制关闭验证码标签页", + ) + with suppress(Exception): + await _bounded( + browser.aclose(), + timeout=_close_step_timeout(), + label="强制关闭浏览器连接", + ) + proc = getattr(browser, "_process", None) + if proc is not None and getattr(proc, "returncode", None) is None: + with suppress(ProcessLookupError, OSError): + proc.kill() + transport = getattr(proc, "_transport", None) + if transport is not None: + with suppress(Exception): + transport.close() + _nd_util.get_registered_instances().discard(browser) + await _remove_temporary_profile(browser) if label: self.log.warning(f"关闭浏览器异常 ({label}): {exc}") @@ -1342,6 +2065,20 @@ def handle_exam_captcha(self, user_exam_plan_id: str) -> dict[str, str]: async def handle_exam_captcha_async(self, user_exam_plan_id: str) -> dict[str, str]: """处理考试前的无感验证码(异步版本)。""" + self._raise_if_stopped() + await self._ensure_browser_ready() + async with self._exclusive_endpoint(): + try: + async with asyncio.timeout(EXAM_FLOW_TIMEOUT): + return await self._handle_exam_captcha_flow(user_exam_plan_id) + except TimeoutError as exc: + raise RuntimeError( + f"无感验证码处理超时({EXAM_FLOW_TIMEOUT:g} 秒)" + ) from exc + + async def _handle_exam_captcha_flow(self, user_exam_plan_id: str) -> dict[str, str]: + """已取得端点互斥锁的考试验证码流程。""" + del user_exam_plan_id self.log.info("正在处理无感验证码") browser, tab = await self._build_page(EXAM_ENTRY_URL, headless=True) try: @@ -1377,14 +2114,29 @@ async def handle_course_captcha_async( 自动识别阶段最多 3 轮、每轮 6 次;浏览器连接异常会重建无头页面继续, 全部失败后才转手动。 """ + self._raise_if_stopped() + await self._ensure_browser_ready() + async with self._exclusive_endpoint(): + try: + async with asyncio.timeout(COURSE_FLOW_TIMEOUT): + return await self._handle_course_captcha_flow(course_url) + except TimeoutError as exc: + raise RuntimeError( + f"课程验证码处理超时({COURSE_FLOW_TIMEOUT:g} 秒)" + ) from exc + + async def _handle_course_captcha_flow( + self, course_url: str | None = None + ) -> dict[str, str]: + """已取得端点互斥锁的课程验证码流程。""" entry_url = course_url or COURSE_ENTRY_URL # 自动识别重试上限(环境变量可调,小核服务器每次尝试很慢): # WB_CAPTCHA_ROUNDS 轮数、WB_CAPTCHA_ATTEMPTS 每轮次数。 # 默认 2 轮 x 3 次(最多 6 次尝试)——实测单次尝试在 1 核机器约 # 4 分钟,18 次全试可能 1 小时+;识别成功率高时 1-2 次即过, # 失败应尽快跳过该课程而不是无限重试。 - max_auto_rounds = int(os.environ.get("WB_CAPTCHA_ROUNDS", "2")) - attempts_per_round = int(os.environ.get("WB_CAPTCHA_ATTEMPTS", "3")) + max_auto_rounds = _env_positive_int("WB_CAPTCHA_ROUNDS", 2) + attempts_per_round = _env_positive_int("WB_CAPTCHA_ATTEMPTS", 3) # 第一阶段: 无头自动识别 self.log.info("正在自动识别验证码...") @@ -1394,9 +2146,11 @@ async def handle_course_captcha_async( self.log.warning( f"自动识别未完成,重建无头浏览器重试(第 {round_no}/{max_auto_rounds} 轮)" ) - await asyncio.sleep(1) + await self._sleep(1) try: browser, tab = await self._build_page(entry_url, headless=True) + except InterruptedError: + raise except Exception as exc: # noqa: BLE001 -- 浏览器启动失败也继续下一轮 last_exc = exc self.log.warning( @@ -1412,6 +2166,8 @@ async def handle_course_captcha_async( if result: self.log.success("验证码自动识别成功") return result + except InterruptedError: + raise except Exception as exc: # noqa: BLE001 -- 连接/页面异常后重建重试 last_exc = exc self.log.warning( @@ -1421,14 +2177,13 @@ async def handle_course_captcha_async( await self._quit_browser(browser, "自动识别") if last_exc is not None: self.log.warning(f"自动识别曾发生异常,将回退到手动: {last_exc}") - await asyncio.sleep(1) # 等待无头浏览器进程完全退出 + await self._sleep(1) # 等待无头浏览器进程完全退出 # 无交互模式(Docker 等无终端环境):不打开可见浏览器等待手动, # 直接抛异常让上层跳过该课程 - if is_non_interactive(): + if self.non_interactive: raise RuntimeError( - "验证码自动识别连续失败且处于无交互模式," - "无法手动完成验证,已跳过该课程" + "验证码自动识别连续失败且处于无交互模式,无法手动完成验证,已跳过该课程" ) # 第二阶段: 打开可见浏览器,让用户手动完成 @@ -1443,3 +2198,13 @@ async def handle_course_captcha_async( return result finally: await self._quit_browser(browser, "手动验证") + + def close(self) -> None: + """清除敏感认证信息;每次流程持有的浏览器均在流程 finally 中关闭。""" + + if self._closed: + return + if self._browser_states: + self.log.warning("关闭验证码处理器时仍存在未完成的浏览器流程") + self._auth.clear() + self._closed = True diff --git a/client.py b/client.py index 2a38b90..e709f6a 100644 --- a/client.py +++ b/client.py @@ -1,19 +1,39 @@ +from __future__ import annotations + import json +import math import os import re import sys import threading import time +import unicodedata import webbrowser +from pathlib import Path from random import randint -from typing import Any -from urllib.parse import parse_qs, urljoin, urlparse +from typing import Any, Self +from urllib.parse import ( + parse_qs, + parse_qsl, + urlencode, + urljoin, + urlparse, + urlsplit, + urlunsplit, +) from uuid import uuid4 from loguru import logger +from answer_store import AnswerStore, AnswerStoreError from api import WeBanAPI from captcha import CaptchaHandler, LoginCaptchaSolver, is_non_interactive +from errors import ( + AccountBlockedError, + APIResponseError, + ResponseValidationError, + WorkflowResult, +) if getattr(sys, "frozen", False): base_path = os.path.dirname(os.path.abspath(sys.executable)) @@ -31,19 +51,46 @@ else: answer_dir = os.path.join(base_path, "answer") answer_path = os.path.join(answer_dir, "answer.json") -root_answer_path = os.path.join(_data_dir, "answer.json") if _data_dir else os.path.join(base_path, "answer.json") +root_answer_path = ( + os.path.join(_data_dir, "answer.json") + if _data_dir + else os.path.join(base_path, "answer.json") +) bundle_answer_path = os.path.join(bundle_path, "answer", "answer.json") +# 完课接口返回成功后,showProgress 的计数可能稍后才可见。轮询次数和 +# 退避上限必须固定,避免网络异常或服务端永不更新时无限阻塞任务。 +PROGRESS_POLL_DELAYS = (0.5, 1.0, 2.0, 4.0) + def clean_text(text): - """只保留字母、数字和汉字,自动去除所有符号和空格 + """生成保守模糊键,保留会改变语义的正负号和比较符。 - 去除标点/空格后做模糊匹配,确保如「以下说法正确的是()」能命中 - 题库中「以下说法正确的是」。 + 普通标点和空格仍会被忽略,但 ``+ - < > = ≤ ≥ ≠`` 不再被删除, + 避免“正/负”“大于/小于”题目碰撞。 :param text: 原始文本 - :return: 仅含字母、数字和汉字的文本 + :return: 用于唯一模糊匹配的文本 """ - return re.sub(r"[^\w一-龥]", "", text) + normalized = unicodedata.normalize("NFKC", str(text)) + return re.sub(r"[^\w一-龥+\-<>=≤≥≠]", "", normalized) + + +def _exact_text(text: object) -> str: + """Unicode 归一化并移除空白,保留其余全部符号。""" + + normalized = unicodedata.normalize("NFKC", str(text)).strip() + return re.sub(r"\s+", "", normalized) + + +def _option_signature(question: dict) -> frozenset[str]: + options = question.get("optionList") + if not isinstance(options, list): + return frozenset() + return frozenset( + clean_text(option.get("content", "")) + for option in options + if isinstance(option, dict) and clean_text(option.get("content", "")) + ) # --------------------------------------------------------------------------- @@ -63,22 +110,58 @@ def get_source_str(query: dict) -> str: return "WEIBAN" -def read_first_existing(paths: list[str]) -> str | None: - """按序读取第一个存在的本地文件内容(模板/题库的打包版兜底共用)。 +def _course_finished(course: dict) -> bool: + """课程列表对象的 finished 字段是否表示已完成(1=完成,2=未完成)。 - 模板(config.example.toml)与题库(answer.json)的下载都会先尝试 - jsDelivr 远程源;失败时回退到本地候选文件(打包内置 _MEIPASS 或 - 可执行文件旁),两者共用本函数读取兜底内容。 - :param paths: 候选路径,按优先级排列(如 bundle 内置优先) - :return: 文件文本;全部不存在或不可读返回 None + 服务端偶发返回 null/字符串,解析失败一律视为未完成,宁可多学一门也不能 + 因为 TypeError 让整个项目中断。JSON 里的 1e309 会解析成 inf, + int(inf) 抛 OverflowError,同样按未完成处理。 """ - for path in paths: - try: - with open(path, encoding="utf-8") as f: - return f.read() - except OSError: - continue - return None + try: + return int(course.get("finished", 0)) == 1 + except (TypeError, ValueError, OverflowError): + return False + + +# 试卷分值的合理上界:官方满分为 100,留出余量拒绝 inf/巨大值。 +_MAX_SCORE = 10_000.0 + + +def _finite_score(value: object) -> float | None: + """把分数字段解析为有限、非负且在合理范围内的 float,否则返回 None。 + + JSON 允许 1e309/-1e309 之类字面量,Python 会得到 ±inf;某些代理还可能 + 透传 NaN。它们进入 >= 比较会产生错误的跳过/不跳过判定,必须拒收。 + """ + if isinstance(value, bool): + return None + try: + parsed = float(value) # type: ignore[arg-type] + except (TypeError, ValueError, OverflowError): + return None + if not math.isfinite(parsed) or parsed < 0 or parsed > _MAX_SCORE: + return None + return parsed + + +def _brief_response(response: object, limit: int = 200) -> str: + """只提取业务码和短消息用于日志,不回显完整响应正文。 + + 客户端可能被脱离 main.py 的 LogRedactor 直接使用,因此这里自行去掉 + 控制字符并截断长度,避免把 token、个人信息或超长内容写进日志。 + """ + if not isinstance(response, dict): + return f"<{type(response).__name__}>" + parts: list[str] = [] + for key in ("code", "detailCode", "msg", "message"): + if key in response: + text = re.sub(r"[\x00-\x1f\x7f]", "?", str(response[key])) + if len(text) > limit: + text = f"{text[:limit]}…" + parts.append(f"{key}={text}") + data = response.get("data") + parts.append(f"data=<{type(data).__name__}>") + return ", ".join(parts) def _check_code_ok(data: dict, allow_200: bool = True) -> bool: @@ -86,12 +169,13 @@ def _check_code_ok(data: dict, allow_200: bool = True) -> bool: 主站请求封装(app.js request):Boolean(data) && Number(code)∈{0,1,200}; 完课 JSONP(sdk.js finishWxCourse):Boolean(data) && Number(code)∈{0,1}, - 传 allow_200=False 对齐。注意 Number(null)===0,code 为 null 时官方同样视为成功。 + 传 allow_200=False 对齐。缺少 code 不是成功响应;显式 code=null 仍按 + Number(null)===0 保留官方兼容语义。 :param data: 接口响应 dict :param allow_200: 是否允许 code=200(主站接口 True,完课 JSONP False) :return: 业务成功返回 True """ - if not data: + if not data or "code" not in data: return False code = data.get("code") try: @@ -219,6 +303,11 @@ def __init__( ai_config: dict[str, Any] | None = None, video_speed: float = 1.0, jupiter_fallback: bool = False, + data_dir: str | os.PathLike[str] | None = None, + answer_store: AnswerStore | None = None, + non_interactive: bool | None = None, + captcha_debug_dir: str | os.PathLike[str] | None = None, + stop_event: threading.Event | None = None, ) -> None: """ :param tenant_name: 学校全称 @@ -238,8 +327,13 @@ def __init__( callApinext)的课程才上报轨迹,默认 False 完全对齐官方行为; 个别学校可能要求该校所有微课都有轨迹, 实测无轨迹会 10018 时可开启该项 + :param data_dir: 统一数据目录;仅在未显式提供 answer_store 时使用 + :param answer_store: 由入口创建的共享题库存储 + :param non_interactive: 显式无交互策略,禁止回退到 input/可见浏览器 + :param captcha_debug_dir: 当前账号隔离后的验证码调试目录 + :param stop_event: 进程级停止事件,用于中断长等待和验证码流程 """ - self.log = log + self.log: Any = log self.tenant_name = tenant_name.strip() self.study_base_time = 20 self.study_random_upper = 10 @@ -255,20 +349,42 @@ def __init__( self.cdp_port = cdp_port self.ai_config = ai_config self._ai_key_warned = False # api_key 未配置提醒只打一次 + self.non_interactive = ( + is_non_interactive() if non_interactive is None else bool(non_interactive) + ) + self.stop_event = stop_event or threading.Event() + resolved_data_dir = Path(data_dir or _data_dir or base_path).resolve( + strict=False + ) + self.data_dir = resolved_data_dir + self.captcha_debug_dir = Path( + captcha_debug_dir + if captcha_debug_dir is not None + else resolved_data_dir / "logs" / "captcha" + ).resolve(strict=False) + self._answer_store_instance = answer_store or self._build_answer_store( + resolved_data_dir + ) if user and all([user.get("userId"), user.get("token")]): - self.api = WeBanAPI(user=user, debug=debug, log=log) + self.api: Any = WeBanAPI(user=user, debug=debug, log=log) elif all([self.tenant_name, account, password]): self.api = WeBanAPI( account=account, password=password, debug=debug, log=log ) else: self.api = WeBanAPI(debug=debug, log=log) - self.tenant_code = self.get_tenant_code() - if self.tenant_code: - self.api.set_tenant_code(self.tenant_code) - else: - raise ValueError("学校代码获取失败,请检查学校全称是否正确") - self._captcha_handler = None + self._closed = False + try: + self.tenant_code = self.get_tenant_code() + if self.tenant_code: + self.api.set_tenant_code(self.tenant_code) + else: + raise ValueError("学校代码获取失败,请检查学校全称是否正确") + except BaseException: + self.api.close() + self._closed = True + raise + self._captcha_handler: Any | None = None # ---- properties / helpers ------------------------------------------------ @@ -286,6 +402,9 @@ def captcha_handler(self): browser_path=self.browser_path, cdp_host=self.cdp_host, cdp_port=self.cdp_port, + debug_dir=self.captcha_debug_dir, + non_interactive=self.non_interactive, + stop_event=self.stop_event, ) return self._captcha_handler @@ -301,6 +420,44 @@ def _format_duration(seconds: float) -> str: return f"{m}m{sec:02d}s" return f"{sec}s" + def _raise_if_stopped(self) -> None: + stop_event = getattr(self, "stop_event", None) + if stop_event is not None and stop_event.is_set(): + raise InterruptedError("运行已被中断") + + def _sleep(self, seconds: float) -> None: + """使用共享停止事件替代不可中断的 time.sleep。""" + + self._raise_if_stopped() + delay = max(0.0, float(seconds)) + stop_event = getattr(self, "stop_event", None) + if stop_event is None: + time.sleep(delay) + return + if stop_event.wait(delay): + raise InterruptedError("运行已被中断") + + def close(self) -> None: + """释放账号客户端持有的网络连接和验证码处理器引用。""" + + if self._closed: + return + handler = self._captcha_handler + self._captcha_handler = None + close_handler = getattr(handler, "close", None) + try: + if close_handler is not None: + close_handler() + finally: + self.api.close() + self._closed = True + + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + def simulate_home_page(self) -> None: """模拟打开官方 H5 首页:对齐登录后页面初始化请求面 @@ -326,14 +483,17 @@ def simulate_home_page(self) -> None: ("学习任务", self.api.list_study_task), ] for name, fn in steps: + self._raise_if_stopped() try: res = fn() if not _check_code_ok(res): self.log.debug(f"首页{name}返回异常:{res}") + except InterruptedError: + raise except PermissionError: raise # Token 失效(被顶号等),立即终止该账号 - except OSError as e: # 网络异常(DNS/连接/SSL)忽略,不影响主流程 - self.log.debug(f"首页{name}请求失败(网络异常):{e}") + except (OSError, APIResponseError) as e: + self.log.debug(f"首页{name}请求失败:{e}") # 必读公告:官方弹窗逐条展示,阅读完成后逐条确认(与浏览器行为一致) try: @@ -343,6 +503,9 @@ def simulate_home_page(self) -> None: self.log.debug(f"必读公告返回异常:{must}") notices = [] for n in notices: + if not isinstance(n, dict) or not n.get("id"): + self.log.debug(f"跳过结构无效的必读公告:{n}") + continue nid = n.get("id", "") title = n.get("title", "") ntype = n.get("type", "") @@ -354,68 +517,214 @@ def simulate_home_page(self) -> None: ) # 官方普通类型公告有阅读倒计时(minReadLength 秒), # 倒计时结束才可点击"下一条/关闭"确认;上限 300s 防极端值 - if ntype not in (3, 4, 5) and isinstance(min_read, (int, float)) and min_read > 0: - time.sleep(min(min_read, 300)) + if ( + ntype not in (3, 4, 5) + and isinstance(min_read, (int, float)) + and min_read > 0 + ): + self._sleep(min(min_read, 300)) try: self.api.view_must_notice(nid) + except InterruptedError: + raise except PermissionError: raise # Token 失效,立即终止该账号 - except OSError as e: - self.log.debug(f"确认必读公告失败(网络异常):{e}") - except PermissionError: + except (OSError, APIResponseError) as e: + self.log.debug(f"确认必读公告失败:{e}") + except (InterruptedError, PermissionError): raise # Token 失效,立即终止该账号 - except OSError as e: - self.log.debug(f"必读公告流程失败(网络异常):{e}") + except (OSError, APIResponseError) as e: + self.log.debug(f"必读公告流程失败:{e}") # 问卷:官方在必读公告确认后检查待答问卷,仅拉取并提示,不自动作答 try: q = self.api.questionnaire_list_by_user_id() qlist = q.get("data") if isinstance(q.get("data"), list) else [] - if q.get("code", "-1") == "0" and qlist: - self.log.info(f"存在 {len(qlist)} 个待答问卷(官方会弹窗提示,请前往网页完成)") + if _check_code_ok(q) and qlist: + self.log.info( + f"存在 {len(qlist)} 个待答问卷(官方会弹窗提示,请前往网页完成)" + ) + except InterruptedError: + raise except PermissionError: raise # Token 失效,立即终止该账号 - except OSError as e: - self.log.debug(f"问卷列表请求失败(网络异常):{e}") + except (OSError, APIResponseError) as e: + self.log.debug(f"问卷列表请求失败:{e}") def _prompt(self, message: str) -> str: """线程安全的 input 封装,多线程下避免 input 输出交错 :param message: 提示信息 :return: 去除首尾空白的用户输入 """ + self._raise_if_stopped() + if self.non_interactive: + raise RuntimeError("无交互模式禁止读取终端输入") with self._stdin_lock: return input(message).strip() + @classmethod + def _build_answer_store(cls, data_dir: Path) -> AnswerStore: + """在统一数据目录内选择写入路径,并保留历史/打包题库作恢复源。""" + + legacy_path = data_dir / "answer.json" + current_path = data_dir / "answer" / "answer.json" + target = legacy_path if legacy_path.exists() else current_path + bundled_path = Path(bundle_path) / "answer" / "answer.json" + fallbacks = tuple( + path + for path in (legacy_path, current_path, bundled_path) + if os.path.normcase(str(path.resolve(strict=False))) + != os.path.normcase(str(target.resolve(strict=False))) + ) + return AnswerStore( + target, + fallbacks=fallbacks, + validator=cls._is_valid_answers, + ) + + def _answer_store(self) -> AnswerStore: + store = getattr(self, "_answer_store_instance", None) + if store is not None: + return store + + # 兼容只构造最小测试替身和旧式嵌入调用;正常客户端始终在 + # __init__ 中使用显式 data_dir 创建实例。 + target = root_answer_path if os.path.exists(root_answer_path) else answer_path + fallbacks = tuple( + path + for path in (root_answer_path, answer_path, bundle_answer_path) + if os.path.normcase(os.path.abspath(path)) + != os.path.normcase(os.path.abspath(target)) + ) + store = AnswerStore( + target, + fallbacks=fallbacks, + validator=self._is_valid_answers, + ) + self._answer_store_instance = store + return store + def _load_answers_json(self, warn_on_fail: bool = False) -> dict: - """加载题库,返回 {clean_text(题目): {clean_text(正确选项), ...}} + """通过安全存储加载并规范化原始题库。 :param warn_on_fail: True 时加载失败只警告不抛异常(学习模式容错), False 时抛出异常(考试模式必须要有题库) - :return: 清洗后的题目标题 → 正确答案内容集合的映射 + :return: 原始题目标题 → 规范化题目对象 """ - answers: dict = {} - # 优先级: 根目录 answer.json > answer/answer.json > 打包内置 - if os.path.exists(root_answer_path): - load_path = root_answer_path - elif os.path.exists(answer_path): - load_path = answer_path - else: - load_path = bundle_answer_path try: - with open(load_path, encoding="utf-8") as f: - for title, options in json.load(f).items(): - title = clean_text(title) - answers.setdefault(title, set()).update( - clean_text(a["content"]) - for a in options.get("optionList", []) - if a["isCorrect"] == 1 - ) - except Exception: + return self._normalize_answers(self._answer_store().load()) + except (AnswerStoreError, OSError, UnicodeError, ValueError): if warn_on_fail: self.log.warning("题库加载失败,课后习题将随机作答") + return {} else: raise - return answers + + @staticmethod + def _match_answer_contents( + answers_json: dict, + question: dict, + ) -> set[str] | None: + """按精确标题优先、唯一兼容模糊标题兜底查找答案。""" + + title = str(question.get("title", "")) + current_signature = _option_signature(question) + if not title or not current_signature: + return None + + # 兼容旧调用方传入的 clean_title -> set 映射。 + legacy = answers_json.get(clean_text(title)) + if isinstance(legacy, (set, frozenset, list, tuple)): + values = {clean_text(item) for item in legacy if clean_text(item)} + return values or None + + entries = [ + (str(stored_title), stored) + for stored_title, stored in answers_json.items() + if isinstance(stored, dict) + ] + + def compatible(entry: dict) -> bool: + return ( + bool(_option_signature(entry)) + and _option_signature(entry) == current_signature + ) + + raw_matches = [ + entry for stored_title, entry in entries if stored_title == title + ] + if raw_matches: + return ( + WeBanClient._correct_answer_contents(raw_matches[0]) + if len(raw_matches) == 1 and compatible(raw_matches[0]) + else None + ) + + exact_key = _exact_text(title) + exact_matches = [ + entry + for stored_title, entry in entries + if _exact_text(stored_title) == exact_key + ] + if exact_matches: + candidates = [entry for entry in exact_matches if compatible(entry)] + return ( + WeBanClient._correct_answer_contents(candidates[0]) + if len(candidates) == 1 + else None + ) + + fuzzy_key = clean_text(title) + candidates = [ + entry + for stored_title, entry in entries + if clean_text(stored_title) == fuzzy_key and compatible(entry) + ] + if len(candidates) != 1: + return None + return WeBanClient._correct_answer_contents(candidates[0]) + + @staticmethod + def _correct_answer_contents(entry: dict) -> set[str] | None: + answers = { + clean_text(option.get("content", "")) + for option in entry.get("optionList", []) + if isinstance(option, dict) + and option.get("isCorrect") == 1 + and clean_text(option.get("content", "")) + } + return answers or None + + @classmethod + def _answer_ids_for_question( + cls, + answers_json: dict, + question: dict, + ) -> list[str]: + """仅在全部答案都能映射为当前试卷合法 ID 时返回结果。""" + + answer_contents = cls._match_answer_contents(answers_json, question) + options = question.get("optionList") + if not answer_contents or not isinstance(options, list): + return [] + answer_ids = [ + str(option.get("id", "")) + for option in options + if isinstance(option, dict) + and clean_text(option.get("content", "")) in answer_contents + and option.get("id") + ] + if len(set(answer_ids)) != len(answer_ids): + return [] + if len(answer_ids) != len(answer_contents): + return [] + try: + question_type = int(question.get("type", 1)) + except (TypeError, ValueError): + return [] + if question_type == 2: + return answer_ids if answer_ids else [] + return answer_ids if len(answer_ids) == 1 else [] @staticmethod def get_project_type(project_category: int) -> str: @@ -466,20 +775,45 @@ def _build_course_url(self, course: dict, task: dict) -> str: :param task: 任务数据(含 userProjectId) :return: 完整的课程播放 URL """ - url = self.api.get_course_url(course["resourceId"], task["userProjectId"])[ - "data" - ] - url += f"&userProjectId={task['userProjectId']}" - url += f"&userId={self.api.user['userId']}" - url += f"&courseId={course['resourceId']}" - url += f"&userName={self.api.user.get('userName', self.api.user.get('realName', ''))}" + response = self.api.get_course_url(course["resourceId"], task["userProjectId"]) + url = response.get("data") if _check_code_ok(response) else None + if not isinstance(url, str) or not url.startswith(("http://", "https://")): + raise ResponseValidationError(f"课程链接响应无效:{response}") link = course.get("praiseNum", "") - url += ( - f"&projectType=special&projectId=undefined&protocol=true&link={link}" - "&weiban=weiban&certificateId=undefined&userActivityState=undefined" - "&step=undefined&index=undefined&viewStep=undefined" + parts = urlsplit(url) + query = parse_qsl(parts.query, keep_blank_values=True) + query.extend( + [ + ("userProjectId", str(task["userProjectId"])), + ("userId", str(self.api.user["userId"])), + ("courseId", str(course["resourceId"])), + ( + "userName", + str( + self.api.user.get("userName", self.api.user.get("realName", "")) + ), + ), + ("projectType", "special"), + ("projectId", "undefined"), + ("protocol", "true"), + ("link", str(link)), + ("weiban", "weiban"), + ("certificateId", "undefined"), + ("userActivityState", "undefined"), + ("step", "undefined"), + ("index", "undefined"), + ("viewStep", "undefined"), + ] + ) + return urlunsplit( + ( + parts.scheme, + parts.netloc, + parts.path, + urlencode(query), + parts.fragment, + ) ) - return url # ---- tenant / progress -------------------------------------------------- @@ -491,17 +825,30 @@ def get_tenant_code(self) -> str: self.log.error("学校全称不能为空") return "" tenant_list = self.api.get_tenant_list_with_letter() - if tenant_list.get("code", -1) == "0": - self.log.info("获取学校列表成功") + groups = tenant_list.get("data") if _check_code_ok(tenant_list) else None + if not isinstance(groups, list): + self.log.error( + f"获取学校列表失败或结构无效:{_brief_response(tenant_list)}" + ) + return "" + self.log.info("获取学校列表成功") tenant_names = [] maybe_names = [] - for item in tenant_list.get("data", []): - for entry in item.get("list", []): - name = entry.get("name", "") + for item in groups: + entries = item.get("list") if isinstance(item, dict) else None + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or "") + code = str(entry.get("code") or "") + if not name or not code: + continue tenant_names.append(name) if self.tenant_name == name.strip(): - self.log.success(f"找到学校代码: {entry['code']}") - return entry["code"] + self.log.success(f"找到学校代码: {code}") + return code if self.tenant_name in name: maybe_names.append(name) self.log.error(f"{tenant_names}") @@ -527,7 +874,36 @@ def get_progress( if output: self.log.warning(f"{project_prefix} 获取进度失败:{progress}") return progress - data = progress.get("data", {}) + data = progress.get("data") + required_keys = ( + "requiredNum", + "requiredFinishedNum", + "optionalNum", + "optionalFinishedNum", + "pushNum", + "pushFinishedNum", + "examNum", + "examFinishedNum", + ) + if not isinstance(data, dict): + message = "进度响应 data 不是对象" + if output: + self.log.error(f"{project_prefix} {message}") + return {"code": "-1", "detailCode": "client_validation", "msg": message} + try: + counts = {key: int(data[key]) for key in required_keys} + except (KeyError, TypeError, ValueError): + message = "进度响应缺少合法计数字段" + if output: + self.log.error(f"{project_prefix} {message}:{progress}") + return {"code": "-1", "detailCode": "client_validation", "msg": message} + if any(value < 0 for value in counts.values()): + message = "进度响应包含负数计数" + if output: + self.log.error(f"{project_prefix} {message}:{progress}") + return {"code": "-1", "detailCode": "client_validation", "msg": message} + data = {**data, **counts} + progress = {**progress, "data": data} if self.study_force: # force 模式会重新学习所有课程,剩余量按总数计算 required = data["requiredNum"] @@ -594,11 +970,13 @@ def login(self) -> dict | None: 失败 10 次后转为手动输入(打开图片浏览器),再额外给 3 次机会。 :return: 成功返回 self.api.user,失败返回 None """ + self._raise_if_stopped() if self.api.user.get("userId"): return self.api.user retry_limit = 10 # 前 10 次 OCR 自动识别,后 3 次手动输入 for i in range(retry_limit + 3): + self._raise_if_stopped() if i > 0: self.log.warning(f"登录失败,正在重试 {i}/{retry_limit + 2} 次") verify_time = self.api.get_timestamp(13, 0) @@ -607,7 +985,7 @@ def login(self) -> dict | None: verify_code = LoginCaptchaSolver.recognize(verify_image, self.log) if not verify_code: continue - elif is_non_interactive(): + elif self.non_interactive: # 无交互模式:不阻塞等待手动输入,直接判定失败 self.log.error( "验证码 OCR 连续失败且处于无交互模式,无法手动输入验证码," @@ -615,22 +993,22 @@ def login(self) -> dict | None: ) break else: - account_id = ( - self.api.account or self.api.user.get("userId") or "unknown" - ) - captcha_dir = os.path.join(base_path, "logs", account_id) - os.makedirs(captcha_dir, exist_ok=True) - captcha_path = os.path.join(captcha_dir, "verify_code.png") - with open(captcha_path, "wb") as f: + self.captcha_debug_dir.mkdir(parents=True, exist_ok=True) + captcha_path = self.captcha_debug_dir / "verify_code.png" + with captcha_path.open("wb") as f: f.write(verify_image) - webbrowser.open(f"file://{captcha_path}") - verify_code = self._prompt( - f"[{account_id}] 请在 {captcha_path} 查看验证码图片输入验证码:" - ) try: - os.remove(captcha_path) - except OSError: - pass + webbrowser.open(captcha_path.as_uri()) + verify_code = self._prompt( + f"请在 {captcha_path} 查看验证码图片并输入验证码:" + ) + finally: + try: + captcha_path.unlink(missing_ok=True) + except OSError as exc: + # 图片可能仍被浏览器/杀毒软件占用;清理失败不应 + # 吞掉已经输入的验证码,更不能阻止登录请求。 + self.log.debug(f"验证码图片清理失败:{exc}") res = self.api.login(verify_code, int(verify_time)) if res.get("detailCode") == "67": self.log.warning("验证码识别失败,正在重试") @@ -645,29 +1023,39 @@ def login(self) -> dict | None: # ---- project list & per-project cycle ---------------------------------- - def _get_project_list(self) -> list[dict]: + def _get_project_list(self) -> list[dict] | None: """获取账号全部进行中的项目列表(含实验室课程合并)""" - my_project = self.api.list_my_project() - if not _check_code_ok(my_project): - self.log.error(f"获取任务列表失败:{my_project}") - return [] - my_project = my_project.get("data", []) + response = self.api.list_my_project() + my_project = response.get("data") if _check_code_ok(response) else None + if not isinstance(my_project, list) or not all( + isinstance(project, dict) for project in my_project + ): + self.log.error(f"获取任务列表失败或结构无效:{response}") + return None + my_project = list(my_project) completion = self.api.list_completion() - if not _check_code_ok(completion): - self.log.error(f"获取模块完成情况失败:{completion}") - else: - showable_modules = [ - d["module"] for d in completion.get("data", []) if d["showable"] == 1 - ] - if "labProject" in showable_modules: - self.log.info("加载实验室课程") - lab_project = self.api.lab_index() - if not _check_code_ok(lab_project): - self.log.error(f"获取实验室课程失败:{lab_project}") - current = lab_project.get("data", {}).get("current") or {} - if current: - my_project.append(current) + modules = completion.get("data") if _check_code_ok(completion) else None + if not isinstance(modules, list) or not all( + isinstance(item, dict) and "module" in item and "showable" in item + for item in modules + ): + self.log.error(f"获取模块完成情况失败或结构无效:{completion}") + return None + showable_modules = [item["module"] for item in modules if item["showable"] == 1] + if "labProject" in showable_modules: + self.log.info("加载实验室课程") + lab_project = self.api.lab_index() + data = lab_project.get("data") if _check_code_ok(lab_project) else None + if not isinstance(data, dict): + self.log.error(f"获取实验室课程失败:{lab_project}") + return None + current = data.get("current") or {} + if current: + if not isinstance(current, dict): + self.log.error(f"实验室课程结构无效:{lab_project}") + return None + my_project.append(current) return my_project def run_project_cycle( @@ -678,7 +1066,7 @@ def run_project_cycle( random_answer: bool, exam_question_time: str, exam_submit_match_rate: int, - ) -> None: + ) -> WorkflowResult: """按项目交替执行:每个项目先完成课程学习,再完成考试,然后 切换到下一个项目(用户要求的顺序:项目 A 学习+考试 → 项目 B 学习+考试)。 @@ -688,7 +1076,7 @@ def run_project_cycle( exam = exam_mode != "false" if not study and not exam: self.log.info("学习与考试均未开启,跳过") - return + return WorkflowResult.success("学习与考试均未开启", skipped=1) if study: mode_desc = {"true": "正常", "force": "强制重新学习"}.get( @@ -704,27 +1092,56 @@ def run_project_cycle( self.log.info(f"考试模式: {mode_desc}") projects = self._get_project_list() + if projects is None: + return WorkflowResult.failed_result("项目列表加载失败") if not projects: self.log.warning("当前没有进行中的项目。") - return + return WorkflowResult.success("当前没有进行中的项目", skipped=1) + overall = WorkflowResult.success() for project in projects: + self._raise_if_stopped() project_name = project.get("projectName", "未知项目") user_project_id = project.get("userProjectId", "") self.log.info(f"===== 开始处理项目:{project_name} =====") if not user_project_id: self.log.warning(f"{project_name}:缺少 userProjectId,跳过") + overall = overall.combine( + WorkflowResult.incomplete( + f"{project_name} 缺少 userProjectId", + skipped=1, + ) + ) continue + study_result = WorkflowResult.success() if study: - self.run_study(study_time, study_mode, only_project=project) + study_result = self.run_study( + study_time, + study_mode, + only_project=project, + ) + overall = overall.combine(study_result) if exam: - self.run_exam( + if study and not study_result.ok: + self.log.error( + f"{project_name} 学习阶段未完整确认,安全跳过该项目考试" + ) + overall = overall.combine( + WorkflowResult.incomplete( + f"{project_name} 因学习不完整跳过考试", + skipped=1, + ) + ) + continue + exam_result = self.run_exam( exam_mode=exam_mode, random_answer=random_answer, exam_question_time=exam_question_time, exam_submit_match_rate=exam_submit_match_rate, only_project=project, ) + overall = overall.combine(exam_result) + return overall # ---- study -------------------------------------------------------------- @@ -733,7 +1150,7 @@ def run_study( study_time: str | int, study_mode: str = "true", only_project: dict | None = None, - ) -> None: + ) -> WorkflowResult: """主学习流程入口:遍历所有项目 → 分类 → 课程,逐门学习 :param study_time: 每门课学习时长 "基础时间,随机上限"(秒),如 "20,10" :param study_mode: 学习模式,"force" 时忽略完成状态全部重新学习 @@ -762,37 +1179,66 @@ def run_study( my_project = [only_project] else: my_project = self._get_project_list() + if my_project is None: + return WorkflowResult.failed_result("学习项目列表加载失败") if not my_project: self.log.warning("当前没有进行中的学习项目。") + return WorkflowResult.success("当前没有学习项目", skipped=1) + completed = 0 + failed = 0 + skipped = 0 + details: list[str] = [] for task in my_project: - project_prefix = task["projectName"] + self._raise_if_stopped() + if not isinstance(task, dict) or not all( + task.get(key) for key in ("projectName", "userProjectId") + ): + failed += 1 + details.append("项目结构无效") + self.log.error(f"项目响应缺少必要字段:{task}") + continue + project_prefix = str(task["projectName"]) # 项目未开始(未到开课时间等):官方 H5 弹 message 并禁止进入,同样提示后跳过 startable, notice = self._project_startable(task) if not startable: self.log.warning( f"{project_prefix}:{notice or '项目尚未开放,暂不可学习'},跳过" ) + skipped += 1 + failed += 1 + details.append(f"{project_prefix}: 项目尚不可学习") continue self.log.info(f"开始处理任务:{project_prefix}") # 对齐官方 H5:进入学习项目页即发 initIndex(项目详情初始化), # 不依赖课程是否加载 apicenext.js try: - self.api.init_index(task["userProjectId"]) + init_response = self.api.init_index(task["userProjectId"]) except PermissionError: raise # Token 失效,立即终止该账号 - except OSError as e: # 网络异常不阻断学习 - self.log.warning(f"初始化学习索引失败(网络异常):{e}") + except (OSError, APIResponseError) as exc: + self.log.error(f"初始化学习索引失败:{exc}") + failed += 1 + details.append(f"{project_prefix}: 初始化失败") + continue + if not _check_code_ok(init_response): + self.log.error(f"初始化学习索引失败:{init_response}") + failed += 1 + details.append(f"{project_prefix}: 初始化响应失败") + continue progress = self.get_progress(task["userProjectId"], project_prefix) - progress_data = ( - progress.get("data", {}) if _check_code_ok(progress) else {} - ) + if not _check_code_ok(progress): + failed += 1 + details.append(f"{project_prefix}: 进度响应失败") + continue + progress_data = progress["data"] choose_types = [ (3, "必修课", "requiredNum", "requiredFinishedNum"), (1, "推送课", "pushNum", "pushFinishedNum"), (2, "自选课", "optionalNum", "optionalFinishedNum"), ] + project_ok = True for choose_type in choose_types: # 只跳过"项目无该类型需求"(需求数=0)的类型: # - need > 0(如项目确实要完成 5 门自选课)→ 正常学习该类型; @@ -808,25 +1254,39 @@ def run_study( continue # 官方 H5 课程主页按项目 projectMode 分流课程列表: # mode==1 折叠分类(listCategory+listCourse);mode≠1 扁平分页 - # (listFlatCourse.do)。取不到 mode 时按折叠路径(原行为)兜底。 + # (listFlatCourse.do)。结构不明时不猜测分流。 try: simple = self.api.get_project_simple(task["userProjectId"]) - project_mode = int( - simple.get("data", {}).get("projectMode", 1) or 1 - ) except PermissionError: raise # Token 失效,立即终止该账号 - except OSError as e: - self.log.debug(f"获取项目模式失败(网络异常):{e}") - project_mode = 1 + except (OSError, APIResponseError) as exc: + self.log.error(f"获取项目模式失败:{exc}") + project_ok = False + break + simple_data = simple.get("data") if _check_code_ok(simple) else None + if ( + not isinstance(simple_data, dict) + or "projectMode" not in simple_data + ): + self.log.error(f"获取项目模式响应无效:{simple}") + project_ok = False + break + try: + project_mode = int(simple_data["projectMode"]) + except (TypeError, ValueError): + self.log.error(f"项目模式值无效:{simple_data['projectMode']}") + project_ok = False + break if project_mode != 1: - self._study_flat_courses( + if not self._study_flat_courses( task, choose_type, project_prefix, answers_json, force_restudy, - ) + ): + project_ok = False + break continue try: @@ -835,28 +1295,71 @@ def run_study( ) except PermissionError: raise # Token 失效,立即终止该账号 - except OSError as e: # 网络异常(DNS/连接/SSL)跳过本分类,不中断整个账号 - self.log.error(f"获取 {choose_type[1]} 分类失败(网络异常):{e}") - continue + except (OSError, APIResponseError) as exc: + self.log.error(f"获取 {choose_type[1]} 分类失败:{exc}") + project_ok = False + break if not _check_code_ok(categories): self.log.error(f"获取 {choose_type[1]} 分类失败:{categories}") - continue + project_ok = False + break + category_list = categories.get("data") + if not isinstance(category_list, list) or not all( + isinstance(category, dict) for category in category_list + ): + self.log.error(f"获取 {choose_type[1]} 分类结构无效") + project_ok = False + break - for category in categories.get("data", []): + for category in category_list: + if not all( + key in category + for key in ( + "categoryName", + "categoryCode", + "finishedNum", + "totalNum", + ) + ): + self.log.error(f"课程分类缺少必要字段:{category}") + project_ok = False + break category_prefix = ( f"{choose_type[1]} {project_prefix}/{category['categoryName']}" ) - if ( - not force_restudy - and category["finishedNum"] >= category["totalNum"] - ): + try: + category_finished = int(category["finishedNum"]) + category_total = int(category["totalNum"]) + except (TypeError, ValueError): + self.log.error(f"课程分类计数无效:{category}") + project_ok = False + break + if not force_restudy and category_finished >= category_total: continue - courses = self.api.list_course( - task["userProjectId"], category["categoryCode"], choose_type[0] + try: + courses = self.api.list_course( + task["userProjectId"], + category["categoryCode"], + choose_type[0], + ) + except PermissionError: + raise + except (OSError, APIResponseError) as exc: + self.log.error(f"获取课程列表失败:{exc}") + project_ok = False + break + course_list = ( + courses.get("data") if _check_code_ok(courses) else None ) - for course in courses.get("data", []): - if not force_restudy and int(course.get("finished", 0)) == 1: + if not isinstance(course_list, list) or not all( + isinstance(course, dict) for course in course_list + ): + self.log.error(f"获取课程列表失败或结构无效:{courses}") + project_ok = False + break + for course in course_list: + if not force_restudy and _course_finished(course): continue if not self._learn_course( course, @@ -866,42 +1369,74 @@ def run_study( answers_json, force_restudy, ): - return + project_ok = False + break + if not project_ok: + break + if not project_ok: + break - self.log.success(f"{project_prefix} 课程学习完成") - self._check_project_course_done(task, project_prefix) + if project_ok and self._check_project_course_done(task, project_prefix): + self.log.success(f"{project_prefix} 课程学习已完整确认") + completed += 1 + else: + self.log.error(f"{project_prefix} 课程学习未完整确认") + failed += 1 + details.append(f"{project_prefix}: 学习未完整确认") + + if failed: + return WorkflowResult.incomplete( + "部分学习项目未完成", + completed=completed, + failed=failed, + skipped=skipped, + details=tuple(details), + ) + return WorkflowResult.success( + "学习阶段完成", + completed=completed, + skipped=skipped, + ) - def _check_project_course_done( - self, task: dict, project_prefix: str - ) -> None: + def _check_project_course_done(self, task: dict, project_prefix: str) -> bool: """校验项目各类型课程完成数是否达到需求数,不足时告警。 - 服务端进度更新可能有延迟,因此只告警不重试;覆盖折叠/扁平两种 - 列表路径学完后的盲区(如扁平分页提前结束导致漏学)。 + 服务端进度更新可能有延迟,因此在有界退避窗口内重读;覆盖折叠/扁平 + 两种列表路径学完后的盲区(如扁平分页提前结束导致漏学)。 """ try: - progress = self.get_progress( - task["userProjectId"], project_prefix, output=False - ) - if not _check_code_ok(progress): - return - data = progress.get("data", {}) - for _, label, need_key, finished_key in [ - (3, "必修课", "requiredNum", "requiredFinishedNum"), - (1, "推送课", "pushNum", "pushFinishedNum"), - (2, "自选课", "optionalNum", "optionalFinishedNum"), - ]: - need = int(data.get(need_key, 0) or 0) - finished = int(data.get(finished_key, 0) or 0) - if need > 0 and finished < need: - self.log.warning( - f"{project_prefix} {label}完成 {finished}/{need}," - f"未达到需求数,请检查是否漏学" - ) - except PermissionError: + for attempt in range(len(PROGRESS_POLL_DELAYS) + 1): + progress = self.get_progress( + task["userProjectId"], project_prefix, output=False + ) + if not _check_code_ok(progress): + return False + data = progress.get("data", {}) + completed = True + for _, label, need_key, finished_key in [ + (3, "必修课", "requiredNum", "requiredFinishedNum"), + (1, "推送课", "pushNum", "pushFinishedNum"), + (2, "自选课", "optionalNum", "optionalFinishedNum"), + ]: + need = int(data.get(need_key, 0) or 0) + finished = int(data.get(finished_key, 0) or 0) + if need > 0 and finished < need: + completed = False + if attempt == len(PROGRESS_POLL_DELAYS): + self.log.warning( + f"{project_prefix} {label}完成 {finished}/{need}," + f"未达到需求数,请检查是否漏学" + ) + if completed: + return True + if attempt < len(PROGRESS_POLL_DELAYS): + self._sleep(PROGRESS_POLL_DELAYS[attempt]) + return False + except (InterruptedError, PermissionError): raise # Token 失效,立即终止该账号 - except OSError as e: - self.log.debug(f"校验学习完成进度失败(网络异常):{e}") + except (OSError, APIResponseError, ResponseValidationError) as e: + self.log.debug(f"校验学习完成进度失败:{e}") + return False def _learn_course( self, @@ -914,21 +1449,25 @@ def _learn_course( ) -> bool: """学习单门课程并校验进度是否更新(折叠/扁平两条列表路径共用)。 - :return: True 可继续下一门;False 表示账号异常/锁定,应停止本账号 + :return: True 表示本门已确认完成;False 表示本门不完整 """ + if not all(course.get(key) for key in ("resourceName", "resourceId")): + self.log.error(f"{category_prefix}:课程响应缺少必要字段:{course}") + return False course_prefix = f"{category_prefix}/{course['resourceName']}" try: progress_before = self.get_progress( task["userProjectId"], project_prefix, output=False ) - finished_before = 0 - if progress_before.get("code", -1) == "0": - d = progress_before["data"] - finished_before = ( - d["requiredFinishedNum"] - + d["pushFinishedNum"] - + d["optionalFinishedNum"] - ) + if not _check_code_ok(progress_before): + self.log.error(f"{course_prefix}:学习前进度响应无效") + return False + d = progress_before["data"] + finished_before = ( + d["requiredFinishedNum"] + + d["pushFinishedNum"] + + d["optionalFinishedNum"] + ) ok = self._study_one_course( course, task, @@ -938,27 +1477,42 @@ def _learn_course( force_restudy, ) if not ok: - self.log.error("检测到行为异常或账号锁定,已停止本账号后续学习") return False - progress_after = self.get_progress(task["userProjectId"], project_prefix) - if progress_after.get("code", -1) == "0": + progress_after = None + finished_after = finished_before + for attempt in range(len(PROGRESS_POLL_DELAYS) + 1): + progress_after = self.get_progress( + task["userProjectId"], project_prefix + ) + if not _check_code_ok(progress_after): + self.log.error(f"{course_prefix}:学习后进度响应无效") + return False d = progress_after["data"] finished_after = ( d["requiredFinishedNum"] + d["pushFinishedNum"] + d["optionalFinishedNum"] ) - if finished_after <= finished_before: - self.log.warning( - f"{course_prefix}:完课成功但进度未更新,请手动检查" + if force_restudy or finished_after > finished_before: + break + if attempt < len(PROGRESS_POLL_DELAYS): + self.log.debug( + f"{course_prefix}:完课接口成功但进度尚未更新," + f"{PROGRESS_POLL_DELAYS[attempt]:g}s 后重试" ) - except PermissionError: + self._sleep(PROGRESS_POLL_DELAYS[attempt]) + if not force_restudy and finished_after <= finished_before: + self.log.warning( + f"{course_prefix}:完课接口成功但进度未更新,标记为不完整" + ) + return False + self.log.success(f"{course_prefix} 完成") + return True + except (InterruptedError, PermissionError): raise # Token 失效,立即终止该账号 - except OSError as e: - # 网络异常(DNS/连接/SSL)跳过本门课程,不中断整个账号; - # 未完成的课程下次运行会自动重学 - self.log.warning(f"{course_prefix}:网络异常,跳过本门课程({e})") - return True + except (OSError, APIResponseError, ResponseValidationError) as e: + self.log.warning(f"{course_prefix}:学习未完成({e})") + return False def _study_flat_courses( self, @@ -967,7 +1521,7 @@ def _study_flat_courses( project_prefix: str, answers_json: dict, force_restudy: bool, - ) -> None: + ) -> bool: """官方 projectMode≠1 的扁平分页课程列表路径(listFlatCourse.do) 官方 H5 课程主页按 project/getSimple.do 的 projectMode 分流: @@ -996,22 +1550,38 @@ def _study_flat_courses( ) except PermissionError: raise # Token 失效,立即终止该账号 - except OSError as e: # 网络异常跳过本类型,不中断整个账号 - self.log.error(f"获取 {label} 课程失败(网络异常):{e}") - return + except (OSError, APIResponseError) as e: + self.log.error(f"获取 {label} 课程失败:{e}") + return False if not _check_code_ok(res): self.log.error(f"获取 {label} 课程失败:{res}") - return - data = res.get("data") or {} - courses_all.extend(data.get("paginateData") or []) - total_pages = int(data.get("totalPages", 1) or 1) + return False + data = res.get("data") + if not isinstance(data, dict): + self.log.error(f"获取 {label} 课程响应 data 无效") + return False + page_courses = data.get("paginateData") + if not isinstance(page_courses, list) or not all( + isinstance(course, dict) for course in page_courses + ): + self.log.error(f"获取 {label} 课程分页列表结构无效") + return False + courses_all.extend(page_courses) + try: + total_pages = int(data["totalPages"]) + except (KeyError, TypeError, ValueError): + self.log.error(f"获取 {label} 课程总页数无效") + return False + if total_pages < 1: + self.log.error(f"获取 {label} 课程总页数无效:{total_pages}") + return False # 官方结束条件:totalPages <= pageNo if total_pages <= page_no: break page_no += 1 for course in courses_all: - if not force_restudy and int(course.get("finished", 0)) == 1: + if not force_restudy and _course_finished(course): continue category_name = course.get("categoryName") or label category_prefix = f"{label} {project_prefix}/{category_name}" @@ -1023,7 +1593,8 @@ def _study_flat_courses( answers_json, force_restudy, ): - return + return False + return True @staticmethod def _is_account_blocked(res: dict) -> bool: @@ -1035,7 +1606,9 @@ def _is_account_blocked(res: dict) -> bool: raw = str(res.get("raw", "")) if detail in {"10018", "701"}: return True - return ("行为存在异常" in msg or "Account locked" in raw or "Account locked" in msg) + return ( + "行为存在异常" in msg or "Account locked" in raw or "Account locked" in msg + ) def _study_one_course( self, @@ -1047,14 +1620,14 @@ def _study_one_course( force_restudy: bool, ) -> bool: """处理单门课程:加载 apicenext.js 的走 jupiter 翻页轨迹; -无 apicenext 的默认只答题+完课(对齐官方页面行为),配置 -jupiter_fallback=true 时也补翻页轨迹。再答题,最后完课。 + 无 apicenext 的默认只答题+完课(对齐官方页面行为),配置 + jupiter_fallback=true 时也补翻页轨迹。再答题,最后完课。 - :return: True 可继续下一门;False 表示账号异常/锁定,应停止本账号 + :return: True 表示完课接口成功;False 表示流程不完整 """ course_prefix = f"{category_prefix}/{course['resourceName']}" - if not force_restudy and int(course.get("finished", 0)) == 1: + if not force_restudy and _course_finished(course): return True self.log.info(f"学习: {course_prefix}") @@ -1064,7 +1637,7 @@ def _study_one_course( if not _check_code_ok(study_res): msg = study_res.get("message") or study_res.get("msg") or "课程暂时无法学习" self.log.warning(f"{course_prefix}:{msg},跳过") - return True + return False study_start = time.time() # 官方 H5 完课不依赖列表对象的 userCourseId:课程页 URL 由 @@ -1083,8 +1656,10 @@ def _study_one_course( if uid and uid[0]: course["userCourseId"] = uid[0] else: - self.log.warning(f"{course_prefix}:未获取到学习记录(userCourseId 为空),跳过") - return True + self.log.warning( + f"{course_prefix}:未获取到学习记录(userCourseId 为空),跳过" + ) + return False course_code = "" url_path = urlparse(course_url).path @@ -1108,16 +1683,15 @@ def _study_one_course( nonstr_map = item_info.get("nonstr_map", {}) total_step = item_info.get("total_step", 0) uses_apinext = item_info.get("uses_apinext", False) - # jupiter 学习轨迹上报始终带本次会话 uuid(浏览器每次学习都上报) - apinext_no = str(uuid4()) - # sdk.js 仅在 apicenext.js 定义了全局 uuid 时才带 uniqueNo; - # 无 apinext 的课传 uniqueNo 会被判行为异常 (10018) - unique_no = str(uuid4()) if uses_apinext else None # 1. jupiter finish=2 翻页轨迹:官方仅在加载 apicenext.js 的课程里上报 # (页面 item.js 调 callApinext);非 apicnext 课程默认不发,除非配置 # jupiter_fallback=true(个别学校要求全部微课都有轨迹) trace_enabled = uses_apinext or self.jupiter_fallback + # 同一次 apicenext 学习只创建一个 UUID,翻页、完成轨迹和 JSONP 完课 + # 全程复用。普通课程仍不向完课接口发送 uniqueNo。 + trace_unique_no = str(uuid4()) if trace_enabled else "" + finish_unique_no = trace_unique_no if uses_apinext else None if total_step and trace_enabled: self.log.info( f"total_step={total_step} ({item_info.get('total_step_source', '')})" @@ -1130,45 +1704,60 @@ def _study_one_course( task["userProjectId"], nonstr_map, total_step, - unique_no=apinext_no, + unique_no=trace_unique_no, finish=2, ) # 2. 获取并回答题目(翻页后题目才可用) question_data = self.api.list_question(course["resourceId"]) - if question_data and question_data.get("code") == "0": - data = question_data.get("data", {}) - for qlist, label, save_func in [ - ( - data.get("viewpointQuestionList", []), - "观点题", - self.api.save_question, - ), - ( - data.get("examQuestionList", []), - "课后习题", - self.api.save_exam_question, - ), - ]: - if qlist: - self.log.info(f" {label} {len(qlist)} 道") - for i, q in enumerate(qlist): - # 无论题库命中还是 fallback 都已提交答案, - # 对用户而言课程题目作答流程已完成 - self._answer_question( - q, - answers_json, - course["resourceId"], - save_func, - source_str, - ) - self.log.info(f" {i + 1}/{len(qlist)} 已完成") - time.sleep(0.5) - elif question_data: - self.log.info(f" list_question: code={question_data.get('code')}") - if item_info.get("has_exam") and not question_data.get("data", {}).get( - "examQuestionList" - ): + if not _check_code_ok(question_data): + self.log.error(f"{course_prefix} 获取课程题目失败:{question_data}") + return False + question_payload = question_data.get("data") + if not isinstance(question_payload, dict): + self.log.error(f"{course_prefix} 课程题目响应结构无效") + return False + for key, label, save_func in [ + ( + "viewpointQuestionList", + "观点题", + self.api.save_question, + ), + ( + "examQuestionList", + "课后习题", + self.api.save_exam_question, + ), + ]: + qlist = question_payload.get(key, []) + if not isinstance(qlist, list): + self.log.error(f"{course_prefix} {label}列表结构无效") + return False + if qlist: + self.log.info(f" {label} {len(qlist)} 道") + for i, question in enumerate(qlist): + if ( + not isinstance(question, dict) + or not question.get("id") + or not isinstance(question.get("optionList"), list) + or not question["optionList"] + ): + self.log.error(f"{course_prefix} {label}题目结构无效") + return False + try: + self._answer_question( + question, + answers_json, + course["resourceId"], + save_func, + source_str, + ) + except ResponseValidationError as exc: + self.log.error(f"{course_prefix} {label}作答失败:{exc}") + return False + self.log.info(f" {i + 1}/{len(qlist)} 已完成") + self._sleep(0.5) + if item_info.get("has_exam") and not question_payload.get("examQuestionList"): self.log.info(" 检测到题目标记但 list_question 无课后习题,可能为内联题目") # 3. 确保满足最低学习时长(服务端要求 study 后至少学习 study_time 秒才接受完课) @@ -1196,12 +1785,12 @@ def _study_one_course( if self.video_speed > 0 and video_duration > 0: deadline = time.monotonic() + remaining while remaining > 0: - time.sleep(min(30, remaining)) + self._sleep(min(30, remaining)) remaining = max(0, deadline - time.monotonic()) if remaining > 0: self.log.info(f"视频剩余 {self._format_duration(remaining)}") else: - time.sleep(remaining) + self._sleep(remaining) # 4. jupiter finish=1 完成标记(提交前上报学习完成,与翻页轨迹同条件) if total_step and trace_enabled: @@ -1211,19 +1800,28 @@ def _study_one_course( task["userProjectId"], nonstr_map, total_step, - unique_no=apinext_no, + unique_no=trace_unique_no, finish=1, ) - time.sleep(2) + self._sleep(2) # 5. 完课 - res = self._finish_course(course, task, query, course_url, unique_no) + res = self._finish_course( + course, + task, + query, + course_url, + finish_unique_no, + ) # 完课走 JSONP(sdk.js finishWxCourse):checkCode 只认 code∈{0,1} if not _check_code_ok(res, allow_200=False): self.log.error(f"{course_prefix} 完成失败:{res}") - return not self._is_account_blocked(res) - - self.log.success(f"{course_prefix} 完成") + if self._is_account_blocked(res): + raise AccountBlockedError( + str(res.get("msg") or "系统检测到行为异常或账号已锁定"), + detail_code=str(res.get("detailCode", "")), + ) + return False return True def _finish_course( @@ -1277,6 +1875,8 @@ def _finish_course( return check_res self.log.success("课程验证码校验通过") finish_kwargs["token"] = check_res.get("data", "") + except InterruptedError: + raise except PermissionError: raise # Token 失效,立即终止该账号 except Exception as e: # noqa: BLE001 -- 浏览器自动化可能抛任意异常,降级为完成失败 @@ -1293,7 +1893,7 @@ def run_exam( exam_question_time: str = "3,3", exam_submit_match_rate: int = 90, only_project: dict | None = None, - ): + ) -> WorkflowResult: """考试主入口 流程:加载题库 → 遍历项目/计划 → 无感验证码 → 获取试卷 → @@ -1321,21 +1921,42 @@ def run_exam( self.exam_mode = exam_mode answers_json = self._load_answers_json() + completed = 0 + failed = 0 + skipped = 0 + details: list[str] = [] + + def mark_failed(message: str) -> None: + nonlocal failed + failed += 1 + details.append(message) if only_project is not None: projects = [only_project] else: projects = self._get_project_list() + if projects is None: + return WorkflowResult.failed_result("考试项目列表加载失败") if not projects: self.log.warning("当前没有进行中的项目可考试。") + return WorkflowResult.success("当前没有考试项目", skipped=1) for project in projects: + self._raise_if_stopped() + if not isinstance(project, dict) or not all( + project.get(key) for key in ("projectName", "userProjectId") + ): + self.log.error(f"考试项目结构无效:{project}") + mark_failed("考试项目结构无效") + continue # 项目未开始(未到开课时间等):官方 H5 弹 message 并禁止进入,同样提示后跳过 startable, notice = self._project_startable(project) if not startable: self.log.warning( f"{project['projectName']}:{notice or '项目尚未开放,暂不可考试'},跳过" ) + skipped += 1 + mark_failed(f"{project['projectName']}: 项目尚不可考试") continue self.log.info(f"开始考试项目 {project['projectName']}") user_project_id = project["userProjectId"] @@ -1343,38 +1964,79 @@ def run_exam( exam_plans = self.api.exam_list_plan(user_project_id) if not _check_code_ok(exam_plans): self.log.error(f"获取考试计划失败:{exam_plans}") - return - exam_plans = exam_plans["data"] + mark_failed(f"{project['projectName']}: 考试计划响应失败") + continue + exam_plans = exam_plans.get("data") + if not isinstance(exam_plans, list) or not all( + isinstance(plan, dict) for plan in exam_plans + ): + self.log.error(f"考试计划列表结构无效:{exam_plans}") + mark_failed(f"{project['projectName']}: 考试计划结构无效") + continue for plan in exam_plans: + self._raise_if_stopped() + required_plan_keys = { + "id", + "examPlanId", + "examPlanName", + "examOddNum", + "examFinishNum", + "examScore", + "passScore", + } + if not required_plan_keys.issubset(plan): + self.log.error(f"考试计划缺少必要字段:{plan}") + mark_failed(f"{project['projectName']}: 考试计划字段缺失") + continue plan_name = f"{project['projectName']}/{plan['examPlanName']}" - exam_odd_num = plan.get("examOddNum", 0) - exam_finish_num = plan.get("examFinishNum", 0) - exam_score = plan.get("examScore", 0) - pass_score = plan.get("passScore", 0) + try: + exam_odd_num = int(plan["examOddNum"]) + exam_finish_num = int(plan["examFinishNum"]) + except (TypeError, ValueError, OverflowError): + self.log.error(f"{plan_name} 考试计划计数字段无效") + mark_failed(f"{plan_name}: 考试计划计数无效") + continue + exam_score = _finite_score(plan["examScore"]) + pass_score = _finite_score(plan["passScore"]) + if exam_score is None or pass_score is None: + # 非有限分数会让 >= 判定失真(如 -inf 被当成"已及格"), + # 宁可跳过该计划也不能基于它决定是否交卷。 + self.log.error(f"{plan_name} 考试计划分数字段无效") + mark_failed(f"{plan_name}: 考试计划分数无效") + continue # ── 已考过的考试,显示历史成绩 ── - full_score = 100 + # 满分仅用于日志和 perfect 模式的跳过判断;preparePaper 在此 + # 处失败或结构异常时按默认 100 分继续,不能拖垮整个账号。 + full_score = 100.0 if exam_finish_num > 0: try: pp = self.api.exam_prepare_paper(plan["id"]) - full_score = pp.get("data", {}).get("paperScore", 100) except PermissionError: raise # Token 失效,立即终止该账号 - except OSError: - pass + except (OSError, APIResponseError) as exc: + self.log.debug(f"{plan_name} 读取试卷总分失败:{exc}") + else: + pp_data = pp.get("data") if _check_code_ok(pp) else None + if isinstance(pp_data, dict): + parsed_full = _finite_score(pp_data.get("paperScore")) + # 0 分满分同样不可信,保持默认 100 + if parsed_full: + full_score = parsed_full self.log.info( f"{plan_name} 已考过 {exam_finish_num}/{exam_odd_num} 次," - f"最高 {exam_score}/{full_score}(及格线 {pass_score})" + f"最高 {exam_score:g}/{full_score:g}(及格线 {pass_score:g})" ) elif exam_odd_num > 0: - self.log.info( - f"{plan_name} 未考试,可考 {exam_odd_num} 次" - ) + self.log.info(f"{plan_name} 未考试,可考 {exam_odd_num} 次") # ── 根据 exam_mode 判断是否跳过 ── if exam_odd_num <= 0: self.log.info(f"{plan_name} 无剩余考试机会,跳过") + skipped += 1 + if exam_finish_num <= 0 or exam_score < pass_score: + mark_failed(f"{plan_name}: 未完成且无剩余机会") continue if ( @@ -1382,7 +2044,10 @@ def run_exam( and exam_finish_num > 0 and exam_score >= pass_score ): - self.log.info(f"{plan_name} 已及格 ({exam_score}分 >= {pass_score}分),跳过") + self.log.info( + f"{plan_name} 已及格 ({exam_score}分 >= {pass_score}分),跳过" + ) + skipped += 1 continue if ( @@ -1391,6 +2056,7 @@ def run_exam( and exam_score >= full_score ): self.log.info(f"{plan_name} 已满分 ({exam_score}分),跳过") + skipped += 1 continue # perfect 模式:只剩 1 次机会时,检查题库是否能全覆盖 @@ -1403,7 +2069,9 @@ def run_exam( self.log.warning(warning_msg) if exam_mode == "true" and exam_finish_num > 0: - self.log.info(f"{plan_name} 已完成 {exam_finish_num} 次,{plan_name} 继续考试以争取更好成绩") + self.log.info( + f"{plan_name} 已完成 {exam_finish_num} 次,{plan_name} 继续考试以争取更好成绩" + ) user_exam_plan_id = plan["id"] exam_plan_id = plan["examPlanId"] @@ -1413,6 +2081,18 @@ def run_exam( self.log.error( f"考试项目 {plan_name} 获取考试记录失败:{before_paper}" ) + mark_failed(f"{plan_name}: beforePaper 失败") + continue + before_data = before_paper.get("data") + if ( + not isinstance(before_data, dict) + or "isExistedNotSubmit" not in before_data + ): + self.log.error( + f"考试项目 {plan_name} 获取考试记录结构无效:{before_paper}" + ) + mark_failed(f"{plan_name}: beforePaper 结构无效") + continue prepare_paper = self.api.exam_prepare_paper(user_exam_plan_id) if not _check_code_ok(prepare_paper): @@ -1423,9 +2103,30 @@ def run_exam( ) else: self.log.error(f"获取考试信息失败:{prepare_paper}") + mark_failed(f"{plan_name}: preparePaper 失败") + continue + prepare_paper = prepare_paper.get("data") + prepare_keys = { + "questionNum", + "paperScore", + "answerTime", + "realName", + "userIDLabel", + } + if not isinstance(prepare_paper, dict) or not prepare_keys.issubset( + prepare_paper + ): + self.log.error(f"获取考试信息结构无效:{prepare_paper}") + mark_failed(f"{plan_name}: preparePaper 结构无效") + continue + try: + question_num = int(prepare_paper["questionNum"]) + except (TypeError, ValueError): + question_num = 0 + if question_num <= 0: + self.log.error(f"{plan_name} 题目数无效,禁止开始考试") + mark_failed(f"{plan_name}: 题目数无效") continue - prepare_paper = prepare_paper["data"] - question_num = prepare_paper["questionNum"] self.log.info( f"考试信息:用户:{prepare_paper['realName']},ID:{prepare_paper['userIDLabel']}," f"题目数:{question_num},试卷总分:{prepare_paper['paperScore']}," @@ -1445,34 +2146,78 @@ def run_exam( ) if not _check_code_ok(check_res): self.log.error(f"无感验证码校验失败:{check_res}") + mark_failed(f"{plan_name}: 验证码校验失败") continue self.log.success("无感验证码校验通过") + except InterruptedError: + raise except PermissionError: raise # Token 失效,立即终止该账号 except Exception as e: # noqa: BLE001 -- 浏览器自动化可能抛任意异常 self.log.error(f"无感验证码处理异常: {e}") + mark_failed(f"{plan_name}: 验证码处理异常") continue exam_paper = self.api.exam_start_paper(user_exam_plan_id) if not _check_code_ok(exam_paper): self.log.error(f"获取考试题目失败:{exam_paper}") - if exam_paper.get("detailCode") == "10018": - self.log.warning( - f"考试项目 {plan_name} 需要手动处理," - f"请在网站上开启一次考试后重试" - ) + mark_failed(f"{plan_name}: startPaper 失败") + continue + + exam_paper = exam_paper.get("data") + if not isinstance(exam_paper, dict): + self.log.error(f"{plan_name} 试卷响应 data 无效,禁止交卷") + mark_failed(f"{plan_name}: 试卷 data 无效") + continue + question_list = exam_paper.get("questionList") + if not isinstance(question_list, list) or not question_list: + self.log.error(f"{plan_name} 空试卷或题目列表无效,禁止交卷") + mark_failed(f"{plan_name}: 空试卷") + continue + if len(question_list) != question_num: + self.log.error( + f"{plan_name} 试卷题数 {len(question_list)} 与声明题数 " + f"{question_num} 不符,禁止交卷" + ) + mark_failed(f"{plan_name}: 题数不符") + continue + paper_valid = True + for question in question_list: + if not isinstance(question, dict) or not all( + key in question for key in ("id", "title", "type", "optionList") + ): + paper_valid = False + break + options = question["optionList"] + if not isinstance(options, list) or not options: + paper_valid = False + break + option_ids = [ + str(option.get("id", "")) + for option in options + if isinstance(option, dict) and option.get("id") + ] + if len(option_ids) != len(options) or len(set(option_ids)) != len( + option_ids + ): + paper_valid = False + break + if not paper_valid: + self.log.error(f"{plan_name} 试卷题目/选项结构无效,禁止交卷") + mark_failed(f"{plan_name}: 试卷结构无效") continue - exam_paper = exam_paper.get("data", {}) - question_list = exam_paper.get("questionList", []) - have_answer, no_answer = [], [] + have_answer: list[tuple[dict, list[str]]] = [] + no_answer: list[dict] = [] for question in question_list: - target = ( - have_answer - if clean_text(question["title"]) in answers_json - else no_answer + mapped_ids = self._answer_ids_for_question( + answers_json, + question, ) - target.append(question) + if mapped_ids: + have_answer.append((question, mapped_ids)) + else: + no_answer.append(question) match_rate = ( len(have_answer) / len(question_list) * 100 if question_list else 0 @@ -1482,22 +2227,26 @@ def run_exam( f"无答案的题目数:{len(no_answer)},题库匹配率:{match_rate:.1f}%" ) - # perfect 模式:匹配率不足且 random_answer=False 时警告 - if exam_mode == "perfect" and match_rate < 100 and not random_answer: - self.log.warning( - f"题库匹配率 {match_rate:.1f}% 不足 100%," - f"perfect 模式下手动作答可能存在风险" - ) - - # 检查提交匹配率 - if match_rate < exam_submit_match_rate and not random_answer: + # 安全门按“已映射到当前试卷合法选项 ID”的题数计算, + # 与是否启用随机答案无关。 + if match_rate < exam_submit_match_rate: self.log.error( f"题库匹配率 {match_rate:.1f}% 低于阈值 {exam_submit_match_rate}%," - f"且 random_answer=false,放弃交卷" + "禁止记录答案和交卷" ) + mark_failed(f"{plan_name}: 匹配率未达安全阈值") + continue + if exam_odd_num <= 1 and match_rate < 100: + self.log.error( + f"{plan_name} 只剩最后一次机会且题库未 100% 合法映射," + "禁止记录答案和交卷" + ) + mark_failed(f"{plan_name}: 最后一次机会安全门") continue # ── 处理无答案题目 ── + recorded_count = 0 + answer_failed = False for i, question in enumerate(no_answer): type_label = question.get("typeLabel", "未知") @@ -1516,8 +2265,8 @@ def run_exam( f"({type_label}),等待 {self._format_duration(use_time)}: " f"{question['title'][:40]}..." ) - time.sleep(use_time) - elif random_answer or is_non_interactive(): + self._sleep(use_time) + elif random_answer or self.non_interactive: # 自动随机作答:单选随机选一个,多选全选 # (无交互模式即使配置 random_answer=false 也走随机, # 避免阻塞等待终端输入) @@ -1530,15 +2279,17 @@ def run_exam( f"({type_label}),等待 {self._format_duration(use_time)}: " f"{question['title'][:40]}..." ) - time.sleep(use_time) + self._sleep(use_time) else: # 手动输入 self.log.info( f"[{i + 1}/{len(no_answer)}] 题目不在题库中,请手动选择答案" ) - print(f"题目类型:{type_label},题目标题:{question['title']}") + self.log.info( + f"题目类型:{type_label},题目标题:{question['title']}" + ) for j, opt in enumerate(question["optionList"]): - print(f"{j + 1}. {opt['content']}") + self.log.info(f"{j + 1}. {opt['content']}") opt_count = len(question["optionList"]) start_time = time.time() @@ -1576,6 +2327,30 @@ def run_exam( use_time = round(time.time() - start_time) + valid_option_ids = { + str(option["id"]) for option in question["optionList"] + } + selected_ids = [str(answer_id) for answer_id in answers_ids] + try: + question_type = int(question.get("type", 1)) + except (TypeError, ValueError): + question_type = 0 + cardinality_ok = ( + bool(selected_ids) + if question_type == 2 + else len(selected_ids) == 1 + ) + if ( + not cardinality_ok + or len(set(selected_ids)) != len(selected_ids) + or not set(selected_ids).issubset(valid_option_ids) + ): + self.log.error( + f"{plan_name} 题目 {question['id']} 产生空答案、" + "重复答案或非法答案 ID,禁止交卷" + ) + answer_failed = True + break self.log.info("正在提交当前答案") if not self.record_answer( user_exam_plan_id, @@ -1584,30 +2359,30 @@ def run_exam( answers_ids, exam_plan_id, ): - raise RuntimeError(f"答题失败,请重新考试:{question}") + answer_failed = True + break + recorded_count += 1 + + if answer_failed: + mark_failed(f"{plan_name}: 答案记录失败") + continue # ── 题库作答 ── if have_answer: self.log.info(f"开始答题库中的题目,共 {len(have_answer)} 道题目") - for i, question in enumerate(have_answer): + for i, (question, answers_ids) in enumerate(have_answer): self.log.info( f"[{i + 1}/{len(have_answer)}] 题目在题库中,开始答题" ) self.log.info( - f"题目类型:{question['typeLabel']}," + f"题目类型:{question.get('typeLabel', '未知')}," f"题目标题:{question['title']}" ) - answers = answers_json[clean_text(question["title"])] - answers_ids = [ - opt["id"] - for opt in question["optionList"] - if clean_text(opt["content"]) in answers - ] use_time = question_base_time + randint(0, question_random_upper) self.log.info( f"等待 {self._format_duration(use_time)},模拟答题中..." ) - time.sleep(use_time) + self._sleep(use_time) if not self.record_answer( user_exam_plan_id, question["id"], @@ -1615,16 +2390,51 @@ def run_exam( answers_ids, exam_plan_id, ): - raise RuntimeError(f"答题失败,请重新考试:{question}") + answer_failed = True + break + recorded_count += 1 + + if answer_failed: + mark_failed(f"{plan_name}: 题库答案记录失败") + continue + if recorded_count != len(question_list): + self.log.error( + f"{plan_name} 仅成功记录 {recorded_count}/{len(question_list)} " + "道题,禁止交卷" + ) + mark_failed(f"{plan_name}: 答案记录数不完整") + continue self.log.info("完成考试,正在提交试卷...") submit_res = self.api.exam_submit_paper(user_exam_plan_id) if not _check_code_ok(submit_res): - raise RuntimeError(f"提交试卷失败,请重新考试:{submit_res}") + self.log.error(f"提交试卷失败:{submit_res}") + mark_failed(f"{plan_name}: 提交试卷失败") + continue + submit_data = submit_res.get("data") + if not isinstance(submit_data, dict) or "score" not in submit_data: + self.log.error(f"提交试卷响应结构无效:{submit_res}") + mark_failed(f"{plan_name}: 交卷响应结构无效") + continue self.log.success( - f"试卷提交成功,考试完成,成绩:{submit_res['data']['score']} 分" + f"试卷提交成功,考试完成,成绩:{submit_data['score']} 分" ) self._update_exam_eta(time.time() - plan_start_ts) + completed += 1 + + if failed: + return WorkflowResult.incomplete( + "部分考试计划未完成", + completed=completed, + failed=failed, + skipped=skipped, + details=tuple(details), + ) + return WorkflowResult.success( + "考试阶段完成", + completed=completed, + skipped=skipped, + ) def _update_exam_eta(self, elapsed: float) -> None: """用实测考试耗时更新每场考试的自适应估计(EMA)""" @@ -1678,16 +2488,12 @@ def parse_item_js( video_block = re.search(r"]*>(.*?)", html, re.DOTALL) if video_block: # 去掉注释(被注释掉的备选 m3u8 源不算数),再找 - clean = re.sub( - r"", "", video_block.group(1), flags=re.DOTALL - ) + clean = re.sub(r"", "", video_block.group(1), flags=re.DOTALL) video_match = re.search( r"]*\bsrc=[\"']([^\"']+)[\"']", clean ) if not video_match: - video_match = re.search( - r"]*\bsrc=[\"']([^\"']+)[\"']", html - ) + video_match = re.search(r"]*\bsrc=[\"']([^\"']+)[\"']", html) if video_match: video_url = urljoin(html_url, video_match.group(1)) result["has_video"] = True @@ -1751,9 +2557,7 @@ def parse_item_js( for buf in buffers: pos = 0 while pos + 8 <= len(buf) and not video_duration: - size = int.from_bytes( - buf[pos : pos + 4], "big" - ) + size = int.from_bytes(buf[pos : pos + 4], "big") box_type = buf[pos + 4 : pos + 8] if size == 1: # largesize(64 位) if pos + 16 > len(buf): @@ -1780,10 +2584,7 @@ def parse_item_js( break if buf[q + 4 : q + 8] == b"mvhd": version = buf[q + 8] - if ( - version == 0 - and q + 28 <= len(buf) - ): + if version == 0 and q + 28 <= len(buf): timescale = int.from_bytes( buf[q + 20 : q + 24], "big", @@ -1805,9 +2606,7 @@ def parse_item_js( timescale = 0 duration = 0 if timescale: - video_duration = ( - duration / timescale - ) + video_duration = duration / timescale break q += inner_size break @@ -1854,10 +2653,10 @@ def parse_item_js( content = _fetch_text(self.api.session, item_url, referer=html_url) if not content: continue - result["nonstr_map"] = _extract_map(content) + extracted = _extract_map(content) + if extracted: + result["nonstr_map"].update(extracted) result["has_exam"] = result["has_exam"] or _check_exam(content) - if result["nonstr_map"] or result["has_exam"]: - break # 推导 total_step(finish=2 的调用次数 = finish=1 的 step - 1) # 每个题目页会产生 2 次额外 apinext 调用(提交 → 结果页 → 继续) @@ -1915,53 +2714,33 @@ def handle_apinext( return unique_no def _send_step(step: int, finish: int, nonstr: str, label: str) -> None: - """单步发送,网络异常最多重试 3 次(含首次)。 - - WeBanAPI 的 session 层已有 HTTPAdapter 全局重试(连接类错误/429/5xx - 自动退避),这里对重试耗尽后剩余的网络异常再兜底 2 次,避免偶发 - 抖动导致翻页轨迹断步。 - """ - for attempt in range(1, 4): - try: - resp = self.api.apinext( - user_course_id, - course_id, - user_project_id, - step=step, - finish=finish, - nonstr=nonstr, - unique_no=unique_no, - ) - if not resp.get("success"): - self.log.warning(f"apinext [{label}] 返回异常:{resp}") - else: - self.log.info(f"apinext [{label}] finish={finish} 已发送") - return - except PermissionError: - raise # Token 失效,立即终止该账号 - except OSError as e: - if attempt < 3: - self.log.warning( - f"apinext [{label}] 网络异常,重试 {attempt}/2:{e}" - ) - time.sleep(attempt) - else: - self.log.warning(f"apinext [{label}] 失败:{e}") + """Jupiter 是状态写入;单步只发送一次并严格校验响应。""" + + resp = self.api.apinext( + user_course_id, + course_id, + user_project_id, + step=step, + finish=finish, + nonstr=nonstr, + unique_no=unique_no, + ) + if not _check_code_ok(resp) or resp.get("success") is not True: + raise ResponseValidationError(f"apinext [{label}] 返回异常:{resp}") + self.log.info(f"apinext [{label}] finish={finish} 已发送") if finish == 2: self.log.info(f"apinext 发送中间步骤,共 {total_step} 步") for step in range(1, total_step + 1): if step_delay: - time.sleep(step_delay) + self._sleep(step_delay) # nonstr_map 的 key 对应 finish=2 的 step,完成步 (finish=1) 不在 map 中 _send_step(step, 2, nonstr_map.get(step, ""), f"{step}/{total_step}") else: if step_delay: - time.sleep(step_delay) + self._sleep(step_delay) # finish=1 的 step 需要偏移 total_step + 1(nonstr_map 不含此步) - _send_step( - total_step + 1, 1, "", f"完成标记 step={total_step + 1}" - ) + _send_step(total_step + 1, 1, "", f"完成标记 step={total_step + 1}") return unique_no @staticmethod @@ -2001,21 +2780,22 @@ def _answer_question( :param source: sourceStr 值 :return: 题库命中返回 True,fallback/失败返回 False """ - title = clean_text(question.get("title", "")) option_list = question.get("optionList", []) if not option_list: return False # 题库命中,直接提交正确答案 - if title in answers_json: - answer_ids = [ - opt["id"] - for opt in option_list - if clean_text(opt["content"]) in answers_json[title] - ] - if answer_ids: - save_func(course_id, question["id"], json.dumps(answer_ids), source) - return True + answer_ids = self._answer_ids_for_question(answers_json, question) + if answer_ids: + result = save_func( + course_id, + question["id"], + json.dumps(answer_ids), + source, + ) + if not _check_code_ok(result): + raise ResponseValidationError(f"课程答题响应失败:{result}") + return True # 题库未命中:先提交第一个选项,从响应中提取正确 answerLabel res = save_func( @@ -2024,6 +2804,8 @@ def _answer_question( json.dumps([option_list[0]["id"]]), source, ) + if not _check_code_ok(res): + raise ResponseValidationError(f"课程试探答题响应失败:{res}") data = res.get("data", {}) # 观点题返回投票统计列表,无 answerLabel if isinstance(data, list): @@ -2042,7 +2824,14 @@ def _answer_question( letter_to_opt[ch]["id"] for ch in correct_letters if ch in letter_to_opt ] if answer_ids: - save_func(course_id, question["id"], json.dumps(answer_ids), source) + result = save_func( + course_id, + question["id"], + json.dumps(answer_ids), + source, + ) + if not _check_code_ok(result): + raise ResponseValidationError(f"课程纠正答题响应失败:{result}") return False def record_answer( @@ -2061,6 +2850,16 @@ def record_answer( :param exam_plan_id: 考试计划 ID :return: 成功返回 True,失败返回 False """ + if ( + not answers_ids + or any( + not isinstance(answer_id, str) or not answer_id + for answer_id in answers_ids + ) + or len(set(answers_ids)) != len(answers_ids) + ): + self.log.error("拒绝记录空答案、重复答案或无效答案 ID") + return False res = self.api.exam_record_question( user_exam_plan_id, question_id, @@ -2161,7 +2960,7 @@ def _ai_search_question(self, question: dict) -> list: self.log.warning( f"AI 搜题第 {attempt} 次请求失败,{wait}s 后重试:{e}" ) - time.sleep(wait) + self._sleep(wait) else: self.log.error(f"AI 搜题请求失败(已重试 {max_retries} 次):{e}") return [] @@ -2232,213 +3031,330 @@ def _parse_ai_answer(content: str) -> list | None: @staticmethod def _is_valid_answers(answers_json: Any) -> bool: - """校验题库是否为有效字典且非空""" - return isinstance(answers_json, dict) and bool(answers_json) + """至少包含一道能在规范化后保留的题目。""" + + if not isinstance(answers_json, dict): + return False + for title, question in answers_json.items(): + if ( + not isinstance(title, str) + or not title + or not isinstance(question, dict) + ): + continue + raw_options = question.get("optionList") + if not isinstance(raw_options, list): + continue + if any( + isinstance(option, dict) + and isinstance(option.get("content"), str) + and bool(_exact_text(option["content"])) + for option in raw_options + ): + return True + return False @staticmethod def _normalize_answers(answers_json: dict) -> dict: - """合并 clean_text 后相同的题目与选项条目,保留原始标点。 + """清理字段但不再按模糊键合并题目或并集单选答案。""" - 标题与选项文本取各组内最长(标点最完整)的原文,选项标记取并集 - (任一变体标 1 则保留 1),合并前后经 clean_text 匹配的运行时 - 行为不变。 - """ - merged: dict = {} + normalized: dict[str, dict[str, Any]] = {} for title, question in answers_json.items(): - clean_title = clean_text(title) - entry = merged.get(clean_title) - if entry is None: - entry = merged[clean_title] = { - "title": title, - "type": question.get("type"), - "options": {}, + if ( + not isinstance(title, str) + or not title + or not isinstance(question, dict) + ): + continue + raw_options = question.get("optionList") + if not isinstance(raw_options, list): + continue + options: dict[str, dict[str, Any]] = {} + for option in raw_options: + if not isinstance(option, dict): + continue + content = option.get("content") + if not isinstance(content, str) or not content: + continue + option_key = _exact_text(content) + if not option_key: + continue + # 同一精确选项由后一次完整记录替换,不对 isCorrect 取并集。 + options[option_key] = { + "content": content, + "isCorrect": 1 if option.get("isCorrect") == 1 else 2, } - elif len(title) > len(entry["title"]): - entry["title"] = title - for option in question.get("optionList", []): - content = clean_text(option["content"]) - old = entry["options"].get(content) - if old is None: - entry["options"][content] = { - "content": option["content"], - "isCorrect": option["isCorrect"], - } - else: - if len(option["content"]) > len(old["content"]): - old["content"] = option["content"] - if option["isCorrect"] == 1: - old["isCorrect"] = 1 - return { - entry["title"]: { - "type": entry["type"], - "optionList": list(entry["options"].values()), + if not options: + continue + normalized[title] = { + "type": question.get("type"), + "optionList": list(options.values()), } - for entry in merged.values() - } + return normalized - def sync_answers(self) -> None: - """同步答案 - :return: 无返回值 - """ - os.makedirs(answer_dir, exist_ok=True) - # 按优先级查找已有题库: 根目录 > answer/ > 打包内置 - existing_path: str | None = None - for p in [root_answer_path, answer_path, bundle_answer_path]: - if os.path.exists(p): - existing_path = p - break - need_download = existing_path is None + @staticmethod + def _extract_history_list(response: dict) -> list[dict] | None: + """兼容 data 为列表和 data.examHistoryList 两种历史响应。""" - answers_json: dict | None = None - if not need_download: - assert existing_path is not None - try: - with open(existing_path, encoding="utf-8") as f: - answers_json = json.load(f) - if not self._is_valid_answers(answers_json): - need_download = True - except (json.JSONDecodeError, OSError): - need_download = True - - if need_download: - # 与模板下载同构:远程 jsDelivr 失败后回退打包内置/本地文件兜底 + if not _check_code_ok(response): + return None + data = response.get("data") + if isinstance(data, list): + histories = data + elif isinstance(data, dict): + histories = data.get("examHistoryList") + else: + return None + if not isinstance(histories, list) or not all( + isinstance(history, dict) for history in histories + ): + return None + return histories + + @classmethod + def _merge_reviewed_answer( + cls, + answers: dict, + reviewed: dict, + ) -> bool: + """以完整复盘结果替换唯一兼容项,不合并正确答案集合。""" + + title = reviewed.get("title") + if not isinstance(title, str) or not title: + return False + normalized = cls._normalize_answers({title: reviewed}) + incoming = normalized.get(title) + if incoming is None: + return False + + target_key: str | None = title if title in answers else None + incoming_signature = _option_signature(incoming) + if target_key is None: + exact_candidates = [ + key + for key, value in answers.items() + if isinstance(value, dict) + and _exact_text(key) == _exact_text(title) + and _option_signature(value) == incoming_signature + ] + if len(exact_candidates) == 1: + target_key = exact_candidates[0] + if target_key is None: + fuzzy_candidates = [ + key + for key, value in answers.items() + if isinstance(value, dict) + and clean_text(key) == clean_text(title) + and _option_signature(value) == incoming_signature + ] + if len(fuzzy_candidates) == 1: + target_key = fuzzy_candidates[0] + + if target_key is not None and target_key != title: + del answers[target_key] + answers[title] = incoming + return True + + def sync_answers(self) -> WorkflowResult: + """从考试复盘增量同步题库,并通过 AnswerStore 原子提交。""" + + store = self._answer_store() + remote_baseline: dict[str, Any] = {} + try: + answers_json = self._normalize_answers(store.load()) + except AnswerStoreError: self.log.info("题库不存在或格式错误,正在下载...") try: remote = self.api.download_answer() - self.log.success("题库已从远程下载") - except Exception as e: # noqa: BLE001 -- 网络失败不应中断整个账号流程 - self.log.warning(f"题库下载失败:{e},回退本地内置题库") - remote = read_first_existing( - [bundle_answer_path, answer_path, root_answer_path] - ) - if remote is None: - self.log.warning("本地无可用题库,本次跳过题库同步") - return - with open(answer_path, "w", encoding="utf-8") as f: - f.write(remote) - try: - with open(answer_path, encoding="utf-8") as f: - answers_json = json.load(f) - except (json.JSONDecodeError, OSError) as e: - self.log.error(f"读取题库失败:{e}") - return - if not self._is_valid_answers(answers_json): + downloaded = json.loads(remote) + except (OSError, TypeError, ValueError, APIResponseError) as exc: + self.log.error(f"题库下载或解析失败:{exc}") + return WorkflowResult.failed_result("没有可用题库") + if not self._is_valid_answers(downloaded): self.log.error("下载的题库格式无效,应为非空 JSON 对象") - return + return WorkflowResult.failed_result("下载题库格式无效") + answers_json = self._normalize_answers(downloaded) + if not self._is_valid_answers(answers_json): + self.log.error("下载的题库格式无效,应包含至少一道有效题目") + return WorkflowResult.failed_result("下载题库格式无效") + remote_baseline = answers_json + self.log.info("题库已从远程下载,待同步事务中合并保存") + + failures = 0 + reviewed_questions: list[dict] = [] + user_project_ids: list[str] = [] + + # 题库同步是辅助阶段:项目/模块列表的网络或协议错误只计入 failures + # 并降级为 incomplete,不能让整个账号在学习开始前就失败。 + for ended in (2, 1): + self._raise_if_stopped() + try: + response = self.api.list_my_project(ended=ended) + except PermissionError: + raise + except (OSError, APIResponseError) as exc: + self.log.warning(f"获取项目列表失败(ended={ended}):{exc}") + failures += 1 + continue + projects = response.get("data") if _check_code_ok(response) else None + if not isinstance(projects, list): + self.log.error(f"获取项目列表失败:{response}") + failures += 1 + continue + for project in projects: + if isinstance(project, dict) and project.get("userProjectId"): + user_project_ids.append(str(project["userProjectId"])) + else: + self.log.warning(f"跳过结构无效的项目:{project}") + failures += 1 - if answers_json is None: - self.log.error("题库加载失败") - return + completion_failed = False + try: + completion = self.api.list_completion() + except PermissionError: + raise + except (OSError, APIResponseError) as exc: + self.log.warning(f"获取模块完成情况失败:{exc}") + failures += 1 + completion_failed = True + completion = {} + modules = completion.get("data") if _check_code_ok(completion) else None + if isinstance(modules, list): + show_lab = any( + isinstance(item, dict) + and item.get("module") == "labProject" + and item.get("showable") == 1 + for item in modules + ) + if show_lab: + lab_failed = False + try: + lab_project = self.api.lab_index() + except PermissionError: + raise + except (OSError, APIResponseError) as exc: + self.log.warning(f"获取实验室项目失败:{exc}") + failures += 1 + lab_failed = True + lab_project = {} + lab_data = ( + lab_project.get("data") if _check_code_ok(lab_project) else None + ) + current = ( + lab_data.get("current") if isinstance(lab_data, dict) else None + ) + if isinstance(current, dict) and current.get("userProjectId"): + user_project_ids.append(str(current["userProjectId"])) + elif not lab_failed: + self.log.warning(f"跳过无效实验室项目:{lab_project}") + failures += 1 + elif not completion_failed: + self.log.warning(f"获取模块完成情况失败:{completion}") + failures += 1 + + # 去重但保留服务端顺序。 + user_project_ids = list(dict.fromkeys(user_project_ids)) + for user_project_id in user_project_ids: + self._raise_if_stopped() + try: + plan_response = self.api.exam_list_plan(user_project_id) + except PermissionError: + raise + except (OSError, APIResponseError) as exc: + self.log.warning(f"项目 {user_project_id} 考试计划同步失败:{exc}") + failures += 1 + continue + plans = plan_response.get("data") if _check_code_ok(plan_response) else None + if not isinstance(plans, list): + self.log.warning(f"项目考试计划响应无效:{plan_response}") + failures += 1 + continue + for plan in plans: + if not isinstance(plan, dict) or not all( + key in plan for key in ("examPlanId", "examType") + ): + self.log.warning(f"跳过无效考试计划:{plan}") + failures += 1 + continue + try: + history_response = self.api.exam_list_history( + plan["examPlanId"], + plan["examType"], + ) + except PermissionError: + raise + except (OSError, APIResponseError) as exc: + self.log.warning(f"考试历史同步失败:{exc}") + failures += 1 + continue + histories = self._extract_history_list(history_response) + if histories is None: + self.log.warning(f"考试历史响应无效:{history_response}") + failures += 1 + continue + for history in histories: + history_id = history.get("examId") or history.get("id") + if not history_id: + self.log.warning(f"跳过缺少 examId/id 的历史记录:{history}") + failures += 1 + continue + try: + review = self.api.exam_review_paper( + str(history_id), + int(history.get("isRetake", 2)), + ) + except PermissionError: + raise + except (OSError, ValueError, APIResponseError) as exc: + self.log.warning(f"考试复盘同步失败:{exc}") + failures += 1 + continue + review_data = review.get("data") if _check_code_ok(review) else None + questions = ( + review_data.get("questions") + if isinstance(review_data, dict) + else None + ) + if not isinstance(questions, list): + self.log.warning(f"考试复盘响应无效:{review}") + failures += 1 + continue + for question in questions: + if isinstance(question, dict) and self._normalize_answers( + {str(question.get("title", "")): question} + ): + reviewed_questions.append(question) + else: + self.log.warning(f"跳过无效复盘题目:{question}") + failures += 1 + + def merge_latest(current: dict[str, Any]) -> dict[str, Any]: + merged = self._normalize_answers(current) + # 远程题库只是缺失本地题库时的基线。将它放入最终 update + # 事务内合并,避免锁外的独立 write 覆盖其他进程刚写入的答案。 + for title, question in self._normalize_answers(remote_baseline).items(): + merged.setdefault(title, question) + for reviewed in reviewed_questions: + if not self._merge_reviewed_answer(merged, reviewed): + self.log.warning(f"跳过无效复盘题目:{reviewed}") + return self._normalize_answers(merged) - # 合并变体:clean_text 相同的题目/选项仅保留一条,保留原始标点 - answers_json = self._normalize_answers(answers_json) - # clean 标题 → 原始标题索引,服务器标题按 clean 语义匹配 - key_by_clean = {clean_text(k): k for k in answers_json} + try: + merged = store.update(merge_latest, default=answers_json) + except (OSError, AnswerStoreError) as exc: + self.log.error(f"题库原子写入失败:{exc}") + return WorkflowResult.failed_result("题库写入失败") - user_project_ids = [ - p["userProjectId"] for p in self.api.list_my_project().get("data", []) - ] - user_project_ids.extend( - p["userProjectId"] - for p in self.api.list_my_project(ended=1).get("data", []) + self.log.success( + f"题库同步完成:复盘 {len(reviewed_questions)} 题,现有 {len(merged)} 题" ) - completion = self.api.list_completion() - if not _check_code_ok(completion): - self.log.error(f"获取模块完成情况失败:{completion}") - - showable_modules = [ - d["module"] for d in completion.get("data", []) if d["showable"] == 1 - ] - if "labProject" in showable_modules: - self.log.info("加载实验室课程") - lab_project = self.api.lab_index() - if not _check_code_ok(lab_project): - self.log.error(f"获取实验室课程失败:{lab_project}") - user_project_ids.append( - lab_project.get("data", {}).get("current", {}).get("userProjectId") + if failures: + return WorkflowResult.incomplete( + "题库已保存,但部分项目同步失败", + completed=len(reviewed_questions), + failed=failures, ) - for user_project_id in user_project_ids: - for plan in self.api.exam_list_plan(user_project_id).get("data", []): - for history in self.api.exam_list_history( - plan["examPlanId"], plan["examType"] - ).get("data", []): - questions = self.api.exam_review_paper( - history["id"], history["isRetake"] - )["data"].get("questions", []) - for answer in questions: - server_title = answer["title"] - clean_title = clean_text(server_title) - old_key = key_by_clean.get(clean_title) - if old_key is None: - # 新题:直接以服务器原文入库,并提醒用户 - answers_json[server_title] = { - "type": answer["type"], - "optionList": [ - { - "content": o["content"], - "isCorrect": o["isCorrect"], - } - for o in answer.get("optionList", []) - ], - } - key_by_clean[clean_title] = server_title - self.log.info(f"发现新题:{server_title}") - for option in answer.get("optionList", []): - self.log.info( - f"发现题目:{server_title} 新选项:{option['content']}" - ) - continue - entry = answers_json[old_key] - # 标题有变化则以服务器原文更新 - if old_key != server_title: - del answers_json[old_key] - answers_json[server_title] = entry - key_by_clean[clean_title] = server_title - # 选项追加合并:新选项追加;已有选项标记取并集 - # (任一变体标 1 则保留 1),文本保留较长原文,同题 - # 不同答案的变体互不覆盖(与 _normalize_answers 一致) - options = { - clean_text(o["content"]): o for o in entry["optionList"] - } - for option in answer.get("optionList", []): - content = clean_text(option["content"]) - old = options.get(content) - if old is None: - options[content] = { - "content": option["content"], - "isCorrect": option["isCorrect"], - } - self.log.info( - f"发现题目:{server_title} 新选项:{option['content']}" - ) - continue - merged = False - if option["isCorrect"] == 1 and old["isCorrect"] != 1: - old["isCorrect"] = 1 - merged = True - if len(option["content"]) > len(old["content"]): - old["content"] = option["content"] - merged = True - if merged: - self.log.info( - f"发现题目:{server_title} 答案合并:{old['content']}" - ) - entry["optionList"] = list(options.values()) - entry["type"] = answer["type"] - - # 所有入库路径统一走规范化,确保只保留 content/isCorrect - answers_json = self._normalize_answers(answers_json) - - # 写回读取来源(打包内置路径只读,退回可写的 answer/ 目录), - # 与 _load_answers_json 的加载优先级保持一致,避免同步结果不被加载 - write_path = ( - existing_path - if existing_path is not None and existing_path != bundle_answer_path - else answer_path + return WorkflowResult.success( + "题库同步完成", + completed=len(reviewed_questions), ) - os.makedirs(os.path.dirname(write_path), exist_ok=True) - with open(write_path, "w", encoding="utf-8") as f: - f.write( - json.dumps(answers_json, indent=2, ensure_ascii=False, sort_keys=True) - ) - f.write("\n") diff --git a/config.example.toml b/config.example.toml index edb1c0d..92e922f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,5 +1,8 @@ # WeBan 配置文件 # +# 安全提醒:password、token 和 [ai].api_key 都会以明文保存在本文件中。 +# 请限制文件读取权限,不要提交到版本库、上传网盘或随日志一起分享。 +# # 账号设置(配置优先级 / 无交互判定 / Docker 运行说明见下方 [settings] 段末) [[account]] # 学校名称(必填,需要和登录页面显示的学校完全一致,推荐复制过来) @@ -22,29 +25,33 @@ password = "" # 学习模式: # false = 不学习,跳过所有学习任务 # true = 正常学习,默认跳过已完成内容 -# force = 强制学习,已完成内容也会重新学习,全部学完后继续循环 +# force = 本轮强制重学,已完成内容也会重新学习一次;不会在同一轮无限循环 study_mode = "true" # 考试模式: # false = 不考试,跳过所有考试任务 # true = 正常考试,已及格/已完成的考试默认跳过 -# perfect = 达到满分为止,如果只剩一次考试机会且题库无法完全匹配则会停止 -# force = 强制考试,即使已及格也继续参加考试,除非没有考试机会了 +# perfect = 本轮以满分为目标;每个考试计划最多新开一张试卷,不会连续重考 +# force = 本轮即使已及格也强制再考;每个考试计划仍最多新开一张试卷 +# perfect/force 如需再次尝试,须确认剩余次数后重新运行,避免自动耗尽机会 exam_mode = "perfect" # 遇到题库中没有的题目时是否随机作答: # true = 单选随机,多选全选 # false = 在终端中等待手动输入答案 +# 无交互模式(包括官方 Docker 镜像)没有人工输入通道;不要依赖 false +# 获取人工确认,是否允许提交仍由匹配率和交卷安全门决定 random_answer = true # 每个学习任务的最少停留时长(秒) # 过快不会记录学习进度,数值越大越稳妥,但整体耗时也会越长 study_time = 30 -# 视频课程学习倍速:完课前按 视频时长/倍速 等待,与真人播放行为对齐,降低风控抽检概率 +# 视频课程倍速仅用于计算完课前等待时间,不会控制网页播放器: +# 等待目标为 max(study_time, 视频时长 / video_speed) # 1 = 按视频实际时长等待(默认,最稳妥) -# 2 = 按视频时长的一半等待(2 倍速播放;实测样本不足,新账号首门课被抽检概率较高,不建议新号使用) -# 0 = 不等待视频时长,直接按上面的 study_time 学习时长完课 +# 2 = 按视频时长的一半等待(等价于 2 倍速;实测样本不足,不建议新号使用) +# 0 = 忽略视频时长,只遵守上面的 study_time video_speed = 1 # 对未加载 apicenext.js 的课程是否补发 jupiter 翻页轨迹(默认关闭,完全对齐官方页面行为) @@ -80,13 +87,17 @@ browser_path = "" # CDP 远程调试(连接已有浏览器实例,适用于 Docker 等无法直接启动浏览器的场景) # 在宿主机启动 Chrome 时加上: chrome --remote-debugging-port=9222 # 然后填写以下两项,nodriver 将通过 CDP 连接该浏览器,不再启动本地浏览器 -# 注意: cdp_host 和 cdp_port 需同时设置才生效 +# 注意: cdp_host 和 cdp_port 需同时设置才生效;完整 CDP 配置优先于 browser_path # Docker 环境会自动检测,无需手动配置 +# CDP 等同于浏览器完全控制权:只连接专用浏览器配置,不要暴露到公网, +# 并用防火墙限制可访问来源。内置浏览器镜像的 CDP 仅监听容器回环地址。 cdp_host = "" cdp_port = 0 # 是否启用调试日志 # 开启后会输出更多日志,比如请求体和响应体,验证码图片等 +# 即使程序会脱敏常见字段,调试内容仍可能含账号、课程或响应中的个人信息; +# 分享日志前必须复查并二次打码,用完后及时删除。 debug = false # ── 配置优先级(高→低)── @@ -114,7 +125,7 @@ debug = false # - stdin 不是 TTY:cron / 后台运行 / 管道 / SSH 无 TTY 会话 # - 或显式传 --non-interactive 参数 # 无交互时:不弹编辑器、所有确认用默认值、验证码/考试不等待手动输入、 -# 末尾不等待回车。 +# 不打开可见浏览器、末尾不等待回车。该限制不能由其他配置项反向覆盖。 # # 首次运行(交互式)不需要手动创建本文件:程序会提示输入学校/学号/密码, # 验证登录成功后自动把账号写入本文件;登录失败不会生成/修改文件。 @@ -124,14 +135,19 @@ debug = false # - 镜像默认已设 WB_DATA_DIR=/app/data 与 ENVIRONMENT=docker, # 数据目录挂载 ./data:/app/data 后 config.toml/logs/answer 都会持久化 # - 首次运行会在数据目录生成 config.example.toml 模板,填写账号后重跑 +# - 即使加 -it,ENVIRONMENT=docker 仍保持无交互;需人工输入或可见浏览器时, +# 请在宿主机直接运行源码/原生可执行文件 +# - captcha_model.onnx 是可选的登录验证码 OCR 资源。缺失时程序仍可启动: +# 交互运行回退人工输入;无交互/Docker 的密码登录会明确失败,Token 登录不受影响 # # AI 答题设置 [ai] -# 是否启用 AI 搜题功能(当题库无匹配时,优先采用 AI 搜题解答,AI 答题失败将回退到随机答题的配置) -enable = true +# 是否启用 AI 搜题。启用后,题干和选项会发送到 base_url 指向的第三方服务; +# 请先确认课程数据允许外传并接受服务商的隐私条款。默认关闭,失败时按本地策略降级。 +enable = false # API 基础路径,例如 DeepSeek、硅基流动等 OpenAI 兼容接口路径(/chat/completions) base_url = "https://opencode.ai/zen/v1" -# AI 服务的 API Key +# AI 服务的 API Key(明文敏感信息,优先通过 WB_AI_API_KEY 临时注入) api_key = "" # 调用的模型名称,如 deepseek-v4-flash 等 model = "nemotron-3-ultra-free" diff --git a/entrypoint.sh b/entrypoint.sh index 4b7cf59..f756a75 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,26 +1,42 @@ #!/bin/bash -set -e +set -euo pipefail # 启动 headless-shell CDP 服务(后台) # 默认 --single-process:1 核小服务器上 Chrome 多进程互相饿死,CDP 鼠标 # 事件实测从 0.06s 拖到 55s;单进程模式恢复实时响应。WB_SINGLE_PROCESS=0 # 可关闭(多核机器想用多进程时)。 +chrome_args=( + --no-sandbox + --use-gl=angle + --use-angle=swiftshader + --remote-debugging-address=127.0.0.1 + --remote-debugging-port=9222 +) if [ "${WB_SINGLE_PROCESS:-1}" = "1" ]; then - /headless-shell/headless-shell \ - --no-sandbox --single-process \ - --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 \ - >/dev/null 2>&1 & -else - /headless-shell/run.sh >/dev/null 2>&1 & + chrome_args+=(--single-process) fi +/headless-shell/headless-shell "${chrome_args[@]}" >/dev/null 2>&1 & # 等待 CDP 端口就绪 +cdp_ready=0 for i in $(seq 1 30); do if (echo >/dev/tcp/127.0.0.1/9222) 2>/dev/null; then - exec /app/WeBan "$@" + cdp_ready=1 + break fi sleep 0.5 done -echo "ERROR: headless-shell CDP port 9222 did not become ready within 15 seconds" >&2 -exit 1 +if [ "$cdp_ready" != "1" ]; then + echo "ERROR: headless-shell CDP port 9222 did not become ready within 15 seconds" >&2 + exit 1 +fi + +# Docker 的 CMD 默认为 /app/WeBan;用户直接传 --help 等参数时 CMD 会被替换, +# 此时补回程序路径。两种情况最终都只启动一次 /app/WeBan。 +if [ "$#" -eq 0 ]; then + set -- /app/WeBan +elif [[ "$1" == -* ]]; then + set -- /app/WeBan "$@" +fi +exec "$@" diff --git a/errors.py b/errors.py new file mode 100644 index 0000000..6e2fd44 --- /dev/null +++ b/errors.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum + + +class WorkflowStatus(str, Enum): + """账号内工作流的可观察终态。""" + + SUCCESS = "success" + INCOMPLETE = "incomplete" + FAILED = "failed" + LOCKED = "locked" + + +@dataclass(frozen=True) +class WorkflowResult: + """学习、考试或同步阶段的结构化结果。""" + + status: WorkflowStatus + message: str = "" + completed: int = 0 + failed: int = 0 + skipped: int = 0 + details: tuple[str, ...] = field(default_factory=tuple) + + @property + def ok(self) -> bool: + return self.status is WorkflowStatus.SUCCESS + + @classmethod + def success( + cls, message: str = "", *, completed: int = 0, skipped: int = 0 + ) -> WorkflowResult: + return cls( + WorkflowStatus.SUCCESS, + message, + completed=completed, + skipped=skipped, + ) + + @classmethod + def incomplete( + cls, + message: str, + *, + completed: int = 0, + failed: int = 1, + skipped: int = 0, + details: tuple[str, ...] = (), + ) -> WorkflowResult: + return cls( + WorkflowStatus.INCOMPLETE, + message, + completed=completed, + failed=failed, + skipped=skipped, + details=details, + ) + + @classmethod + def failed_result( + cls, message: str, *, details: tuple[str, ...] = () + ) -> WorkflowResult: + return cls( + WorkflowStatus.FAILED, + message, + failed=1, + details=details, + ) + + def combine(self, other: WorkflowResult, message: str = "") -> WorkflowResult: + """合并连续阶段,保留其中最严重的状态。""" + + priority = { + WorkflowStatus.SUCCESS: 0, + WorkflowStatus.INCOMPLETE: 1, + WorkflowStatus.FAILED: 2, + WorkflowStatus.LOCKED: 3, + } + status = ( + self.status + if priority[self.status] >= priority[other.status] + else other.status + ) + deciding_result = self if status is self.status else other + return WorkflowResult( + status=status, + message=message or deciding_result.message, + completed=self.completed + other.completed, + failed=self.failed + other.failed, + skipped=self.skipped + other.skipped, + details=self.details + other.details, + ) + + +class WeBanError(RuntimeError): + """可预期的业务或协议错误基类。""" + + +class ResponseValidationError(WeBanError): + """响应结构不完整,继续执行可能产生副作用。""" + + +class APIResponseError(WeBanError): + """HTTP 或响应编码错误,不包含凭据和完整响应正文。""" + + def __init__( + self, + message: str, + *, + status_code: int | None, + endpoint: str, + summary: str, + ) -> None: + self.status_code = status_code + self.endpoint = endpoint + self.summary = summary + status = f"HTTP {status_code}" if status_code is not None else "HTTP 未知" + super().__init__(f"{message}({status},端点 {endpoint},摘要 {summary})") + + +class TokenInvalidError(PermissionError): + """Token 失效或账号在别处登录。""" + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + endpoint: str = "", + ) -> None: + self.status_code = status_code + self.endpoint = endpoint + super().__init__(message) + + +class AccountBlockedError(TokenInvalidError): + """平台返回行为异常或锁号,必须立刻终止当前账号。""" + + def __init__( + self, + message: str = "系统检测到行为异常或账号已锁定", + *, + detail_code: str = "", + status_code: int | None = None, + endpoint: str = "", + ) -> None: + self.detail_code = detail_code + self.status = WorkflowStatus.LOCKED + self.result = WorkflowResult( + WorkflowStatus.LOCKED, + message, + failed=1, + ) + super().__init__( + message, + status_code=status_code, + endpoint=endpoint, + ) diff --git a/main.py b/main.py index 1b254a0..a1b7a94 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,9 @@ -import argparse +from __future__ import annotations + +import getpass +import hashlib +import inspect +import json import os import re import subprocess @@ -6,374 +11,662 @@ import threading import time import tomllib -import traceback -from concurrent.futures import ThreadPoolExecutor, as_completed +from collections.abc import Callable, Iterator, Mapping +from concurrent.futures import Future, ThreadPoolExecutor, as_completed +from contextlib import contextmanager +from dataclasses import dataclass +from enum import Enum +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as distribution_version +from pathlib import Path +from types import ModuleType +from typing import Any, TextIO import requests -from loguru import logger +from loguru import logger as base_logger + +from runtime_config import ( + AccountCredentials, + ConfigError, + InteractionPolicy, + ResolvedAccount, + RuntimeConfig, + atomic_write_text, + build_runtime_config, + create_local_config_template, + load_toml, + parse_args, + resolve_interaction_policy, + resolve_paths, +) -from captcha import check_browser_health, is_non_interactive -from client import WeBanClient, read_first_existing +GITHUB_REPO = "hangone/WeBan" +UPDATE_CHECK_TIMEOUT = 3 +EXIT_SUCCESS = 0 +EXIT_FAILURE = 1 +EXIT_CONFIG_ERROR = 2 +EXIT_PARTIAL_FAILURE = 3 +EXIT_INTERRUPTED = 130 -# ── 命令行参数与环境变量(优先级:CLI > 环境变量 > 配置文件)──────── +_SYNC_LOCK = threading.Lock() +_RUNTIME_ENV_KEYS = ("WB_DATA_DIR", "WB_NON_INTERACTIVE") -def _env_bool(name: str, default: bool = False) -> bool: - """读取 WB_ 前缀布尔环境变量:1/true/yes 为真,其余为默认""" - val = os.environ.get(name) - if val is None: - return default - return val.strip().lower() in ("1", "true", "yes", "on") +def _resolve_version() -> str: + candidates: list[Path] = [] + bundle = getattr(sys, "_MEIPASS", None) + if bundle: + candidates.append(Path(bundle) / "pyproject.toml") + candidates.append(Path(__file__).resolve().parent / "pyproject.toml") + for path in candidates: + try: + with path.open("rb") as file: + version = tomllib.load(file).get("project", {}).get("version") + except (OSError, tomllib.TOMLDecodeError): + continue + if version: + return str(version) + try: + return distribution_version("weban") + except PackageNotFoundError: + return "unknown" -def _parse_args() -> tuple[argparse.Namespace, list[str]]: - """解析命令行参数(--config/--data-dir/--non-interactive 等)。 - 返回值 (opts, unknown):opts 为解析结果;unknown 为未识别参数, - 保留不改动(历史调用方式兼容)。 - """ - parser = argparse.ArgumentParser( - prog="WeBan", - description="WeBan 学习自动化(多账号,可按项目交替学习+考试)", - # 禁用前缀缩写(allow_abbrev):--tenant 不再被当作 --tenant-name - # 的缩写,参数必须写全名,与配置文件键名/环境变量名严格对应 - allow_abbrev=False, - ) - parser.add_argument( - "--config", - metavar="PATH", - help="配置文件路径(默认: 程序目录/config.toml)", - ) - parser.add_argument( - "--data-dir", - metavar="PATH", - help=( - "数据目录:config.toml、logs、answer 都放在此目录下 " - "(适合 Docker 挂载持久化,如 docker run -v ./data:/app/data " - "-e WB_DATA_DIR=/app/data)" - ), - ) - parser.add_argument( - "--non-interactive", - action="store_true", - help="无交互模式:所有输入使用默认值,不打开编辑器,末尾不等待回车", - ) - parser.add_argument( - "--study-mode", - choices=["false", "true", "force"], - help="学习模式(覆盖配置文件)", - ) - parser.add_argument( - "--exam-mode", - choices=["false", "true", "perfect", "force"], - help="考试模式(覆盖配置文件)", - ) - parser.add_argument( - "--random-answer", - choices=["true", "false"], - help="题库外题目是否随机作答(覆盖配置文件)", - ) - parser.add_argument( - "--cdp-host", - metavar="HOST", - help="CDP 浏览器地址(覆盖配置文件)", - ) - parser.add_argument( - "--cdp-port", - type=int, - metavar="PORT", - help="CDP 浏览器端口(覆盖配置文件)", - ) - parser.add_argument( - "--max-workers", - type=int, - metavar="N", - help="多账号最大并发数(覆盖配置文件)", - ) - parser.add_argument( - "--tenant-name", - dest="tenant_name", - metavar="NAME", - help="单账号学校全称(与配置文件 tenant_name 对应;配合 --username 免配置文件," - "可配合环境变量 WB_TENANT_NAME)", - ) - parser.add_argument( - "--username", - metavar="USER", - help="单账号用户名(配合 --tenant-name,免配置文件,可配合环境变量 WB_USERNAME)", - ) - parser.add_argument( - "--password", - metavar="PASS", - help="单账号密码(默认同用户名,可配合环境变量 WB_PASSWORD)", - ) - parser.add_argument( - "--study-time", - metavar="SEC", - help='每门课学习时长 "基础,随机上限"(秒),如 "20,5"(覆盖配置文件)', - ) - parser.add_argument( - "--video-speed", - type=float, - metavar="N", - help="视频课程倍速:0=不按视频时长等待,1=按原时长,2=半速(覆盖配置文件)", - ) - parser.add_argument( - "--exam-question-time", - metavar="SEC", - help='每道考试题答题等待时长 "基础,随机上限"(秒),如 "3,3"(覆盖配置文件)', - ) - parser.add_argument( - "--exam-submit-match-rate", - type=int, - metavar="N", - help="允许交卷的最低题库匹配率(百分比,覆盖配置文件)", - ) - parser.add_argument( - "--browser-path", - metavar="PATH", - help="浏览器可执行文件路径(覆盖配置文件)", - ) - parser.add_argument( - "--jupiter-fallback", - choices=["true", "false"], - help="对未加载 apicenext.js 的课程是否补发 jupiter 翻页轨迹(覆盖配置文件)", - ) - parser.add_argument( - "--user-id", - metavar="ID", - help="单账号用户 ID(配合 --tenant-name --token 用 Token 登录,可配合环境变量 WB_USER_ID)", - ) - parser.add_argument( - "--token", - metavar="TOKEN", - help="单账号登录 Token(配合 --tenant-name --user-id,可配合环境变量 WB_TOKEN)", - ) - parser.add_argument( - "--debug", - action="store_true", - help="启用调试日志(覆盖配置文件)", - ) - # AI 搜题(覆盖配置文件 [ai] 段) - parser.add_argument( - "--ai-enable", - choices=["true", "false"], - help="是否启用 AI 搜题(覆盖配置文件 [ai].enable)", - ) - parser.add_argument( - "--ai-base-url", - metavar="URL", - help="AI 服务 API 基础路径(覆盖配置文件 [ai].base_url)", - ) - parser.add_argument( - "--ai-api-key", - metavar="KEY", - help="AI 服务 API Key(覆盖配置文件 [ai].api_key)", +VERSION = f"v{_resolve_version()}" + + +class LogRedactor: + """在日志分发前统一脱敏,覆盖终端和所有文件 sink。""" + + _KEYS = ( + r"password|passwd|pwd|token|x-token|authorization|cookie|ticket|" + r"user_?id|username|login_?name|student_?id|account|real_?name|" + r"tenant_?name|tenant_?code|mobile|phone|email|id_?card" ) - parser.add_argument( - "--ai-model", - metavar="NAME", - help="AI 模型名称(覆盖配置文件 [ai].model)", + _QUOTED_PAIR = re.compile( + rf"(?i)(?P[\"']?(?:{_KEYS})[\"']?\s*[:=]\s*)" + r"(?P[\"'])(?P.*?)(?P=quote)" ) - parser.add_argument( - "--ai-timeout", - type=int, - metavar="SEC", - help="AI 请求超时秒数(覆盖配置文件 [ai].timeout)", + _UNQUOTED_PAIR = re.compile( + rf"(?i)(?P\b(?:{_KEYS})\b\s*[:=]\s*)" + r"(?P[^,\s&;}\]]+)" ) - parser.add_argument( - "--ai-max-retries", - type=int, - metavar="N", - help="AI 请求失败最大重试次数(覆盖配置文件 [ai].max_retries)", + _AUTH = re.compile(r"(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+") + + def __init__(self) -> None: + self._values: set[str] = set() + self._lock = threading.Lock() + + def register(self, *values: object) -> None: + with self._lock: + for value in values: + if value is None: + continue + text = str(value) + # 极短值只能依靠带键名的规则脱敏,避免把普通数字/字母全替换。 + if len(text) >= 4: + self._values.add(text) + + def register_account(self, account: ResolvedAccount) -> None: + credentials = account.credentials + self.register( + credentials.tenant_name, + credentials.username, + credentials.password, + credentials.user_id, + credentials.token, + ) + + def redact(self, text: object) -> str: + result = str(text) + result = self._AUTH.sub(lambda match: f"{match.group(1)} ", result) + result = self._QUOTED_PAIR.sub( + lambda match: ( + f"{match.group('prefix')}{match.group('quote')}" + f"{match.group('quote')}" + ), + result, + ) + result = self._UNQUOTED_PAIR.sub( + lambda match: f"{match.group('prefix')}", + result, + ) + with self._lock: + sensitive_values = sorted(self._values, key=len, reverse=True) + for value in sensitive_values: + result = result.replace(value, "") + return result + + def __call__(self, record: Any) -> None: + record["message"] = self.redact(record["message"]) + + +def render_console_record(record: Mapping[str, Any]) -> str: + """格式化终端记录,不修改共享 record。""" + + message = str(record.get("message", "")) + level = record.get("level") + level_name = getattr(level, "name", str(level or "INFO")) + if level_name == "DEBUG": + message = message.replace("\n", "\\n").replace("\r", "\\r") + if len(message) > 2_000: + message = f"{message[:2_000]}…" + timestamp = record.get("time") + format_time = getattr(timestamp, "strftime", None) + if callable(format_time): + time_text = format_time("%Y-%m-%d %H:%M:%S") + else: + time_text = time.strftime("%Y-%m-%d %H:%M:%S") + extra = record.get("extra") + account = extra.get("account", "系统") if isinstance(extra, Mapping) else "系统" + return f"{time_text}|{level_name:<7}|{account}|{message}\n" + + +def _setup_logging( + runtime: RuntimeConfig, redactor: LogRedactor, *, stream: TextIO = sys.stdout +) -> tuple[Any, str]: + runtime.paths.logs_dir.mkdir(parents=True, exist_ok=True) + run_start_ts = time.strftime("%Y%m%d-%H%M%S") + base_logger.remove() + base_logger.configure(patcher=redactor) + + def terminal_sink(message: Any) -> None: + stream.write(render_console_record(message.record)) + stream.flush() + + base_logger.add(terminal_sink, format="{message}", colorize=False) + system_format = "{time:YYYY-MM-DD HH:mm:ss}|{level:<7}|{extra[account]}|{message}" + base_logger.add( + runtime.paths.logs_dir / f"weban-{run_start_ts}.log", + encoding="utf-8", + format=system_format, + rotation="10 MB", + retention="7 days", + filter=lambda record: record["extra"].get("account") == "系统", ) - opts, unknown = parser.parse_known_args() - return opts, unknown + return base_logger.bind(account="系统"), run_start_ts + +def _make_account_filter(log_key: str) -> Callable[[Any], bool]: + return lambda record: record["extra"].get("account") == log_key -def merge_ai_config(opts: argparse.Namespace, ai: dict) -> dict: - """CLI/环境变量覆盖 AI 配置([ai] 段):CLI > 环境变量 > 配置文件。 - 未通过 CLI/env 指定的字段保留配置文件原值。 +class StopRequested(InterruptedError): + """停止事件打断业务模块中的同步等待。""" + + +class InterruptibleTime: + """保留 time 模块接口,仅把 sleep 替换成可中断等待。""" + + def __init__(self, stop_event: threading.Event): + self._stop_event = stop_event + + def sleep(self, seconds: float) -> None: + delay = max(0.0, float(seconds)) + if self._stop_event.wait(delay): + raise StopRequested("运行已被中断") + + def __getattr__(self, name: str) -> Any: + return getattr(time, name) + + +@dataclass(frozen=True) +class RuntimeDependencies: + """入口注入到账号任务中的业务构造器。 + + 浏览器可用性不在启动期预检:CaptchaHandler 会在首次真正需要验证码时 + 才探测 CDP/本地浏览器,避免纯同步或无验证码的运行被无关的浏览器缺失阻断。 """ - merged = dict(ai) - cli_map = { - "enable": opts.ai_enable, - "base_url": opts.ai_base_url, - "api_key": opts.ai_api_key, - "model": opts.ai_model, - "timeout": opts.ai_timeout, - "max_retries": opts.ai_max_retries, - } - for key, cli_val in cli_map.items(): - env_val = os.environ.get(f"WB_AI_{key.upper()}") - if cli_val is not None: - merged[key] = ( - str(cli_val).strip().lower() in ("1", "true", "yes") - if key == "enable" - else cli_val - ) - elif env_val is not None: - merged[key] = ( - env_val.strip().lower() in ("1", "true", "yes") - if key == "enable" - else env_val - ) - return merged + client_class: type[Any] + + +def _set_module_attr(module: ModuleType, name: str, value: object) -> None: + setattr(module, name, value) -# 命令行 > 环境变量 > 自动检测(配置文件在 load_config 后再合并) -_OPTS, _ = _parse_args() -if _OPTS.data_dir: - _data_dir = _OPTS.data_dir -elif os.environ.get("WB_DATA_DIR"): - _data_dir = os.environ["WB_DATA_DIR"] -else: - _data_dir = None +def _load_business_modules() -> tuple[ModuleType, ModuleType]: + # 局部静态 import 同时保证启动校验前不加载浏览器依赖,并让 PyInstaller + # 能发现模块;测试可替换此加载边界而不导入真实浏览器。 + import captcha + import client -def _detect_non_interactive() -> bool: - """判定是否无交互运行。 + return captcha, client - 优先级:--non-interactive 显式指定 > 环境/自动检测。 - 环境/自动检测复用 captcha.is_non_interactive(): - - ENVIRONMENT=docker(或 container):Dockerfile 默认设置,容器环境 - - stdin 不是 TTY:Docker 无 -it、cron、管道、后台运行、SSH 无 TTY - 会话等都无法接收用户输入,自动进入无交互模式。不用某个专用 - "交互开关"环境变量,因为无交互的环境远不止 docker,且 docker - -it 时其实可以交互。 + +def _apply_runtime_adapters( + runtime: RuntimeConfig, stop_event: threading.Event +) -> RuntimeDependencies: + """导入业务模块后注入统一路径、交互策略和可中断等待。 + + 当前 client/captcha 尚无这些构造参数,因此入口使用兼容适配;若后续模块 + 增加正式参数,_create_client() 会自动优先传入。 """ - if _OPTS.non_interactive: - return True - return is_non_interactive() + os.environ["WB_DATA_DIR"] = str(runtime.paths.data_dir) + os.environ["WB_NON_INTERACTIVE"] = ( + "1" if runtime.interaction.non_interactive else "0" + ) + captcha_module, client_module = _load_business_modules() + + policy_fn = lambda: runtime.interaction.non_interactive + _set_module_attr(captcha_module, "is_non_interactive", policy_fn) + _set_module_attr(client_module, "is_non_interactive", policy_fn) + + interruptible_time = InterruptibleTime(stop_event) + _set_module_attr(captcha_module, "time", interruptible_time) + _set_module_attr(client_module, "time", interruptible_time) + + # client.py 目前在导入时计算这些全局路径;显式覆盖可同时修复 + # --data-dir 晚于 import 生效和自定义 --config 路径不统一的问题。 + _set_module_attr(client_module, "base_path", str(runtime.paths.data_dir)) + _set_module_attr(client_module, "answer_dir", str(runtime.paths.answer_dir)) + _set_module_attr( + client_module, + "answer_path", + str(runtime.paths.answer_dir / "answer.json"), + ) + _set_module_attr( + client_module, + "root_answer_path", + str(runtime.paths.data_dir / "answer.json"), + ) + + original_handler = getattr(client_module, "_weban_original_captcha_handler", None) + if original_handler is None: + original_handler = client_module.__dict__["CaptchaHandler"] + _set_module_attr( + client_module, "_weban_original_captcha_handler", original_handler + ) -NON_INTERACTIVE = _detect_non_interactive() + def configured_captcha_handler(*args: Any, **kwargs: Any) -> Any: + tenant_code = kwargs.get("tenant_code") + user_id = kwargs.get("user_id") + if tenant_code is None and args: + tenant_code = args[0] + if user_id is None and len(args) > 1: + user_id = args[1] + digest = hashlib.sha256( + f"{tenant_code or ''}\0{user_id or ''}".encode() + ).hexdigest()[:16] + kwargs.setdefault( + "debug_dir", + runtime.paths.captcha_debug_dir / f"account-{digest}", + ) + kwargs.setdefault("non_interactive", runtime.interaction.non_interactive) + kwargs.setdefault("stop_event", stop_event) + return original_handler(*args, **kwargs) + _set_module_attr(client_module, "CaptchaHandler", configured_captcha_handler) + return RuntimeDependencies(client_class=client_module.__dict__["WeBanClient"]) -def _resolve_version() -> str: - """版本号单一来源:pyproject.toml(打包后从冻结资源读取),importlib.metadata 作回退""" - candidates = [] - if getattr(sys, "frozen", False): - bundle = getattr(sys, "_MEIPASS", None) - if bundle: - candidates.append(os.path.join(bundle, "pyproject.toml")) - candidates.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "pyproject.toml")) - for path in candidates: - try: - with open(path, "rb") as f: - version = tomllib.load(f).get("project", {}).get("version") - if version: - return version - except (OSError, tomllib.TOMLDecodeError): - continue +class AccountRunStatus(str, Enum): + SUCCESS = "success" + INCOMPLETE = "incomplete" + FAILED = "failed" + CANCELLED = "cancelled" - try: - from importlib.metadata import version as _dist_version - return _dist_version("weban") - except ImportError: # PackageNotFoundError 是其子类,统一回退 unknown - return "unknown" +@dataclass(frozen=True) +class AccountRunResult: + account_index: int + log_key: str + status: AccountRunStatus + detail: str = "" -VERSION = f"v{_resolve_version()}" +@dataclass(frozen=True) +class RunSummary: + results: tuple[AccountRunResult, ...] -if getattr(sys, "frozen", False): - base_path = os.path.dirname(os.path.abspath(sys.executable)) - bundle_path = sys._MEIPASS # type: ignore[attr-defined] -else: - base_path = os.path.dirname(os.path.abspath(__file__)) - bundle_path = base_path - -if _data_dir: - # Docker/数据目录模式:config/logs/answer 全部放数据目录(可挂载持久化) - config_path = os.path.join(_data_dir, "config.toml") - logs_dir = os.path.join(_data_dir, "logs") -else: - config_path = os.path.join(base_path, "config.toml") - logs_dir = os.path.join(base_path, "logs") -# 模板可能位于: 打包资源目录(_MEIPASS, onefile 解压) / exe 旁 / 源码目录 -# frozen 时 base_path 是 exe 目录而模板在 bundle 里,必须 bundle 优先 -config_example_candidates = [ - os.path.join(bundle_path, "config.example.toml"), - os.path.join(base_path, "config.example.toml"), -] - -# 本次进程启动时间戳,用于日志文件名区分每次运行(如 20260810-132642) -run_start_ts = time.strftime("%Y%m%d-%H%M%S") - - -def _log_format_message(record) -> str: - """终端 sink 的格式化函数。 - - DEBUG 级别的请求/响应详情(含 HTML 页面)带大量换行,转义成单行 - 并限制字数,避免刷屏;其余级别(INFO/SUCCESS/WARNING/ERROR 等) - 保持消息原样,正常换行不受影响。日志文件 sink 不经过此函数。 - """ - if record["level"].name == "DEBUG": - msg = record["message"].replace("\n", "\\n").replace("\r", "\\r") - if len(msg) > 2000: - msg = msg[:2000] - record["message"] = msg - # 注意:format 为函数时 loguru 不会自动补结尾换行(字符串格式才会), - # 必须显式加 \n,否则终端所有日志挤在一行 - return log_format + "\n" # loguru 会基于修改后的 record 替换占位符 - -# 远程模板下载地址(jsDelivr CDN 稳定;gh-proxy 免费公共代理会限流 403, -# github 官方 raw 域名在国内不稳定,均不用) -CONFIG_EXAMPLE_URL = ( - "https://cdn.jsdelivr.net/gh/hangone/WeBan@main/config.example.toml" -) + @property + def success_count(self) -> int: + return sum(result.status is AccountRunStatus.SUCCESS for result in self.results) -# ── 日志 ── -logger.remove() -logger = logger.bind(account="系统") -log_format = ( - "{time:YYYY-MM-DD HH:mm:ss}|" - "{level:<7}|" - "{extra[account]}|" - "{message}" -) -# 终端输出转义为单行并截断超长消息(DEBUG 模式请求/响应详情可能刷屏); -# 日志文件使用完整格式,不转义、不截断 -logger.add( - sink=sys.stdout, - colorize=True, - format=_log_format_message, -) + @property + def failed_count(self) -> int: + return sum(result.status is AccountRunStatus.FAILED for result in self.results) -os.makedirs(logs_dir, exist_ok=True) -logger.add( - os.path.join(logs_dir, f"weban-{run_start_ts}.log"), - encoding="utf-8", - format=log_format, - retention="7 days", -) + @property + def incomplete_count(self) -> int: + return sum( + result.status is AccountRunStatus.INCOMPLETE for result in self.results + ) -# 同步锁,防止同时读写题库 -sync_lock = threading.Lock() + @property + def cancelled_count(self) -> int: + return sum( + result.status is AccountRunStatus.CANCELLED for result in self.results + ) + @property + def exit_code(self) -> int: + if not self.results: + return EXIT_FAILURE + if self.success_count == len(self.results): + return EXIT_SUCCESS + if self.success_count == 0: + return EXIT_FAILURE + return EXIT_PARTIAL_FAILURE + + +def _raise_if_stopped(stop_event: threading.Event) -> None: + if stop_event.is_set(): + raise StopRequested("运行已被中断") + + +def _workflow_status(result: Any) -> str: + """读取业务层结构化结果,同时兼容旧客户端返回 None。""" + + if result is None: + return "success" + status = getattr(result, "status", None) + value = getattr(status, "value", status) + if isinstance(value, str): + return value.lower() + ok = getattr(result, "ok", None) + if ok is True: + return "success" + if ok is False: + return "incomplete" + return "success" + + +def _workflow_message(result: Any, fallback: str) -> str: + message = getattr(result, "message", "") + return str(message).strip() or fallback + + +@contextmanager +def _interruptible_lock(lock: Any, stop_event: threading.Event) -> Iterator[None]: + acquired = False + while not acquired: + _raise_if_stopped(stop_event) + acquired = lock.acquire(timeout=0.2) + try: + yield + finally: + lock.release() + + +def _client_kwargs( + account: ResolvedAccount, + runtime: RuntimeConfig, + stop_event: threading.Event, + log: Any, + client_class: type[Any], +) -> dict[str, Any]: + settings = account.settings + kwargs: dict[str, Any] = { + "log": log, + "browser_path": settings.browser_path, + "cdp_host": settings.cdp_host, + "cdp_port": settings.cdp_port, + "debug": settings.debug, + "ai_config": runtime.ai.as_dict(), + "video_speed": settings.video_speed, + "jupiter_fallback": settings.jupiter_fallback, + } + try: + parameters = inspect.signature(client_class).parameters + except (TypeError, ValueError): + parameters = {} + optional_integration = { + "interaction_policy": runtime.interaction, + "non_interactive": runtime.interaction.non_interactive, + "stop_event": stop_event, + "data_dir": runtime.paths.data_dir, + "captcha_debug_dir": ( + runtime.paths.captcha_debug_dir + / account.identity.tenant_dir + / account.identity.account_dir + ), + } + for name, value in optional_integration.items(): + if name in parameters: + kwargs[name] = value + return kwargs + + +def _create_client( + account: ResolvedAccount, + runtime: RuntimeConfig, + stop_event: threading.Event, + log: Any, + client_class: type[Any], +) -> Any: + credentials = account.credentials + kwargs = _client_kwargs(account, runtime, stop_event, log, client_class) + if credentials.uses_token: + return client_class( + credentials.tenant_name, + user={"userId": credentials.user_id, "token": credentials.token}, + **kwargs, + ) + return client_class( + credentials.tenant_name, + credentials.username, + credentials.password, + **kwargs, + ) -# ── 更新检查 ────────────────────────────────────────────── -GITHUB_REPO = "hangone/WeBan" -# 网络异常时的请求超时(秒):检查失败也不能让用户久等 -UPDATE_CHECK_TIMEOUT = 3 +def run_account( + account: ResolvedAccount, + runtime: RuntimeConfig, + account_index: int, + stop_event: threading.Event, + dependencies: RuntimeDependencies, + logger: Any, + run_start_ts: str, +) -> AccountRunResult: + """运行一个账号并返回结构化结果,不把异常转换成进程级成功。""" + + identity = account.identity + account_log_dir = ( + runtime.paths.logs_dir / identity.tenant_dir / identity.account_dir + ) + try: + account_log_dir.mkdir(parents=True, exist_ok=True) + handler_id = base_logger.add( + account_log_dir / f"weban-{run_start_ts}.log", + encoding="utf-8", + level="DEBUG", + format=("{time:YYYY-MM-DD HH:mm:ss}|{level:<7}|{extra[account]}|{message}"), + rotation="10 MB", + retention="7 days", + filter=_make_account_filter(identity.log_key), + ) + except OSError as exc: + logger.error(f"{identity.log_key} 无法创建独立日志:{type(exc).__name__}") + return AccountRunResult( + account_index, + identity.log_key, + AccountRunStatus.FAILED, + "log_setup_failed", + ) + + log = base_logger.bind(account=identity.log_key) + client: Any = None + incomplete_reasons: list[str] = [] + try: + _raise_if_stopped(stop_event) + settings = account.settings + random_answer = settings.random_answer + if runtime.interaction.non_interactive and not random_answer: + log.warning("无交互模式下强制启用随机作答") + random_answer = True + + if account.credentials.uses_token: + log.info("使用 Token 凭据登录") + else: + log.info("使用密码凭据登录") + client = _create_client( + account, runtime, stop_event, log, dependencies.client_class + ) + _raise_if_stopped(stop_event) + if not client.login(): + log.error("登录失败") + return AccountRunResult( + account_index, + identity.log_key, + AccountRunStatus.FAILED, + "login_failed", + ) + + log.info("登录成功,模拟打开首页") + client.simulate_home_page() + _raise_if_stopped(stop_event) + log.info("开始同步答案") + with _interruptible_lock(_SYNC_LOCK, stop_event): + _raise_if_stopped(stop_event) + initial_sync = client.sync_answers() + initial_sync_status = _workflow_status(initial_sync) + if initial_sync_status in {"failed", "incomplete"}: + reason = _workflow_message(initial_sync, "初始题库同步未完整完成") + log.warning(reason) + incomplete_reasons.append(reason) + elif initial_sync_status == "locked": + log.error("初始题库同步报告账号已锁定") + return AccountRunResult( + account_index, + identity.log_key, + AccountRunStatus.FAILED, + "account_locked", + ) + + client.exam_mode = settings.exam_mode + _raise_if_stopped(stop_event) + workflow = client.run_project_cycle( + study_time=settings.study_time, + study_mode=settings.study_mode, + exam_mode=settings.exam_mode, + random_answer=random_answer, + exam_question_time=settings.exam_question_time, + exam_submit_match_rate=settings.exam_submit_match_rate, + ) + workflow_status = _workflow_status(workflow) + if workflow_status in {"failed", "locked"}: + reason = _workflow_message(workflow, "学习或考试流程失败") + log.error(reason) + return AccountRunResult( + account_index, + identity.log_key, + AccountRunStatus.FAILED, + f"workflow_{workflow_status}", + ) + if workflow_status == "incomplete": + reason = _workflow_message(workflow, "学习或考试未完整完成") + log.warning(reason) + incomplete_reasons.append(reason) + + _raise_if_stopped(stop_event) + log.info("最终同步答案") + with _interruptible_lock(_SYNC_LOCK, stop_event): + _raise_if_stopped(stop_event) + final_sync = client.sync_answers() + final_sync_status = _workflow_status(final_sync) + if final_sync_status in {"failed", "incomplete"}: + reason = _workflow_message(final_sync, "最终题库同步未完整完成") + log.warning(reason) + incomplete_reasons.append(reason) + elif final_sync_status == "locked": + log.error("最终题库同步报告账号已锁定") + return AccountRunResult( + account_index, + identity.log_key, + AccountRunStatus.FAILED, + "account_locked", + ) + + if incomplete_reasons: + log.warning("执行结束,但存在未完整确认的阶段") + return AccountRunResult( + account_index, + identity.log_key, + AccountRunStatus.INCOMPLETE, + "; ".join(dict.fromkeys(incomplete_reasons)), + ) + log.success("执行完成") + return AccountRunResult( + account_index, identity.log_key, AccountRunStatus.SUCCESS + ) + except (StopRequested, InterruptedError): + log.warning("任务已中断") + return AccountRunResult( + account_index, + identity.log_key, + AccountRunStatus.CANCELLED, + "interrupted", + ) + except PermissionError as exc: + log.error(f"权限错误:{exc}") + return AccountRunResult( + account_index, + identity.log_key, + AccountRunStatus.FAILED, + "permission_error", + ) + except (RuntimeError, ValueError) as exc: + log.error(f"运行失败({type(exc).__name__}):{exc}") + return AccountRunResult( + account_index, + identity.log_key, + AccountRunStatus.FAILED, + type(exc).__name__, + ) + except Exception as exc: # noqa: BLE001 - 账号边界必须转为结构化失败 + log.error(f"未预期错误({type(exc).__name__}):{exc}") + return AccountRunResult( + account_index, + identity.log_key, + AccountRunStatus.FAILED, + type(exc).__name__, + ) + finally: + close_client = getattr(client, "close", None) + if close_client is not None: + try: + close_client() + except Exception as exc: # noqa: BLE001 - 清理失败不能覆盖原始结果 + log.error(f"客户端资源清理失败({type(exc).__name__})") + base_logger.remove(handler_id) + + +def _probe_login( + account: ResolvedAccount, + runtime: RuntimeConfig, + stop_event: threading.Event, + dependencies: RuntimeDependencies, +) -> bool: + log = base_logger.bind(account=account.identity.log_key) + log.info("正在验证交互输入的账号") + client: Any = None + try: + client = _create_client( + account, runtime, stop_event, log, dependencies.client_class + ) + return bool(client.login()) + except (OSError, RuntimeError, ValueError, PermissionError) as exc: + log.error(f"账号验证失败({type(exc).__name__}):{exc}") + return False + finally: + close_client = getattr(client, "close", None) + if close_client is not None: + try: + close_client() + except Exception as exc: # noqa: BLE001 - 验证结果优先 + log.error(f"验证客户端清理失败({type(exc).__name__})") def _parse_version(text: str) -> tuple[int, ...]: - """版本号解析为可比较的整数元组(忽略非数字段,如 v3.9.6 → (3,9,6))""" return tuple(int(part) for part in re.findall(r"\d+", text or "")) -def _run_update_check() -> None: - """执行一次 GitHub 最新 Release 检查并输出结果(同步实现)。 - - 有新版 → WARNING 提示下载地址;无新版 → DEBUG; - 网络/HTTP/解析失败 → WARNING 说明原因并跳过(不重试、不阻塞)。 - """ +def _run_update_check(logger: Any) -> None: try: - resp = requests.get( + response = requests.get( f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest", headers={ "Accept": "application/vnd.github+json", @@ -381,85 +674,99 @@ def _run_update_check() -> None: }, timeout=UPDATE_CHECK_TIMEOUT, ) - except requests.RequestException as e: - logger.warning(f"检查更新失败(网络异常):{e},已跳过") + except requests.RequestException as exc: + logger.warning(f"检查更新失败(网络异常):{exc},已跳过") return - if resp.status_code != 200: - logger.warning(f"检查更新失败:GitHub API 返回 {resp.status_code},已跳过") + if response.status_code != 200: + logger.warning(f"检查更新失败:HTTP {response.status_code},已跳过") return try: - data = resp.json() - if not isinstance(data, dict): - logger.warning("检查更新失败:响应格式异常,已跳过") - return + data = response.json() except ValueError: logger.warning("检查更新失败:响应解析异常,已跳过") return - latest = data.get("tag_name") or "" + if not isinstance(data, dict): + logger.warning("检查更新失败:响应格式异常,已跳过") + return + latest = str(data.get("tag_name") or "") if _parse_version(latest) > _parse_version(VERSION): latest_url = data.get("html_url") or ( f"https://github.com/{GITHUB_REPO}/releases/latest" ) - logger.warning( - f"发现新版本 {latest}(当前 {VERSION}),请前往 {latest_url} 下载更新" - ) + logger.warning(f"发现新版本 {latest},下载地址:{latest_url}") else: logger.info(f"已是最新版本({VERSION})") -def _check_update_async() -> None: - """异步检查更新:后台线程执行,不阻塞主流程;失败只提示不等待""" - threading.Thread(target=_run_update_check, daemon=True, name="update-check").start() - +def _check_update_async(logger: Any) -> None: + threading.Thread( + target=_run_update_check, + args=(logger,), + daemon=True, + name="update-check", + ).start() -# ── 工具函数 ────────────────────────────────────────────── +def _toml_string(value: str) -> str: + return json.dumps(value, ensure_ascii=False) -def open_editor(path: str): - """打开系统编辑器编辑指定文件""" - logger.info(f"配置文件路径: {path}") - try: - if sys.platform == "win32": - subprocess.Popen(["notepad", path]) - elif sys.platform == "darwin": - subprocess.Popen(["open", "-t", path]) - else: - subprocess.Popen(["xdg-open", path]) - except FileNotFoundError: - logger.warning("无法打开编辑器,请手动编辑上述文件") - try: - print("编辑完成后按回车键继续...", flush=True) - input() - except EOFError: # stdin 关闭(如管道执行)时直接结束 - pass - - -def is_account_valid(account: dict) -> bool: - """检查账号是否有效:tenant_name 非空 AND (username 非空 OR (user_id 非空 AND token 非空))""" - tenant_name = account.get("tenant_name", "").strip() - username = account.get("username", "").strip() - user_id = account.get("user_id", "").strip() - token = account.get("token", "").strip() - return bool(tenant_name) and (bool(username) or (bool(user_id) and bool(token))) +def _first_local_template(runtime: RuntimeConfig) -> str | None: + for candidate in runtime.paths.config_example_candidates: + try: + return candidate.read_text(encoding="utf-8") + except OSError: + continue + return None -def _toml_escape(s: str) -> str: - """TOML 基本字符串转义(反斜杠与双引号)""" - return s.replace("\\", "\\\\").replace('"', '\\"') +def save_interactive_account( + runtime: RuntimeConfig, credentials: AccountCredentials, logger: Any +) -> None: + """原子保存交互凭据,并尽可能将配置权限限制为当前用户读写。""" -def prompt_account_interactive() -> dict | None: - """交互式提示输入学校/用户名/密码,返回账号 dict;输入被中断或未填完整返回 None""" + config_path = runtime.paths.config_path + if config_path.exists(): + try: + content = config_path.read_text(encoding="utf-8") + except OSError as exc: + raise ConfigError(f"无法读取配置文件:{config_path}") from exc + else: + content = _first_local_template(runtime) or "# WeBan 配置文件\n[settings]\n" + if content and not content.endswith("\n"): + content += "\n" + content += ( + "\n[[account]]\n" + f"tenant_name = {_toml_string(credentials.tenant_name)}\n" + f"username = {_toml_string(credentials.username)}\n" + f"password = {_toml_string(credentials.password)}\n" + f"user_id = {_toml_string(credentials.user_id)}\n" + f"token = {_toml_string(credentials.token)}\n" + ) + atomic_write_text(config_path, content, mode=0o600) + logger.success(f"账号已安全保存到 {config_path}") + + +def prompt_account_interactive( + policy: InteractionPolicy, + *, + input_fn: Callable[[str], str] | None = None, + password_fn: Callable[[str], str] | None = None, +) -> dict[str, str] | None: + """交互读取账号;密码使用 getpass,不在终端回显。""" + + if not policy.allow_input: + raise RuntimeError("无交互模式禁止读取终端输入") + read_input = input if input_fn is None else input_fn + read_password = getpass.getpass if password_fn is None else password_fn print("\n请输入账号信息:") try: - tenant_name = input(" 学校全称(如:北京交通大学-本科生): ").strip() - username = input(" 用户名(学号): ").strip() - password = input(" 密码(默认同用户名): ").strip() - except (EOFError, KeyboardInterrupt): - logger.warning("输入被中断") + tenant_name = read_input(" 学校全称:").strip() + username = read_input(" 用户名(学号):").strip() + password = read_password(" 密码(默认同用户名):") + except EOFError: return None if not tenant_name or not username: - logger.error("学校全称和用户名不能为空") return None return { "tenant_name": tenant_name, @@ -468,593 +775,299 @@ def prompt_account_interactive() -> dict | None: } -def save_interactive_account(account: dict) -> None: - """登录验证通过后把账号写入 config.toml。 - - - 文件已存在:追加 [[account]](TOML 数组表可分散出现,不影响已有设置) - - 文件不存在:以配置文件模板为底,填入账号后创建 - 仅在登录验证成功后调用;失败路径不触碰配置文件。 - """ - os.makedirs(os.path.dirname(config_path) or ".", exist_ok=True) - account_lines = ( - "\n[[account]]\n" - f'tenant_name = "{_toml_escape(account["tenant_name"])}"\n' - f'username = "{_toml_escape(account["username"])}"\n' - f'password = "{_toml_escape(account["password"])}"\n' - ) - if os.path.exists(config_path): - with open(config_path, "a", encoding="utf-8") as f: - f.write(account_lines) +def open_editor( + path: Path, + policy: InteractionPolicy, + *, + input_fn: Callable[[str], str] | None = None, +) -> None: + if not policy.allow_input: + raise RuntimeError("无交互模式禁止打开编辑器") + if sys.platform == "win32": + command = ["notepad", str(path)] + elif sys.platform == "darwin": + command = ["open", "-t", str(path)] else: - template = read_first_existing(config_example_candidates) - if template is not None: - content = template - for key in ("tenant_name", "username", "password"): - # 只替换 [[account]] 段第一个未注释的同名键(count=1 且首个匹配即目标) - content = re.sub( - rf'^{key} = ""$', - f'{key} = "{_toml_escape(account[key])}"', - content, - count=1, - flags=re.MULTILINE, + command = ["xdg-open", str(path)] + try: + subprocess.Popen(command) + except OSError as exc: + raise RuntimeError("无法打开配置编辑器") from exc + read_input = input if input_fn is None else input_fn + read_input("编辑完成后按回车键继续...") + + +def _execute_accounts( + runtime: RuntimeConfig, + stop_event: threading.Event, + dependencies: RuntimeDependencies, + logger: Any, + run_start_ts: str, + *, + use_multithread: bool, +) -> RunSummary: + if not use_multithread or len(runtime.accounts) <= 1: + logger.info("使用单线程模式,逐个执行") + results: list[AccountRunResult] = [] + for index, account in enumerate(runtime.accounts): + _raise_if_stopped(stop_event) + results.append( + run_account( + account, + runtime, + index, + stop_event, + dependencies, + logger, + run_start_ts, ) - else: - content = ( - "# WeBan 配置文件(由程序自动生成)\n" - "[settings]\n" - + account_lines ) - with open(config_path, "w", encoding="utf-8") as f: - f.write(content) - logger.success(f"登录成功,账号已保存到 {config_path}") - - -# ── 配置加载 ────────────────────────────────────────────── - + return RunSummary(tuple(results)) -def load_config() -> dict: - """加载 config.toml,不存在时按模式处理: - - 无交互模式:下载/创建模板但不打开编辑器(容器/后台) - - 交互模式:不生成文件,返回空配置,由主流程提示交互输入账号 - (登录成功后才写配置文件,登录失败不生成) - """ - if not os.path.exists(config_path): - if not NON_INTERACTIVE: - logger.info( - f"未找到配置文件({config_path}),接下来按提示输入学校、学号、密码即可" - ) - return {"settings": {}, "ai": {}, "account": []} - logger.info("config.toml 不存在,正在下载远程模板...") - downloaded = False - try: - resp = requests.get(CONFIG_EXAMPLE_URL, timeout=30) - resp.raise_for_status() - os.makedirs(os.path.dirname(config_path), exist_ok=True) - with open(config_path, "w", encoding="utf-8") as f: - f.write(resp.text) - logger.success(f"远程模板已下载到 {config_path}") - downloaded = True - except OSError as e: - logger.warning(f"下载远程模板失败 ({CONFIG_EXAMPLE_URL}): {e}") - - if not downloaded: - local_template = read_first_existing(config_example_candidates) - if local_template is not None: - os.makedirs(os.path.dirname(config_path), exist_ok=True) - with open(config_path, "w", encoding="utf-8") as f: - f.write(local_template) - logger.success(f"已从本地模板创建 {config_path}") - downloaded = True - - if os.path.exists(config_path): - logger.warning( - "已创建空配置模板,请在挂载的数据目录中填写账号信息后重试" + max_workers = min(len(runtime.accounts), runtime.max_workers) + logger.info(f"使用多线程模式,最大并发数:{max_workers}") + executor = ThreadPoolExecutor(max_workers=max_workers) + futures: dict[Future[AccountRunResult], int] = {} + results = [] + try: + for index, account in enumerate(runtime.accounts): + future = executor.submit( + run_account, + account, + runtime, + index, + stop_event, + dependencies, + logger, + run_start_ts, ) - # 重新加载 - with open(config_path, "rb") as f: - return tomllib.load(f) - else: - logger.error("无法创建配置文件") - sys.exit(1) - - with open(config_path, "rb") as f: - return tomllib.load(f) - - -# ── 账号级日志过滤器 ───────────────────────────────────────── - - -def _make_account_filter(account_name: str): - """返回一个 loguru filter,只放行 extra[account] == account_name 的日志记录""" - - def filter_fn(record) -> bool: - return record["extra"].get("account") == account_name - - return filter_fn - - -# ── 单个账号执行 ──────────────────────────────────────────── - - -def run_account( - account_config: dict, global_settings: dict, ai_config: dict, account_index: int -) -> bool: - """运行单个账号的任务 - - :param account_config: [[account]] 的字典 - :param global_settings: [settings] 的字典 - :param ai_config: [ai] 的字典 - :param account_index: 账号序号 - :return: 成功返回 True,失败返回 False - """ - - def get_setting(key, default=None): - """账号级优先,回退到全局设置""" - val = account_config.get(key) - if val is not None and val != "": - return val - return global_settings.get(key, default) - - def cli_or_env(key: str, cli_val, default=None): - """命令行参数 > 环境变量 > 默认值""" - if cli_val is not None: - return cli_val - env_val = os.environ.get(f"WB_{key.upper()}") - if env_val is not None: - return env_val - return default - - # 必填字段(password 默认为 username) - tenant_name = account_config.get("tenant_name", "").strip() - username = account_config.get("username", "").strip() - password = account_config.get("password", "") or username - user_id = account_config.get("user_id", "") - token_val = account_config.get("token", "") - - # 账号标识(用于日志文件夹名) - account_name = username or user_id or f"account_{account_index}" - - # 合并设置(账号级优先,回退到全局;CLI/环境变量最高优先) - study_mode = cli_or_env( - "study_mode", _OPTS.study_mode, get_setting("study_mode", "true") - ) - exam_mode = cli_or_env( - "exam_mode", _OPTS.exam_mode, get_setting("exam_mode", "true") - ) - random_answer_raw = cli_or_env( - "random_answer", _OPTS.random_answer, get_setting("random_answer", True) - ) - if isinstance(random_answer_raw, str): - random_answer = random_answer_raw.strip().lower() in ("1", "true", "yes") - else: - random_answer = bool(random_answer_raw) - # 无交互模式下不允许手动输入答案,强制随机作答 - if NON_INTERACTIVE and not random_answer: - log = logger.bind(account=account_name) - log.warning("无交互模式下强制启用随机作答(random_answer=true)") - random_answer = True - study_time = cli_or_env( - "study_time", _OPTS.study_time, get_setting("study_time", "20,10") - ) - video_speed = float( - cli_or_env( - "video_speed", - _OPTS.video_speed, - get_setting("video_speed", 1), - ) - ) - exam_question_time = cli_or_env( - "exam_question_time", _OPTS.exam_question_time, get_setting("exam_question_time", "3,3") - ) - exam_submit_match_rate = int( - cli_or_env( - "exam_submit_match_rate", - _OPTS.exam_submit_match_rate, - get_setting("exam_submit_match_rate", 90), - ) - ) - browser_path = ( - _OPTS.browser_path - or os.environ.get("WB_BROWSER_PATH", "").strip() - or get_setting("browser_path", "") - or None - ) - cdp_host = cli_or_env( - "cdp_host", _OPTS.cdp_host, get_setting("cdp_host", "") or None - ) - cdp_port_raw = cli_or_env( - "cdp_port", _OPTS.cdp_port, get_setting("cdp_port", 0) or None - ) - cdp_port = int(cdp_port_raw) if cdp_port_raw else None - debug_raw = cli_or_env("debug", _OPTS.debug, get_setting("debug", False)) - if isinstance(debug_raw, str): - debug = debug_raw.strip().lower() in ("1", "true", "yes") + futures[future] = index + for future in as_completed(futures): + index = futures[future] + try: + results.append(future.result()) + except Exception as exc: # noqa: BLE001 - future 边界兜底 + logger.error( + f"账号任务 {index + 1} 异常({type(exc).__name__}):{exc}" + ) + results.append( + AccountRunResult( + index, + f"账号{index + 1:02d}", + AccountRunStatus.FAILED, + type(exc).__name__, + ) + ) + except KeyboardInterrupt: + stop_event.set() + for future in futures: + future.cancel() + executor.shutdown(wait=True, cancel_futures=True) + raise else: - debug = bool(debug_raw) - jupiter_fallback_raw = cli_or_env( - "jupiter_fallback", _OPTS.jupiter_fallback, get_setting("jupiter_fallback", False) - ) - jupiter_fallback = str(jupiter_fallback_raw).lower() in ( - "1", - "true", - "yes", - ) - - # 为该账号创建专属日志文件夹 - account_log_dir = os.path.join(logs_dir, account_name) - os.makedirs(account_log_dir, exist_ok=True) - account_log_path = os.path.join( - account_log_dir, f"weban-{run_start_ts}.log" - ) - - # 添加只属于该账号的日志 sink(debug 请求/响应详情走 DEBUG 级别,需放行) - account_filter = _make_account_filter(account_name) - handler_id = logger.add( - account_log_path, - encoding="utf-8", - level="DEBUG", - format=log_format, - filter=account_filter, - ) - - log = logger.bind(account=account_name) - + executor.shutdown(wait=True) + results.sort(key=lambda result: result.account_index) + return RunSummary(tuple(results)) + + +def _register_runtime_secrets(redactor: LogRedactor, runtime: RuntimeConfig) -> None: + redactor.register(runtime.ai.api_key) + for account in runtime.accounts: + redactor.register_account(account) + + +def _build_runtime( + argv: list[str] | None, + env: Mapping[str, str], +) -> tuple[Any, dict[str, Any], RuntimeConfig, bool]: + opts = parse_args(argv) + paths = resolve_paths(opts, env, script_path=__file__) + document = load_toml(paths.config_path) + raw_settings = document.get("settings", {}) + if raw_settings is None: + raw_settings = {} + if not isinstance(raw_settings, dict): + raise ConfigError("[settings] 必须是 TOML 表") + policy = resolve_interaction_policy(opts, raw_settings, env) + created_template = False + if not paths.config_path.exists() and policy.non_interactive: + created_template = create_local_config_template(paths) + document = load_toml(paths.config_path) + runtime = build_runtime_config(opts, document, paths, env) + return opts, document, runtime, created_template + + +def main( + argv: list[str] | None = None, + *, + env: Mapping[str, str] | None = None, +) -> int: + source_env = dict(os.environ if env is None else env) + stop_event = threading.Event() + logger: Any = None try: - # ── 构建客户端 ── - if token_val and user_id: - # Token 登录(优先) - user = {"userId": user_id, "token": token_val} - log.info(f"使用 Token 登录({tenant_name})") - client = WeBanClient( - tenant_name, - user=user, - log=log, - browser_path=browser_path, - cdp_host=cdp_host, - cdp_port=cdp_port, - debug=debug, - ai_config=ai_config, - video_speed=video_speed, - jupiter_fallback=jupiter_fallback, - ) - elif tenant_name and username: - # 密码登录 — password 默认为 username - log.info(f"使用密码登录({tenant_name})") - client = WeBanClient( - tenant_name, - username, - password, - log=log, - browser_path=browser_path, - cdp_host=cdp_host, - cdp_port=cdp_port, - debug=debug, - ai_config=ai_config, - video_speed=video_speed, - jupiter_fallback=jupiter_fallback, - ) - else: - log.error( - "缺少必要的配置信息: 需要填写 tenant_name 和 username," - "或 tenant_name + user_id + token" - ) - return False - - if not client.login(): - log.error("登录失败") - return False - - log.info(f"登录成功({tenant_name}),模拟打开首页") - client.simulate_home_page() - - log.info("登录成功,开始同步答案") - with sync_lock: - client.sync_answers() - - # ── 学习 + 考试(按项目交替:完成一个项目的课程和考试后切换下一个) ── - client.exam_mode = exam_mode # 进度预估需要知道考试是否计入 - client.run_project_cycle( - study_time=study_time, - study_mode=study_mode, - exam_mode=exam_mode, - random_answer=random_answer, - exam_question_time=exam_question_time, - exam_submit_match_rate=exam_submit_match_rate, - ) - - # ── 最终同步 ── - log.info("最终同步答案") - with sync_lock: - client.sync_answers() - - log.success("执行完成") - return True - - except PermissionError as e: - log.error(f"权限错误: {e}") - return False - except RuntimeError as e: - log.error(f"运行时错误: {e}") - return False - except ValueError as e: - log.error(f"参数错误: {e}") - return False - except Exception as e: # noqa: BLE001 -- 入口兜底,任何未预期异常都记录并返回失败 - log.error(f"运行失败: {e}") - traceback.print_exc(file=sys.stderr) - return False - finally: - logger.remove(handler_id) - + opts, document, runtime, created_template = _build_runtime(argv, source_env) + except ConfigError as exc: + print(f"配置错误:{exc}", file=sys.stderr) + return EXIT_CONFIG_ERROR + except KeyboardInterrupt: + return EXIT_INTERRUPTED -# ── 入口 ──────────────────────────────────────────────────── + redactor = LogRedactor() + _register_runtime_secrets(redactor, runtime) + try: + logger, run_start_ts = _setup_logging(runtime, redactor) + except OSError as exc: + print(f"日志初始化失败:{exc}", file=sys.stderr) + return EXIT_FAILURE + except KeyboardInterrupt: + return EXIT_INTERRUPTED -if __name__ == "__main__": + runtime_env_before = {name: os.environ.get(name) for name in _RUNTIME_ENV_KEYS} try: logger.info(f"程序启动,当前版本:{VERSION}") - _check_update_async() # 异步检查更新,失败/无新版不阻塞主流程 - logger.info("程序更新地址:https://github.com/hangone/WeBan") - - # 加载配置文件 - def load_all_config(): - config = load_config() - return ( - config.get("settings", {}), - config.get("ai", {}), - config.get("account", []), - ) - - global_settings, ai_config, accounts = load_all_config() - - # 命令行/环境变量直传单账号(免配置文件,如 Docker 单用户): - # --tenant-name + --username [--password] 或 WB_TENANT_NAME + WB_USERNAME [WB_PASSWORD] - # 或 --tenant-name + --user-id + --token(Token 登录) - cli_tenant = ( - _OPTS.tenant_name - or os.environ.get("WB_TENANT_NAME", "").strip() - ) - cli_username = _OPTS.username or os.environ.get("WB_USERNAME", "").strip() - cli_user_id = _OPTS.user_id or os.environ.get("WB_USER_ID", "").strip() - cli_token = _OPTS.token or os.environ.get("WB_TOKEN", "").strip() - if cli_tenant and cli_username: - cli_password = ( - _OPTS.password or os.environ.get("WB_PASSWORD", "") or cli_username - ) - cli_account = { - "tenant_name": cli_tenant, - "username": cli_username, - "password": cli_password, - } - elif cli_tenant and cli_user_id and cli_token: - cli_account = { - "tenant_name": cli_tenant, - "user_id": cli_user_id, - "token": cli_token, - } - else: - cli_account = None - if cli_account is not None: - # CLI/env 账号优先于配置文件同用户名账号(避免重复),置于最前 - accounts = [ - a - for a in accounts - if a.get("username") != cli_account.get("username") - or a.get("tenant_name") != cli_tenant - ] - accounts.insert(0, cli_account) - logger.info( - f"使用命令行/环境变量指定账号:{cli_tenant}/" - f"{cli_account.get('username') or cli_account.get('user_id')}" + if created_template: + logger.warning( + f"已创建本地配置模板,请填写账号后重试:{runtime.paths.config_path}" ) + dependencies: RuntimeDependencies | None = None - # 过滤有效账号 - valid_accounts = [a for a in accounts if is_account_valid(a)] - - if not valid_accounts: - if NON_INTERACTIVE: + if not runtime.accounts: + if runtime.interaction.non_interactive: logger.error( - f"没有找到有效的账号配置,请检查 {config_path}" \ - + (" 或设置 WB_TENANT_NAME/WB_USERNAME/WB_PASSWORD" if not cli_account else "") + f"没有有效账号;请填写 {runtime.paths.config_path}," + "或设置 WB_TENANT_NAME/WB_USERNAME/WB_PASSWORD" ) - sys.exit(1) - # 交互模式:逐项提示输入,登录验证成功后才写入配置文件(失败不生成) - logger.warning("未找到有效账号,请按提示输入账号信息") - while True: - account = prompt_account_interactive() - if account is None: - logger.error("输入被中断,退出") - sys.exit(1) - # 登录验证(密码错误/学校名错误/网络不可用都会失败) - log = logger.bind(account=account["username"]) - probe = WeBanClient( - account["tenant_name"], - account["username"], - account["password"], - log=log, - browser_path=( - _OPTS.browser_path - or os.environ.get("WB_BROWSER_PATH", "").strip() - or None - ), - cdp_host=( - _OPTS.cdp_host - or os.environ.get("WB_CDP_HOST", "").strip() - or None - ), - cdp_port=( - int(_OPTS.cdp_port) - if _OPTS.cdp_port - else ( - int(os.environ["WB_CDP_PORT"]) - if os.environ.get("WB_CDP_PORT", "").strip() - else None - ) - ), - debug=bool( - _OPTS.debug - or os.environ.get("WB_DEBUG", "").strip() - in ("1", "true", "yes") - ), + return EXIT_CONFIG_ERROR + logger.warning("未找到有效账号,请按提示输入") + while not runtime.accounts: + raw_account = prompt_account_interactive(runtime.interaction) + if raw_account is None: + logger.error("账号输入不完整或已中断") + return EXIT_CONFIG_ERROR + candidate_document = dict(document) + candidate_document["account"] = [raw_account] + candidate_runtime = build_runtime_config( + opts, + candidate_document, + runtime.paths, + source_env, + stdin_is_tty=True, ) - log.info(f"正在验证账号 {account['tenant_name']}/{account['username']} ...") - if probe.login(): - save_interactive_account(account) - # 文件已生成,重载一次让配置文件内其他设置生效 - global_settings, ai_config, accounts = load_all_config() - ai_config = merge_ai_config(_OPTS, ai_config) - break - log.error("登录失败:学校全称或用户名/密码不正确,请重新输入") - valid_accounts = [a for a in accounts if is_account_valid(a)] - - # 单账号时提示是否更换(无交互模式跳过,直接使用该账号) - if len(valid_accounts) == 1 and not NON_INTERACTIVE: - acct = valid_accounts[0] - acct_name = ( - acct.get("username") - or acct.get("user_id") - or acct.get("tenant_name", "") - ) + _register_runtime_secrets(redactor, candidate_runtime) + if dependencies is None: + dependencies = _apply_runtime_adapters( + candidate_runtime, stop_event + ) + if not _probe_login( + candidate_runtime.accounts[0], + candidate_runtime, + stop_event, + dependencies, + ): + logger.error("登录验证失败,请重新输入") + continue + save_interactive_account( + candidate_runtime, + candidate_runtime.accounts[0].credentials, + logger, + ) + document = load_toml(runtime.paths.config_path) + runtime = build_runtime_config( + opts, + document, + runtime.paths, + source_env, + stdin_is_tty=True, + ) + _register_runtime_secrets(redactor, runtime) + + if runtime.interaction.allow_input: choice = ( - input(f"当前账号:{acct_name},是否更换账号?(y/N,默认N): ") + input(f"当前已配置 {len(runtime.accounts)} 个账号,是否更换账号?(y/N):") .strip() .lower() ) if choice == "y": - open_editor(config_path) - global_settings, ai_config, accounts = load_all_config() - ai_config = merge_ai_config(_OPTS, ai_config) - valid_accounts = [a for a in accounts if is_account_valid(a)] - if not valid_accounts: - logger.error("没有有效的账号配置") - sys.exit(1) - - accounts = valid_accounts - logger.info(f"共加载到 {len(accounts)} 个账号") - - # 检测浏览器是否可用(优先级:CLI > 环境变量 > 配置文件 → 自动检测) - browser_path = ( - _OPTS.browser_path - or os.environ.get("WB_BROWSER_PATH", "").strip() - or global_settings.get("browser_path", "") - or None - ) - cdp_host = ( - _OPTS.cdp_host - or os.environ.get("WB_CDP_HOST", "").strip() - or global_settings.get("cdp_host", "") - or None - ) - cdp_port_raw = ( - _OPTS.cdp_port - if _OPTS.cdp_port is not None - else os.environ.get("WB_CDP_PORT", "").strip() - or global_settings.get("cdp_port", 0) - ) - cdp_port = int(cdp_port_raw) or None - - # 用户未配置时,自动探测可用的 CDP 端口 - if not browser_path and not cdp_host and not cdp_port: - import socket - for host, port in [ - ("127.0.0.1", 9222), ("127.0.0.1", 9223), - ("host.docker.internal", 9222), ("host.docker.internal", 9223), - ]: - try: - with socket.create_connection((host, port), timeout=1): - # 端口可达,进一步验证是否为 CDP 服务 - resp = requests.get(f"http://{host}:{port}/json/version", timeout=3) - if resp.ok and "Browser" in resp.json(): - cdp_host, cdp_port = host, port - logger.info(f"自动探测到 CDP 浏览器 {host}:{port}") - break - except (OSError, requests.RequestException, ValueError): - continue + open_editor(runtime.paths.config_path, runtime.interaction) + document = load_toml(runtime.paths.config_path) + runtime = build_runtime_config( + opts, + document, + runtime.paths, + source_env, + stdin_is_tty=True, + ) + if not runtime.accounts: + logger.error("编辑后的配置中没有有效账号") + return EXIT_CONFIG_ERROR + _register_runtime_secrets(redactor, runtime) - try: - resolved = check_browser_health(browser_path, cdp_host, cdp_port) - logger.info(f"浏览器检测通过: {resolved}") - except RuntimeError as e: - logger.error(f"浏览器检测失败: {e}") - sys.exit(1) - - # 将探测结果写回 global_settings,供 run_account 读取 - if cdp_host: - global_settings["cdp_host"] = cdp_host - if cdp_port: - global_settings["cdp_port"] = cdp_port - - # 是否多线程 - max_workers_raw = _OPTS.max_workers - if max_workers_raw is None: - env_mw = os.environ.get("WB_MAX_WORKERS") - if env_mw is not None: - max_workers_raw = int(env_mw) - if max_workers_raw is None: - max_workers_raw = int(global_settings.get("max_workers", 5)) - max_workers = min(len(accounts), max_workers_raw) - - if len(accounts) > 1 and not NON_INTERACTIVE: + logger.info(f"共加载到 {len(runtime.accounts)} 个账号") + if dependencies is None: + dependencies = _apply_runtime_adapters(runtime, stop_event) + + # 所有配置、账号和路径均已校验后才允许外网更新检查。 + _check_update_async(logger) + logger.info("程序更新地址:https://github.com/hangone/WeBan") + + if len(runtime.accounts) > 1 and runtime.interaction.allow_input: choice = ( - input(f"检测到 {len(accounts)} 个账号,是否同时运行?(Y/n,默认Y): ") + input(f"检测到 {len(runtime.accounts)} 个账号,是否同时运行?(Y/n):") .strip() .lower() ) use_multithread = choice != "n" else: - # 无交互模式:多账号默认并发执行 - use_multithread = len(accounts) > 1 - - if use_multithread and len(accounts) > 1: - logger.info(f"使用多线程模式,最大并发数: {max_workers}") - success_count = 0 - failed_count = 0 - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_to_account = { - executor.submit(run_account, cfg, global_settings, ai_config, i): ( - cfg, - i, - ) - for i, cfg in enumerate(accounts) - } - - for future in as_completed(future_to_account): - cfg, idx = future_to_account[future] - try: - if future.result(): - success_count += 1 - else: - failed_count += 1 - except Exception as e: # noqa: BLE001 -- 线程结果可能抛任意异常 - logger.error(f"[账号 {idx + 1}] 线程执行异常: {e}") - failed_count += 1 - - logger.info( - f"所有账号执行完成!成功: {success_count},失败: {failed_count}" - ) - else: - logger.info("使用单线程模式,逐个执行") - success_count = 0 - failed_count = 0 - - for i, cfg in enumerate(accounts): - if run_account(cfg, global_settings, ai_config, i): - success_count += 1 - else: - failed_count += 1 - - logger.info( - f"所有账号执行完成!成功: {success_count},失败: {failed_count}" - ) - + use_multithread = len(runtime.accounts) > 1 + + summary = _execute_accounts( + runtime, + stop_event, + dependencies, + logger, + run_start_ts, + use_multithread=use_multithread, + ) + logger.info( + "所有账号执行完成:" + f"成功 {summary.success_count},未完整 {summary.incomplete_count}," + f"失败 {summary.failed_count}," + f"中断 {summary.cancelled_count}" + ) + exit_code = summary.exit_code + return exit_code except KeyboardInterrupt: - print("用户终止") - except Exception as e: # noqa: BLE001 -- 入口兜底 - logger.error(f"运行失败: {e}") - traceback.print_exc(file=sys.stderr) + stop_event.set() + if logger is not None: + logger.warning("用户终止,正在停止账号任务") + return EXIT_INTERRUPTED + except ConfigError as exc: + logger.error(f"配置错误:{exc}") + return EXIT_CONFIG_ERROR + except (OSError, RuntimeError) as exc: + logger.error(f"启动失败({type(exc).__name__}):{exc}") + return EXIT_FAILURE + except Exception as exc: # noqa: BLE001 - 进程入口必须返回非零退出码 + logger.error(f"未预期启动错误({type(exc).__name__}):{exc}") + return EXIT_FAILURE + finally: + for name, value in runtime_env_before.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + # Loguru 文件 sink 在 Windows 上持有独占句柄;显式移除可保证 + # 嵌入调用、测试及临时数据目录在 main() 返回后立即可清理。 + base_logger.remove() + # 交互启动失败也应保留窗口,让用户看到错误;无交互运行和 + # 已请求中断的任务不等待输入。清理先完成,避免等待时占用资源。 + if runtime.interaction.allow_input and not stop_event.is_set(): + try: + input("按回车键退出") + except (EOFError, KeyboardInterrupt): + pass - if not NON_INTERACTIVE: - try: - input("按回车键退出") - except EOFError: - pass + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml index dd86f3f..8248173 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,9 +24,14 @@ dependencies = [ dev = [ "pyinstaller>=6.20.0", "pyright>=1.1.409", + "pytest>=9.1.1", "ruff>=0.15.15", ] +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] + [tool.pyright] pythonVersion = "3.12" venvPath = "." diff --git a/runtime_config.py b/runtime_config.py new file mode 100644 index 0000000..88ce464 --- /dev/null +++ b/runtime_config.py @@ -0,0 +1,1001 @@ +"""运行时配置解析、路径归一化与启动前校验。 + +本模块只依赖标准库,确保应用可以在导入网络、浏览器和业务模块之前完成 +全部配置校验。配置优先级统一为 CLI > 环境变量 > 账号 TOML > 全局 TOML +> 默认值。 +""" + +from __future__ import annotations + +import argparse +import hashlib +import math +import os +import re +import stat +import sys +import tempfile +import tomllib +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +STUDY_MODES = frozenset({"false", "true", "force"}) +EXAM_MODES = frozenset({"false", "true", "perfect", "force"}) + +DEFAULT_SETTINGS: dict[str, Any] = { + "study_mode": "true", + "exam_mode": "true", + "random_answer": True, + "study_time": "20,10", + "video_speed": 1.0, + "exam_question_time": "3,3", + "exam_submit_match_rate": 90, + "browser_path": "", + "cdp_host": "", + "cdp_port": 0, + "max_workers": 5, + "debug": False, + "jupiter_fallback": False, +} + +DEFAULT_AI: dict[str, Any] = { + "enable": False, + "base_url": "", + "api_key": "", + "model": "", + "timeout": 60, + "max_retries": 2, +} + +_ACCOUNT_FIELDS = ( + "tenant_name", + "username", + "password", + "user_id", + "token", +) +_SETTING_CLI_FIELDS = { + "study_mode": "study_mode", + "exam_mode": "exam_mode", + "random_answer": "random_answer", + "study_time": "study_time", + "video_speed": "video_speed", + "exam_question_time": "exam_question_time", + "exam_submit_match_rate": "exam_submit_match_rate", + "browser_path": "browser_path", + "cdp_host": "cdp_host", + "cdp_port": "cdp_port", + "debug": "debug", + "jupiter_fallback": "jupiter_fallback", +} + + +class ConfigError(ValueError): + """配置不完整、格式错误或超出安全范围。""" + + +@dataclass(frozen=True) +class ResolvedPaths: + """所有可写数据都锚定到同一个数据目录。""" + + program_dir: Path + bundle_dir: Path + data_dir: Path + config_path: Path + logs_dir: Path + answer_dir: Path + captcha_debug_dir: Path + config_example_candidates: tuple[Path, ...] + + +@dataclass(frozen=True) +class InteractionPolicy: + """显式交互策略,供入口及业务模块适配层共用。""" + + non_interactive: bool + + @property + def allow_input(self) -> bool: + return not self.non_interactive + + @property + def allow_visible_browser(self) -> bool: + return not self.non_interactive + + +@dataclass(frozen=True) +class AccountIdentity: + """不含原始账号信息的日志身份和安全路径组件。""" + + log_key: str + tenant_dir: str + account_dir: str + + +@dataclass(frozen=True) +class AccountCredentials: + tenant_name: str + username: str + password: str + user_id: str + token: str + + @property + def uses_token(self) -> bool: + return bool(self.user_id and self.token) + + @property + def principal(self) -> str: + return self.user_id if self.uses_token else self.username + + +@dataclass(frozen=True) +class AccountSettings: + study_mode: str + exam_mode: str + random_answer: bool + study_time: str + video_speed: float + exam_question_time: str + exam_submit_match_rate: int + browser_path: str | None + cdp_host: str | None + cdp_port: int | None + debug: bool + jupiter_fallback: bool + + +@dataclass(frozen=True) +class ResolvedAccount: + credentials: AccountCredentials + settings: AccountSettings + identity: AccountIdentity + + +@dataclass(frozen=True) +class AISettings: + enable: bool + base_url: str + api_key: str + model: str + timeout: int + max_retries: int + + def as_dict(self) -> dict[str, Any]: + return { + "enable": self.enable, + "base_url": self.base_url, + "api_key": self.api_key, + "model": self.model, + "timeout": self.timeout, + "max_retries": self.max_retries, + } + + +@dataclass(frozen=True) +class RuntimeConfig: + paths: ResolvedPaths + interaction: InteractionPolicy + accounts: tuple[ResolvedAccount, ...] + ai: AISettings + max_workers: int + + +def build_parser() -> argparse.ArgumentParser: + """创建严格 CLI 解析器;未知参数由 argparse 以退出码 2 拒绝。""" + + parser = argparse.ArgumentParser( + prog="WeBan", + description="WeBan 学习自动化(多账号,可按项目交替学习+考试)", + allow_abbrev=False, + ) + parser.add_argument("--config", metavar="PATH", help="配置文件路径") + parser.add_argument("--data-dir", metavar="PATH", help="统一数据目录") + parser.add_argument( + "--non-interactive", + action=argparse.BooleanOptionalAction, + default=None, + help="禁用所有终端输入和可见浏览器回退", + ) + parser.add_argument("--study-mode", choices=sorted(STUDY_MODES)) + parser.add_argument("--exam-mode", choices=sorted(EXAM_MODES)) + parser.add_argument("--random-answer", choices=["true", "false"]) + parser.add_argument("--cdp-host", metavar="HOST") + parser.add_argument("--cdp-port", metavar="PORT") + parser.add_argument("--max-workers", metavar="N") + parser.add_argument("--tenant-name", dest="tenant_name", metavar="NAME") + parser.add_argument("--username", metavar="USER") + parser.add_argument("--password", metavar="PASS") + parser.add_argument("--study-time", metavar="SEC") + parser.add_argument("--video-speed", metavar="N") + parser.add_argument("--exam-question-time", metavar="SEC") + parser.add_argument("--exam-submit-match-rate", metavar="N") + parser.add_argument("--browser-path", metavar="PATH") + parser.add_argument("--jupiter-fallback", choices=["true", "false"]) + parser.add_argument("--user-id", metavar="ID") + parser.add_argument("--token", metavar="TOKEN") + parser.add_argument( + "--debug", + action=argparse.BooleanOptionalAction, + default=None, + help="启用调试日志", + ) + parser.add_argument("--ai-enable", choices=["true", "false"]) + parser.add_argument("--ai-base-url", metavar="URL") + parser.add_argument("--ai-api-key", metavar="KEY") + parser.add_argument("--ai-model", metavar="NAME") + parser.add_argument("--ai-timeout", metavar="SEC") + parser.add_argument("--ai-max-retries", metavar="N") + return parser + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + return build_parser().parse_args(argv) + + +def _absolute_path(value: object, *, relative_to: Path) -> Path: + text = os.path.expandvars(os.path.expanduser(str(value).strip())) + if not text: + raise ConfigError("路径不能为空") + path = Path(text) + if not path.is_absolute(): + path = relative_to / path + return path.resolve(strict=False) + + +def resolve_paths( + opts: argparse.Namespace, + env: Mapping[str, str] | None = None, + *, + script_path: str | os.PathLike[str] | None = None, + cwd: str | os.PathLike[str] | None = None, + frozen: bool | None = None, + executable_path: str | os.PathLike[str] | None = None, + bundle_path: str | os.PathLike[str] | None = None, +) -> ResolvedPaths: + """按 CLI > env > 默认值解析配置路径和统一数据根目录。""" + + source_env = os.environ if env is None else env + current_dir = Path.cwd() if cwd is None else Path(cwd) + current_dir = current_dir.resolve(strict=False) + is_frozen = bool(getattr(sys, "frozen", False)) if frozen is None else frozen + source_script = Path(script_path or __file__).resolve(strict=False) + source_executable = Path(executable_path or sys.executable).resolve(strict=False) + program_dir = source_executable.parent if is_frozen else source_script.parent + raw_bundle = bundle_path or getattr(sys, "_MEIPASS", None) + resolved_bundle = ( + Path(raw_bundle).resolve(strict=False) if raw_bundle else program_dir + ) + + cli_data_dir = getattr(opts, "data_dir", None) + env_data_dir = source_env.get("WB_DATA_DIR") + data_value = cli_data_dir if cli_data_dir is not None else env_data_dir + + cli_config = getattr(opts, "config", None) + env_config = source_env.get("WB_CONFIG") + config_value = cli_config if cli_config is not None else env_config + + explicit_data_dir = ( + _absolute_path(data_value, relative_to=current_dir) + if data_value not in (None, "") + else None + ) + explicit_config = ( + _absolute_path(config_value, relative_to=current_dir) + if config_value not in (None, "") + else None + ) + + if explicit_data_dir is not None: + data_dir = explicit_data_dir + elif explicit_config is not None: + data_dir = explicit_config.parent + else: + data_dir = program_dir + + config_path = explicit_config or data_dir / "config.toml" + candidates: list[Path] = [] + for candidate in ( + resolved_bundle / "config.example.toml", + program_dir / "config.example.toml", + ): + if candidate not in candidates: + candidates.append(candidate) + + logs_dir = data_dir / "logs" + return ResolvedPaths( + program_dir=program_dir, + bundle_dir=resolved_bundle, + data_dir=data_dir, + config_path=config_path, + logs_dir=logs_dir, + answer_dir=data_dir / "answer", + captcha_debug_dir=logs_dir / "captcha", + config_example_candidates=tuple(candidates), + ) + + +def load_toml(path: Path) -> dict[str, Any]: + """读取 TOML;不存在时返回空配置,损坏时给出可操作的配置错误。""" + + if not path.exists(): + return {} + try: + with path.open("rb") as file: + document = tomllib.load(file) + except OSError as exc: + raise ConfigError(f"无法读取配置文件:{path}") from exc + except UnicodeError as exc: + raise ConfigError(f"配置文件编码错误:{path}") from exc + except tomllib.TOMLDecodeError as exc: + raise ConfigError(f"配置文件 TOML 格式错误:{exc}") from exc + if not isinstance(document, dict): + raise ConfigError("配置文件顶层必须是 TOML 表") + return document + + +def _section(document: Mapping[str, Any], name: str) -> dict[str, Any]: + value = document.get(name, {}) + if value is None: + return {} + if not isinstance(value, dict): + raise ConfigError(f"[{name}] 必须是 TOML 表") + return dict(value) + + +def _strict_bool(value: object, label: str) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, int) and value in (0, 1): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ConfigError(f"{label} 必须是布尔值(true/false)") + + +def _strict_int( + value: object, + label: str, + *, + minimum: int | None = None, + maximum: int | None = None, +) -> int: + if isinstance(value, bool): + raise ConfigError(f"{label} 必须是整数") + if isinstance(value, int): + parsed = value + elif isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()): + parsed = int(value.strip()) + else: + raise ConfigError(f"{label} 必须是整数") + if minimum is not None and parsed < minimum: + raise ConfigError(f"{label} 不能小于 {minimum}") + if maximum is not None and parsed > maximum: + raise ConfigError(f"{label} 不能大于 {maximum}") + return parsed + + +def _strict_float( + value: object, + label: str, + *, + minimum: float | None = None, + maximum: float | None = None, +) -> float: + if isinstance(value, bool): + raise ConfigError(f"{label} 必须是数字") + if not isinstance(value, (str, int, float)): + raise ConfigError(f"{label} 必须是数字") + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise ConfigError(f"{label} 必须是数字") from exc + if not math.isfinite(parsed): + raise ConfigError(f"{label} 必须是有限数字") + if minimum is not None and parsed < minimum: + raise ConfigError(f"{label} 不能小于 {minimum:g}") + if maximum is not None and parsed > maximum: + raise ConfigError(f"{label} 不能大于 {maximum:g}") + return parsed + + +def _enum(value: object, label: str, choices: frozenset[str]) -> str: + if not isinstance(value, str): + raise ConfigError(f"{label} 必须是字符串") + parsed = value.strip().lower() + if parsed not in choices: + allowed = "/".join(sorted(choices)) + raise ConfigError(f"{label} 只能是 {allowed}") + return parsed + + +def _time_range( + value: object, + label: str, + *, + maximum_total: int, +) -> str: + if isinstance(value, bool): + raise ConfigError(f"{label} 必须是“基础秒数,随机上限”") + if isinstance(value, int): + parts: list[object] = [value] + elif isinstance(value, str): + parts = [part.strip() for part in value.split(",")] + else: + raise ConfigError(f"{label} 必须是“基础秒数,随机上限”") + if not 1 <= len(parts) <= 2 or any(part == "" for part in parts): + raise ConfigError(f"{label} 必须是“基础秒数,随机上限”") + base = _strict_int(parts[0], f"{label}基础秒数", minimum=0) + random_upper = ( + _strict_int(parts[1], f"{label}随机上限", minimum=0) if len(parts) == 2 else 0 + ) + if base + random_upper > maximum_total: + raise ConfigError(f"{label}最大等待时间不能超过 {maximum_total} 秒") + return f"{base},{random_upper}" + + +def _optional_text(value: object, label: str) -> str | None: + if value is None: + return None + if isinstance(value, (dict, list, tuple, set)): + raise ConfigError(f"{label} 必须是字符串") + text = str(value).strip() + return text or None + + +def _pick( + opts: argparse.Namespace, + env: Mapping[str, str], + key: str, + *, + account: Mapping[str, Any] | None, + settings: Mapping[str, Any], + default: object, +) -> object: + cli_field = _SETTING_CLI_FIELDS.get(key, key) + cli_value = getattr(opts, cli_field, None) + if cli_value is not None: + return cli_value + env_key = f"WB_{key.upper()}" + if env_key in env: + return env[env_key] + if account is not None and key in account and account[key] not in (None, ""): + return account[key] + if key in settings and settings[key] not in (None, ""): + return settings[key] + return default + + +def resolve_interaction_policy( + opts: argparse.Namespace, + settings: Mapping[str, Any], + env: Mapping[str, str] | None = None, + *, + stdin_is_tty: bool | None = None, +) -> InteractionPolicy: + source_env = os.environ if env is None else env + cli_value = getattr(opts, "non_interactive", None) + if cli_value is not None: + return InteractionPolicy(non_interactive=bool(cli_value)) + if "WB_NON_INTERACTIVE" in source_env: + return InteractionPolicy( + non_interactive=_strict_bool( + source_env["WB_NON_INTERACTIVE"], "WB_NON_INTERACTIVE" + ) + ) + if "non_interactive" in settings: + return InteractionPolicy( + non_interactive=_strict_bool( + settings["non_interactive"], "settings.non_interactive" + ) + ) + environment = source_env.get("ENVIRONMENT", "").strip().lower() + if environment in {"docker", "container"}: + return InteractionPolicy(non_interactive=True) + if stdin_is_tty is None: + try: + stdin_is_tty = bool(sys.stdin.isatty()) + except (AttributeError, ValueError): + stdin_is_tty = False + return InteractionPolicy(non_interactive=not stdin_is_tty) + + +def _resolve_account_settings( + opts: argparse.Namespace, + env: Mapping[str, str], + raw_account: Mapping[str, Any], + global_settings: Mapping[str, Any], + paths: ResolvedPaths, +) -> AccountSettings: + study_mode = _enum( + _pick( + opts, + env, + "study_mode", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["study_mode"], + ), + "study_mode", + STUDY_MODES, + ) + exam_mode = _enum( + _pick( + opts, + env, + "exam_mode", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["exam_mode"], + ), + "exam_mode", + EXAM_MODES, + ) + random_answer = _strict_bool( + _pick( + opts, + env, + "random_answer", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["random_answer"], + ), + "random_answer", + ) + study_time = _time_range( + _pick( + opts, + env, + "study_time", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["study_time"], + ), + "study_time", + maximum_total=86_400, + ) + video_speed = _strict_float( + _pick( + opts, + env, + "video_speed", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["video_speed"], + ), + "video_speed", + minimum=0, + maximum=16, + ) + exam_question_time = _time_range( + _pick( + opts, + env, + "exam_question_time", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["exam_question_time"], + ), + "exam_question_time", + maximum_total=3_600, + ) + match_rate = _strict_int( + _pick( + opts, + env, + "exam_submit_match_rate", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["exam_submit_match_rate"], + ), + "exam_submit_match_rate", + minimum=0, + maximum=100, + ) + cdp_host = _optional_text( + _pick( + opts, + env, + "cdp_host", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["cdp_host"], + ), + "cdp_host", + ) + cdp_port_value = _pick( + opts, + env, + "cdp_port", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["cdp_port"], + ) + cdp_port_number = _strict_int(cdp_port_value, "cdp_port", minimum=0, maximum=65_535) + cdp_port = cdp_port_number or None + if bool(cdp_host) != bool(cdp_port): + raise ConfigError("cdp_host 与 cdp_port 必须同时设置") + if cdp_host and "://" in cdp_host: + raise ConfigError("cdp_host 只填写主机名或 IP,不能包含 URL scheme") + + # CDP 是显式的远程浏览器选择,优先于 browser_path。只有未配置完整 + # CDP 时才解析并校验本地浏览器路径,避免无关的失效路径阻断 CDP 连接。 + browser_path: str | None = None + if cdp_host is None: + browser_value = _pick( + opts, + env, + "browser_path", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["browser_path"], + ) + browser_text = _optional_text(browser_value, "browser_path") + if browser_text: + resolved_browser = _absolute_path( + browser_text, relative_to=paths.config_path.parent + ) + if not resolved_browser.is_file(): + raise ConfigError(f"browser_path 指向的文件不存在:{resolved_browser}") + browser_path = str(resolved_browser) + + debug = _strict_bool( + _pick( + opts, + env, + "debug", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["debug"], + ), + "debug", + ) + jupiter_fallback = _strict_bool( + _pick( + opts, + env, + "jupiter_fallback", + account=raw_account, + settings=global_settings, + default=DEFAULT_SETTINGS["jupiter_fallback"], + ), + "jupiter_fallback", + ) + return AccountSettings( + study_mode=study_mode, + exam_mode=exam_mode, + random_answer=random_answer, + study_time=study_time, + video_speed=video_speed, + exam_question_time=exam_question_time, + exam_submit_match_rate=match_rate, + browser_path=browser_path, + cdp_host=cdp_host, + cdp_port=cdp_port, + debug=debug, + jupiter_fallback=jupiter_fallback, + ) + + +def _credential_text(value: object, label: str, *, strip: bool = True) -> str: + if value is None: + return "" + if isinstance(value, (dict, list, tuple, set, bool)): + raise ConfigError(f"{label} 必须是字符串或数字") + if isinstance(value, float) and not math.isfinite(value): + raise ConfigError(f"{label} 不是有效值") + text = str(value) + return text.strip() if strip else text + + +def _normalize_credentials( + raw: Mapping[str, Any], account_number: int +) -> AccountCredentials | None: + values = { + "tenant_name": _credential_text( + raw.get("tenant_name"), f"第 {account_number} 个账号 tenant_name" + ), + "username": _credential_text( + raw.get("username"), f"第 {account_number} 个账号 username" + ), + "password": _credential_text( + raw.get("password"), + f"第 {account_number} 个账号 password", + strip=False, + ), + "user_id": _credential_text( + raw.get("user_id"), f"第 {account_number} 个账号 user_id" + ), + "token": _credential_text( + raw.get("token"), f"第 {account_number} 个账号 token" + ), + } + if not any(values.values()): + return None + if not values["tenant_name"]: + raise ConfigError(f"第 {account_number} 个账号缺少 tenant_name") + has_password_login = bool(values["username"]) + has_any_token_field = bool(values["user_id"] or values["token"]) + has_token_login = bool(values["user_id"] and values["token"]) + if has_any_token_field and not has_token_login: + raise ConfigError(f"第 {account_number} 个账号的 user_id 与 token 必须同时设置") + if not has_password_login and not has_token_login: + raise ConfigError( + f"第 {account_number} 个账号需要 username,或 user_id + token" + ) + if has_password_login and not values["password"]: + values["password"] = values["username"] + return AccountCredentials(**values) + + +def make_account_identity( + credentials: AccountCredentials, account_index: int +) -> AccountIdentity: + tenant_hash = hashlib.sha256( + f"tenant\0{credentials.tenant_name}".encode() + ).hexdigest()[:12] + account_hash = hashlib.sha256( + ( + f"{credentials.tenant_name}\0" + f"{'token' if credentials.uses_token else 'password'}\0" + f"{credentials.principal}" + ).encode() + ).hexdigest()[:16] + return AccountIdentity( + log_key=f"账号{account_index + 1:02d}-{account_hash}", + tenant_dir=f"tenant-{tenant_hash}", + account_dir=f"account-{account_hash}", + ) + + +def _credential_overrides( + opts: argparse.Namespace, env: Mapping[str, str] +) -> tuple[dict[str, Any], set[str]]: + values: dict[str, Any] = {} + specified: set[str] = set() + for field in _ACCOUNT_FIELDS: + cli_value = getattr(opts, field, None) + env_key = f"WB_{field.upper()}" + if cli_value is not None: + values[field] = cli_value + specified.add(field) + elif env_key in env: + values[field] = env[env_key] + specified.add(field) + return values, specified + + +def _prepare_raw_accounts( + opts: argparse.Namespace, + env: Mapping[str, str], + document: Mapping[str, Any], +) -> list[dict[str, Any]]: + raw_accounts_value = document.get("account", []) + if raw_accounts_value is None: + raw_accounts_value = [] + if not isinstance(raw_accounts_value, list) or not all( + isinstance(item, dict) for item in raw_accounts_value + ): + raise ConfigError("[[account]] 必须是 TOML 数组表") + raw_accounts = [dict(item) for item in raw_accounts_value] + overrides, specified = _credential_overrides(opts, env) + if not specified: + return raw_accounts + + try: + direct_probe = _normalize_credentials(overrides, 1) + except ConfigError: + direct_probe = None + if direct_probe is not None: + matching_index: int | None = None + for index, raw in enumerate(raw_accounts): + candidate = _normalize_credentials(raw, index + 1) + if candidate is None: + continue + if ( + candidate.tenant_name == direct_probe.tenant_name + and candidate.principal == direct_probe.principal + ): + matching_index = index + break + if matching_index is None: + raw_accounts.insert(0, dict(overrides)) + else: + matched = raw_accounts.pop(matching_index) + matched.update(overrides) + raw_accounts.insert(0, matched) + return raw_accounts + + # 单独用环境变量覆盖密码/Token 等字段时,可无歧义地合并到唯一账号。 + non_blank_accounts = [ + raw + for raw in raw_accounts + if any(raw.get(field) not in (None, "") for field in _ACCOUNT_FIELDS) + ] + if len(non_blank_accounts) != 1: + raise ConfigError("账号 CLI/环境变量不完整;多账号配置下必须提供完整登录身份") + target = non_blank_accounts[0] + target.update(overrides) + return raw_accounts + + +def _resolve_ai( + opts: argparse.Namespace, + env: Mapping[str, str], + raw_ai: Mapping[str, Any], +) -> AISettings: + def pick(key: str) -> object: + cli_value = getattr(opts, f"ai_{key}", None) + if cli_value is not None: + return cli_value + env_key = f"WB_AI_{key.upper()}" + if env_key in env: + return env[env_key] + if key in raw_ai: + return raw_ai[key] + return DEFAULT_AI[key] + + enable = _strict_bool(pick("enable"), "ai.enable") + base_url = _optional_text(pick("base_url"), "ai.base_url") or "" + api_key = _credential_text(pick("api_key"), "ai.api_key", strip=False) + model = _optional_text(pick("model"), "ai.model") or "" + timeout = _strict_int(pick("timeout"), "ai.timeout", minimum=1, maximum=600) + max_retries = _strict_int( + pick("max_retries"), "ai.max_retries", minimum=0, maximum=10 + ) + if base_url: + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ConfigError("ai.base_url 必须是有效的 http/https URL") + if enable and (not base_url or not model): + raise ConfigError("启用 AI 时必须设置 ai.base_url 和 ai.model") + return AISettings( + enable=enable, + base_url=base_url, + api_key=api_key, + model=model, + timeout=timeout, + max_retries=max_retries, + ) + + +def build_runtime_config( + opts: argparse.Namespace, + document: Mapping[str, Any], + paths: ResolvedPaths, + env: Mapping[str, str] | None = None, + *, + stdin_is_tty: bool | None = None, +) -> RuntimeConfig: + """合并并严格校验所有配置,不执行任何网络操作。""" + + source_env = os.environ if env is None else env + global_settings = _section(document, "settings") + raw_ai = _section(document, "ai") + interaction = resolve_interaction_policy( + opts, + global_settings, + source_env, + stdin_is_tty=stdin_is_tty, + ) + max_workers = _strict_int( + _pick( + opts, + source_env, + "max_workers", + account=None, + settings=global_settings, + default=DEFAULT_SETTINGS["max_workers"], + ), + "max_workers", + minimum=1, + maximum=64, + ) + ai = _resolve_ai(opts, source_env, raw_ai) + raw_accounts = _prepare_raw_accounts(opts, source_env, document) + + resolved_accounts: list[ResolvedAccount] = [] + seen: set[tuple[str, str, str]] = set() + for raw_index, raw_account in enumerate(raw_accounts, start=1): + credentials = _normalize_credentials(raw_account, raw_index) + if credentials is None: + continue + dedupe_key = ( + credentials.tenant_name, + "token" if credentials.uses_token else "password", + credentials.principal, + ) + if dedupe_key in seen: + raise ConfigError(f"第 {raw_index} 个账号与前面的账号重复") + seen.add(dedupe_key) + settings = _resolve_account_settings( + opts, source_env, raw_account, global_settings, paths + ) + account_index = len(resolved_accounts) + resolved_accounts.append( + ResolvedAccount( + credentials=credentials, + settings=settings, + identity=make_account_identity(credentials, account_index), + ) + ) + + return RuntimeConfig( + paths=paths, + interaction=interaction, + accounts=tuple(resolved_accounts), + ai=ai, + max_workers=max_workers, + ) + + +def atomic_write_text(path: Path, content: str, *, mode: int = 0o600) -> None: + """同目录临时文件 + fsync + replace,并尽量收紧凭据文件权限。""" + + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temp_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temp_path = Path(temp_name) + try: + try: + os.chmod(temp_path, mode) + except OSError: + pass + file = os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") + # fdopen 成功后描述符归文件对象所有;多线程下重复 close 可能误关 + # 其他线程刚复用的同号描述符(如日志文件)。 + descriptor = -1 + with file: + file.write(content) + file.flush() + os.fsync(file.fileno()) + os.replace(temp_path, path) + try: + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) + except OSError: + pass + if os.name != "nt": + try: + directory_fd = os.open(path.parent, os.O_RDONLY) + except OSError: + return + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except BaseException: + if descriptor >= 0: + try: + os.close(descriptor) + except OSError: + pass + temp_path.unlink(missing_ok=True) + raise + + +def create_local_config_template(paths: ResolvedPaths) -> bool: + """仅用本地模板创建配置,避免校验前发生远程下载。""" + + if paths.config_path.exists(): + return False + for candidate in paths.config_example_candidates: + try: + content = candidate.read_text(encoding="utf-8") + except OSError: + continue + atomic_write_text(paths.config_path, content) + return True + atomic_write_text( + paths.config_path, + "# WeBan 配置文件\n[settings]\n\n" + '[[account]]\ntenant_name = ""\nusername = ""\npassword = ""\n', + ) + return True diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..60882a4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +import pytest +import requests + + +@pytest.fixture(autouse=True) +def block_real_http(monkeypatch: pytest.MonkeyPatch) -> None: + """任何未被本地替身接管的 HTTP 请求都应立即失败。""" + + def fail_request(*args: object, **kwargs: object) -> None: + del args, kwargs + raise AssertionError("测试禁止访问真实网络") + + monkeypatch.setattr(requests.sessions.Session, "request", fail_request) diff --git a/tests/test_answer_store.py b/tests/test_answer_store.py new file mode 100644 index 0000000..9a74578 --- /dev/null +++ b/tests/test_answer_store.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import json +import multiprocessing +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +import answer_store as answer_store_module +from answer_store import AnswerStore + + +def _increment_store(path: str, rounds: int) -> None: + store = AnswerStore(path) + for _ in range(rounds): + store.update(lambda data: data.update(count=int(data.get("count", 0)) + 1)) + + +def test_atomic_write_keeps_previous_version_and_recovers_damage( + tmp_path: Path, +) -> None: + path = tmp_path / "answer.json" + store = AnswerStore(path) + first = {"题目": {"version": 1}} + second = {"题目": {"version": 2}} + + store.write(first) + store.write(second) + + assert json.loads(path.read_text(encoding="utf-8")) == second + assert json.loads(store.backup_path.read_text(encoding="utf-8")) == first + + path.write_text('{"broken":', encoding="utf-8") + assert store.load() == first + assert json.loads(path.read_text(encoding="utf-8")) == first + + +def test_interrupted_replace_never_truncates_existing_store( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "answer.json" + store = AnswerStore(path) + original = {"题目": {"version": 1}} + store.write(original) + real_replace = answer_store_module.os.replace + + def fail_main_replace(source: Path, destination: Path) -> None: + if Path(destination) == path: + raise OSError("simulated interruption") + real_replace(source, destination) + + monkeypatch.setattr(answer_store_module.os, "replace", fail_main_replace) + + with pytest.raises(OSError, match="simulated interruption"): + store.write({"题目": {"version": 2}}) + + assert json.loads(path.read_text(encoding="utf-8")) == original + assert not list(tmp_path.glob(".answer.json.*.tmp")) + + +def test_threaded_updates_are_serialized(tmp_path: Path) -> None: + path = tmp_path / "answer.json" + AnswerStore(path).write({"count": 0}) + + with ThreadPoolExecutor(max_workers=6) as pool: + futures = [pool.submit(_increment_store, str(path), 8) for _ in range(6)] + for future in futures: + future.result() + + assert AnswerStore(path).load()["count"] == 48 + + +def test_process_updates_are_serialized(tmp_path: Path) -> None: + path = tmp_path / "answer.json" + AnswerStore(path).write({"count": 0}) + context = multiprocessing.get_context("spawn") + processes = [ + context.Process(target=_increment_store, args=(str(path), 5)) for _ in range(3) + ] + + for process in processes: + process.start() + for process in processes: + process.join(timeout=20) + assert process.exitcode == 0 + + assert AnswerStore(path).load()["count"] == 15 diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py new file mode 100644 index 0000000..9f812c7 --- /dev/null +++ b/tests/test_api_contract.py @@ -0,0 +1,396 @@ +import copy +import json +from dataclasses import dataclass +from typing import Any + +import pytest +import requests + +import api as api_module +from api import LoggingSession, WeBanAPI, handle_response + +FIXED_TIMESTAMP = "1700000000.123" +FIXED_MILLISECOND_TIMESTAMP = "1700000000123" +LOGIN_CIPHERTEXT = ( + "tJEFOOI9vOrjpS88K_udmaFdzxwkuXGQHTSIfHIp19gDokig7gBWfr0FD0i0Z5FO" + "3PJKNw0flUtJTito58oD7GMFVCmYpTW0Z2G5xgk6bMV-5BBunCJVF37JXXS3-AfT" + "NKBGASU46RDjK8dIdzi2PA==" +) +JUPITER_CIPHERTEXT = ( + "U0NXcDRYcGZwQ3F0a2MwOW9IUzg3QmlUQlNYSDNtWDV0MVNQaHhNN09VSHpzVURv" + "UWpJMmpjRFlRT1lHM0hUMWo4QkRZUUIxK3FzRHlRZlJqYXpoaTJiYmxWeU16ZFhZ" + "WTVpd0RMZGlpbGZBV1hUdktBWTR3S0MxODcxdi9ES0x2RHlpUC9iaUoxOTg5MVZJ" + "dFk5emVMeWRxK0JNb3RMTFZUQzlBbHRjckRqYkNhbUIyWHhDU2NaVFM5WkNVTnJl" + "UENyQWpieW80blRqMUVHRGEwWVNkNUNYN2RNUWdCQy9vaHlwUzJaVG1iQkxjaDc1" + "TkZEM2crekNGa0pVdmo3RUMzUjhJU1R2ZUJLVjNZR0lFQmtma2c9PQ==" +) + + +@dataclass(frozen=True) +class RecordedCall: + method: str + url: str + kwargs: dict[str, Any] + session_headers: dict[str, Any] + + +class RecordingSession: + def __init__( + self, + *responses: requests.Response, + headers: dict[str, Any] | None = None, + ) -> None: + self.headers = dict(headers or {}) + self.responses = list(responses) + self.calls: list[RecordedCall] = [] + + def request(self, method: str, url: str, **kwargs: Any) -> requests.Response: + self.calls.append( + RecordedCall( + method=method, + url=url, + kwargs=copy.deepcopy(kwargs), + session_headers=copy.deepcopy(self.headers), + ) + ) + if not self.responses: + raise AssertionError(f"没有为 {method} {url} 配置本地响应") + return self.responses.pop(0) + + def get(self, url: str, **kwargs: Any) -> requests.Response: + return self.request("GET", url, **kwargs) + + def post(self, url: str, **kwargs: Any) -> requests.Response: + return self.request("POST", url, **kwargs) + + +def make_json_response( + payload: dict[str, Any], + status_code: int = 200, +) -> requests.Response: + response = requests.Response() + response.status_code = status_code + response.encoding = "utf-8" + response.headers["Content-Type"] = "application/json" + response._content = json.dumps(payload, ensure_ascii=False).encode() + return response + + +def make_text_response(text: str, status_code: int = 200) -> requests.Response: + response = requests.Response() + response.status_code = status_code + response.encoding = "utf-8" + response.headers["Content-Type"] = "text/html" + response._content = text.encode() + return response + + +def make_api( + response: requests.Response, + *, + tenant_code: str = "tenant-01", + user: dict[str, str] | None = None, +) -> tuple[WeBanAPI, RecordingSession]: + actual_user = user or {"userId": "user-01", "token": "token-01"} + api = WeBanAPI(tenant_code=tenant_code, user=actual_user) + session = RecordingSession(response, headers=dict(api.session.headers)) + api.session = session # type: ignore[assignment] + return api, session + + +def assert_default_headers(call: RecordedCall, token: str = "token-01") -> None: + expected = { + **LoggingSession.DEFAULT_HEADERS, + "X-Token": token, + } + assert {key: call.session_headers[key] for key in expected} == expected + + +class CapturingLog: + def __init__(self) -> None: + self.errors: list[str] = [] + + def error(self, message: str) -> None: + self.errors.append(message) + + +def test_handle_response_returns_json_object() -> None: + response = make_json_response({"code": "0", "data": {"value": 1}}) + + assert handle_response(response) == {"code": "0", "data": {"value": 1}} + + +@pytest.mark.parametrize("body", ["", "bad gateway", '{"code":']) +def test_handle_response_rejects_non_json_200(body: str) -> None: + log = CapturingLog() + + assert handle_response(make_text_response(body), log=log) == {} + assert any("响应内容不是有效的 JSON" in message for message in log.errors) + + +@pytest.mark.parametrize( + ("status_code", "message"), + [ + (401, "Token 无效,请检查账号信息"), + (403, "Token 无效,不允许同时登录,请重试"), + ], +) +def test_handle_response_rejects_auth_errors( + status_code: int, + message: str, +) -> None: + with pytest.raises(PermissionError, match=message): + handle_response(make_text_response("denied", status_code)) + + +def test_handle_response_turns_http_500_into_empty_result() -> None: + log = CapturingLog() + + assert handle_response(make_text_response("server error", 500), log=log) == {} + assert any("请求失败:500 server error" in message for message in log.errors) + + +def test_login_request_and_encrypted_form_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + result = { + "code": "0", + "data": {"token": "token-new", "userId": "user-new"}, + } + api = WeBanAPI( + tenant_code="tenant-01", + account="student-01", + password="secret", + ) + session = RecordingSession( + make_json_response(result), + headers=dict(api.session.headers), + ) + api.session = session # type: ignore[assignment] + monkeypatch.setattr(api, "get_timestamp", lambda *args: FIXED_TIMESTAMP) + + assert api.login("ABCD", 1700000000) == result + + call = session.calls[0] + assert call.method == "POST" + assert call.url == "https://weiban.mycourse.cn/pharos/login/login.do" + assert call.kwargs == { + "params": {"timestamp": FIXED_TIMESTAMP}, + "data": {"data": LOGIN_CIPHERTEXT}, + "timeout": (9.05, 15), + } + assert_default_headers(call, token="") + assert api.user == result["data"] + assert api.session.headers["X-Token"] == "token-new" + assert api.password is None + + +def test_common_post_query_form_and_headers_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api, session = make_api(make_json_response({"code": "0", "data": []})) + monkeypatch.setattr(api, "get_timestamp", lambda *args: FIXED_TIMESTAMP) + + assert api.list_course("project-01", "category-01", choose_type=3) == { + "code": "0", + "data": [], + } + + call = session.calls[0] + assert call.method == "POST" + assert call.url == ("https://weiban.mycourse.cn/pharos/usercourse/listCourse.do") + assert call.kwargs == { + "params": {"timestamp": FIXED_TIMESTAMP}, + "data": { + "userProjectId": "project-01", + "chooseType": 3, + "categoryCode": "category-01", + "tenantCode": "tenant-01", + "userId": "user-01", + }, + "timeout": (9.05, 15), + } + assert_default_headers(call) + + +def test_mercury_form_and_signature_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api, session = make_api(make_json_response({"code": "0", "data": []})) + monkeypatch.setattr(api, "get_timestamp", lambda *args: FIXED_TIMESTAMP) + + api.list_question("course-01") + + call = session.calls[0] + assert call.method == "POST" + assert call.url == "https://resource.mycourse.cn/mercuryprovider/router" + assert call.kwargs == { + "data": { + "appKey": "00000001", + "format": "json", + "v": "1.0", + "timestamp": FIXED_TIMESTAMP, + "clientId": "pharos", + "service": "mercury.microlecture.listQuestion", + "id": "course-01", + "sign": "0A0BD3C55C25782E26538F851DAE3246038AF447", + }, + "timeout": (9.05, 15), + } + assert_default_headers(call) + + +def test_jupiter_json_and_encryption_contract() -> None: + api, session = make_api(make_json_response({"code": 200, "success": True})) + + result = api.apinext( + "user-course-01", + "course-01", + "project-01", + step=7, + finish=1, + nonstr="nonce-01", + unique_no="unique-01", + ) + + assert result == {"code": 200, "success": True} + call = session.calls[0] + assert call.method == "POST" + assert call.url == ( + "https://weiban.mycourse.cn/jupiterapi/api/statusercourse/v1/next" + ) + assert call.kwargs == { + "json": {"data": JUPITER_CIPHERTEXT}, + "timeout": (9.05, 15), + } + assert_default_headers(call) + + +def test_exam_captcha_check_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api, session = make_api(make_json_response({"code": "0"})) + monkeypatch.setattr(api, "get_timestamp", lambda *args: FIXED_TIMESTAMP) + + api.exam_check("exam-plan-01", "rand-01", "ticket-01") + + call = session.calls[0] + assert call.method == "POST" + assert call.url == "https://weiban.mycourse.cn/pharos/exam/check.do" + assert call.kwargs == { + "params": {"timestamp": FIXED_TIMESTAMP}, + "data": { + "userExamPlanId": "exam-plan-01", + "randstr": "rand-01", + "ticket": "ticket-01", + "tenantCode": "tenant-01", + "userId": "user-01", + }, + "timeout": (9.05, 15), + } + assert_default_headers(call) + + +def test_course_captcha_check_contract() -> None: + api, session = make_api(make_json_response({"code": "0", "data": "token"})) + + api.course_check( + "user-course-01", + "project-01", + "course-01", + "rand-01", + "ticket-01", + ) + + call = session.calls[0] + assert call.method == "POST" + assert call.url == "https://weiban.mycourse.cn/pharos/usercourse/check.do" + assert call.kwargs == { + "data": { + "userId": "user-01", + "userCourseId": "user-course-01", + "userProjectId": "project-01", + "courseId": "course-01", + "tenantCode": "tenant-01", + "randstr": "rand-01", + "ticket": "ticket-01", + }, + "headers": { + "Accept": "*/*", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Origin": "https://mcwk.mycourse.cn", + "Referer": "https://mcwk.mycourse.cn/", + "X-Token": None, + }, + "timeout": (9.05, 15), + } + assert_default_headers(call) + + +def test_jsonp_finish_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + callback = f"jQuery3410{10**15}_{FIXED_MILLISECOND_TIMESTAMP}" + payload = f'{callback}({{"code":"0","detailCode":"0"}})' + api, session = make_api(make_text_response(payload)) + monkeypatch.setattr( + api, + "get_timestamp", + lambda *args: FIXED_MILLISECOND_TIMESTAMP, + ) + monkeypatch.setattr(api_module, "randint", lambda lower, upper: lower) + + assert api.finish_by_token( + "user-course-01", + token="completion-token", + unique_no="unique-01", + referer="https://mcwk.mycourse.cn/course/demo/demo.html", + ) == {"code": "0", "detailCode": "0"} + + call = session.calls[0] + assert call.method == "GET" + assert call.url == ( + "https://weiban.mycourse.cn/pharos/usercourse/v2/completion-token.do" + ) + assert call.kwargs == { + "params": { + "userCourseId": "user-course-01", + "tenantCode": "tenant-01", + "uniqueNo": "unique-01", + "callback": callback, + "_": 1700000000124, + }, + "headers": { + "Accept": "*/*", + "Referer": "https://mcwk.mycourse.cn/course/demo/demo.html", + "X-Token": None, + }, + "timeout": (9.05, 15), + } + assert_default_headers(call) + + +def test_exam_submit_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api, session = make_api(make_json_response({"code": "0", "data": {"score": 100}})) + monkeypatch.setattr(api, "get_timestamp", lambda *args: FIXED_TIMESTAMP) + + assert api.exam_submit_paper("exam-plan-01") == { + "code": "0", + "data": {"score": 100}, + } + + call = session.calls[0] + assert call.method == "POST" + assert call.url == ("https://weiban.mycourse.cn/pharos/exam/submitPaper.do") + assert call.kwargs == { + "params": {"timestamp": FIXED_TIMESTAMP}, + "data": { + "userExamPlanId": "exam-plan-01", + "tenantCode": "tenant-01", + "userId": "user-01", + }, + "timeout": (9.05, 15), + } + assert_default_headers(call) diff --git a/tests/test_api_safety.py b/tests/test_api_safety.py new file mode 100644 index 0000000..e03a4ee --- /dev/null +++ b/tests/test_api_safety.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import json +from collections.abc import Callable + +import pytest +import requests + +import api as api_module +from api import LoggingSession, WeBanAPI, handle_response +from errors import AccountBlockedError, APIResponseError + + +def _response( + payload: dict | str, + status: int = 200, + content_type: str = "application/json", +) -> requests.Response: + response = requests.Response() + response.status_code = status + response.encoding = "utf-8" + response.headers["Content-Type"] = content_type + if isinstance(payload, dict): + response._content = json.dumps(payload, ensure_ascii=False).encode() + else: + response._content = payload.encode() + return response + + +class _ListLog: + def __init__(self) -> None: + self.messages: list[str] = [] + + def debug(self, message: str) -> None: + self.messages.append(str(message)) + + def error(self, message: str) -> None: + self.messages.append(str(message)) + + +def _transport( + responses: list[requests.Response], + calls: list[tuple[str, str]], +) -> Callable[..., requests.Response]: + def request(method: str, url: str, **kwargs: object) -> requests.Response: + del kwargs + calls.append((method, url)) + return responses.pop(0) + + return request + + +def test_side_effect_request_is_sent_once_but_query_can_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = WeBanAPI( + tenant_code="tenant", + user={"userId": "user", "token": "token"}, + ) + calls: list[tuple[str, str]] = [] + responses = [_response("server error", 500, "text/plain")] + monkeypatch.setattr( + api.session._session, + "request", + _transport(responses, calls), + ) + monkeypatch.setattr(api_module.time, "sleep", lambda _: None) + + with pytest.raises(APIResponseError): + api.exam_submit_paper("plan") + assert len(calls) == 1 + + calls.clear() + responses.extend( + [ + _response("busy", 500, "text/plain"), + _response({"code": "0", "data": []}), + ] + ) + assert api.exam_list_plan("project") == {"code": "0", "data": []} + assert len(calls) == 2 + + +def test_jsonp_finish_get_is_not_retried( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = WeBanAPI( + tenant_code="tenant", + user={"userId": "user", "token": "token"}, + ) + calls: list[tuple[str, str]] = [] + responses = [_response("busy", 503, "text/plain")] + monkeypatch.setattr( + api.session._session, + "request", + _transport(responses, calls), + ) + + with pytest.raises(APIResponseError): + api.finish_by_token("course") + assert len(calls) == 1 + + +def test_structured_error_redacts_endpoint_and_body() -> None: + response = _response( + { + "token": "token-secret", + "userId": "user-secret", + "message": "failed", + }, + status=500, + ) + + with pytest.raises(APIResponseError) as caught: + handle_response( + response, + endpoint="https://example.test/api?ticket=ticket-secret", + strict=True, + ) + + rendered = str(caught.value) + assert "token-secret" not in rendered + assert "user-secret" not in rendered + assert "ticket-secret" not in rendered + assert caught.value.status_code == 500 + + +def test_debug_logging_redacts_request_and_login_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: + log = _ListLog() + session = LoggingSession(log=log, debug=True) + response = _response( + { + "token": "response-token-secret", + "userId": "response-user-secret", + } + ) + monkeypatch.setattr( + session._session, + "request", + lambda *args, **kwargs: response, + ) + + session.post( + "https://weiban.mycourse.cn/test?ticket=url-ticket-secret", + data={ + "ticket": "body-ticket-secret", + "userId": "body-user-secret", + }, + ) + + rendered = "\n".join(log.messages) + for secret in ( + "response-token-secret", + "response-user-secret", + "url-ticket-secret", + "body-ticket-secret", + "body-user-secret", + ): + assert secret not in rendered + + +@pytest.mark.parametrize( + "payload", + [ + {"code": "-1", "detailCode": "10018", "msg": "行为存在异常"}, + {"code": "701", "msg": "Account locked"}, + ], +) +def test_lock_codes_raise_dedicated_exception(payload: dict) -> None: + with pytest.raises(AccountBlockedError): + handle_response( + _response(payload), + endpoint="https://weiban.mycourse.cn/test", + strict=True, + ) + + +def test_session_and_api_close_are_idempotent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = WeBanAPI( + password="secret", + user={"userId": "user", "token": "token"}, + ) + close_calls = 0 + + def close() -> None: + nonlocal close_calls + close_calls += 1 + + monkeypatch.setattr(api.session._session, "close", close) + api.close() + api.close() + + assert close_calls == 1 + assert api.password is None + assert "token" not in api.user + assert "X-Token" not in api.session.headers + with pytest.raises(RuntimeError, match="已关闭"): + api.session.get("https://example.test") diff --git a/tests/test_captcha.py b/tests/test_captcha.py new file mode 100644 index 0000000..695df1a --- /dev/null +++ b/tests/test_captcha.py @@ -0,0 +1,788 @@ +import asyncio +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest + +import captcha + + +class StubLog: + def __init__(self) -> None: + self.messages: list[str] = [] + + def _record(self, message: object) -> None: + self.messages.append(str(message)) + + info = _record + warning = _record + error = _record + success = _record + + +class FakeResponse: + def __init__(self, payload: object) -> None: + self.payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> object: + return self.payload + + +class FakeBrowser: + def __init__(self, profile: Path | None = None) -> None: + self.closed = False + self._process = None + self.config = SimpleNamespace( + uses_custom_data_dir=profile is None, + user_data_dir=profile, + ) + + async def aclose(self) -> None: + self.closed = True + + +def make_handler( + *, + host: str = "127.0.0.1", + port: int = 9222, + non_interactive: bool = True, +) -> captcha.CaptchaHandler: + return captcha.CaptchaHandler( + tenant_code="tenant", + user_id="user", + token="token", + log=StubLog(), + cdp_host=host, + cdp_port=port, + non_interactive=non_interactive, + ) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, 2), + ("", 2), + ("abc", 2), + ("0", 2), + ("-3", 2), + (" 4 ", 4), + ("999", 50), + ], +) +def test_env_retry_count_falls_back_safely( + monkeypatch: pytest.MonkeyPatch, raw: str | None, expected: int +) -> None: + if raw is None: + monkeypatch.delenv("WB_CAPTCHA_ROUNDS", raising=False) + else: + monkeypatch.setenv("WB_CAPTCHA_ROUNDS", raw) + + assert captcha._env_positive_int("WB_CAPTCHA_ROUNDS", 2) == expected + + +def test_check_browser_health_requests_real_cdp_version( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, float]] = [] + + def fake_get(url: str, *, timeout: float) -> FakeResponse: + calls.append((url, timeout)) + return FakeResponse( + { + "Browser": "Chrome/128.0", + "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/id", + } + ) + + monkeypatch.setattr(captcha.requests, "get", fake_get) + + result = captcha.check_browser_health(cdp_host="127.0.0.1", cdp_port=9222) + + assert result == "127.0.0.1:9222" + assert calls == [("http://127.0.0.1:9222/json/version", captcha.CDP_HEALTH_TIMEOUT)] + + +def test_check_browser_health_prefers_cdp_over_missing_browser_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + def fake_get(url: str, *, timeout: float) -> FakeResponse: + del timeout + calls.append(url) + return FakeResponse( + { + "Browser": "Chrome/128.0", + "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/id", + } + ) + + monkeypatch.setattr(captcha.requests, "get", fake_get) + + result = captcha.check_browser_health( + browser_path="missing-browser.exe", + cdp_host="127.0.0.1", + cdp_port=9222, + ) + + assert result == "127.0.0.1:9222" + assert calls == ["http://127.0.0.1:9222/json/version"] + + +def test_check_browser_health_rejects_non_cdp_http_service( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + captcha.requests, + "get", + lambda *args, **kwargs: FakeResponse({"status": "ok"}), + ) + + with pytest.raises(RuntimeError, match="缺少 Browser"): + captcha.check_browser_health(cdp_host="localhost", cdp_port=9222) + + +@pytest.mark.parametrize( + ("host", "port"), + [ + ("127.0.0.1", None), + (None, 9222), + ], +) +def test_check_browser_health_rejects_partial_cdp_config( + host: str | None, + port: int | None, +) -> None: + with pytest.raises(RuntimeError, match="必须同时提供"): + captcha.check_browser_health(cdp_host=host, cdp_port=port) + + +def test_check_browser_health_rejects_missing_explicit_browser( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + detected = False + + def fake_detect() -> None: + nonlocal detected + detected = True + + monkeypatch.setattr(captcha, "detect_browser", fake_detect) + + with pytest.raises(RuntimeError, match="显式指定.*不存在"): + captcha.check_browser_health(browser_path=str(tmp_path / "missing.exe")) + + assert detected is False + + +def test_handler_validates_its_final_account_browser_config() -> None: + with pytest.raises(RuntimeError, match="必须同时提供"): + captcha.CaptchaHandler( + tenant_code="tenant", + user_id="user", + token="token", + log=StubLog(), + cdp_host="127.0.0.1", + ) + + +def test_handler_defers_network_health_check_until_first_use( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str | None, str | None, int | None]] = [] + + def fake_check( + path: str | None, + host: str | None, + port: int | None, + ) -> str: + calls.append((path, host, port)) + return "127.0.0.1:9222" + + monkeypatch.setattr(captcha, "check_browser_health", fake_check) + handler = make_handler() + + assert calls == [] + asyncio.run(handler._ensure_browser_ready()) + asyncio.run(handler._ensure_browser_ready()) + + assert calls == [(None, "127.0.0.1", 9222)] + + +def test_handler_lazily_discovers_default_cdp_endpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + checked_local_browser = False + + def fail_local_check(*args: object, **kwargs: object) -> str: + nonlocal checked_local_browser + del args, kwargs + checked_local_browser = True + raise AssertionError("不应继续探测本地浏览器") + + monkeypatch.setattr( + captcha, + "_detect_default_cdp_endpoint", + lambda: ("127.0.0.1", 9223), + ) + monkeypatch.setattr(captcha, "check_browser_health", fail_local_check) + handler = captcha.CaptchaHandler( + tenant_code="tenant", + user_id="user", + token="token", + log=StubLog(), + non_interactive=True, + ) + + asyncio.run(handler._ensure_browser_ready()) + + assert (handler.cdp_host, handler.cdp_port) == ("127.0.0.1", 9223) + assert handler._browser_ready is True + assert checked_local_browser is False + + +def test_course_url_origin_excludes_path_query_and_fragment() -> None: + assert ( + captcha._origin_from_url("https://mcwk.mycourse.cn/course/view?id=1#/chapter") + == "https://mcwk.mycourse.cn" + ) + + +def test_shared_cdp_endpoint_serializes_captcha_flows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + active = 0 + max_active = 0 + + async def fake_flow( + self: captcha.CaptchaHandler, + user_exam_plan_id: str, + ) -> dict[str, str]: + nonlocal active, max_active + del self + active += 1 + max_active = max(max_active, active) + await asyncio.sleep(0.03) + active -= 1 + return {"randstr": user_exam_plan_id, "ticket": "ticket"} + + monkeypatch.setattr( + captcha.CaptchaHandler, + "_handle_exam_captcha_flow", + fake_flow, + ) + first = make_handler(port=19321) + second = make_handler(port=19321) + first._browser_ready = True + second._browser_ready = True + + async def run_both() -> list[dict[str, str]]: + return list( + await asyncio.gather( + first.handle_exam_captcha_async("first"), + second.handle_exam_captcha_async("second"), + ) + ) + + results = asyncio.run(run_both()) + + assert max_active == 1 + assert {result["randstr"] for result in results} == {"first", "second"} + + +def test_exam_flow_has_hard_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + async def never_finishes( + self: captcha.CaptchaHandler, + user_exam_plan_id: str, + ) -> dict[str, str]: + del self, user_exam_plan_id + await asyncio.Event().wait() + raise AssertionError("unreachable") + + monkeypatch.setattr(captcha, "EXAM_FLOW_TIMEOUT", 0.02) + monkeypatch.setattr( + captcha.CaptchaHandler, + "_handle_exam_captcha_flow", + never_finishes, + ) + handler = make_handler(port=19322) + handler._browser_ready = True + + with pytest.raises(RuntimeError, match="无感验证码处理超时"): + asyncio.run(handler.handle_exam_captcha_async("plan")) + + +def test_cleanup_only_closes_explicitly_owned_instance() -> None: + from nodriver.core import util as nd_util + + owned = FakeBrowser() + foreign = FakeBrowser() + registry = nd_util.get_registered_instances() + registry_any: Any = registry + registry_any.update({owned, foreign}) + try: + asyncio.run(captcha.kill_stray_browsers([owned])) + + assert owned.closed is True + assert owned not in registry + assert foreign.closed is False + assert foreign in registry + finally: + registry.discard(owned) + registry.discard(foreign) + + +def test_local_health_probe_preserves_preexisting_registered_browser( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from nodriver.core import util as nd_util + + executable = tmp_path / "chrome.exe" + executable.write_bytes(b"stub") + executable.chmod(0o755) + owned = FakeBrowser() + foreign = FakeBrowser() + registry = nd_util.get_registered_instances() + registry_any: Any = registry + registry_any.add(foreign) + + async def get_probe_page(url: str) -> None: + assert url.startswith("data:text/html") + + owned.get = get_probe_page # type: ignore[attr-defined] + + async def fake_start(**kwargs: object) -> FakeBrowser: + del kwargs + registry_any.add(owned) + return owned + + monkeypatch.setattr(captcha.nodriver, "start", fake_start) + try: + result = captcha.check_browser_health(browser_path=str(executable)) + + assert result == str(executable.resolve()) + assert owned.closed is True + assert owned not in registry + assert foreign.closed is False + assert foreign in registry + finally: + registry.discard(owned) + registry.discard(foreign) + + +def test_cleanup_removes_only_owned_temporary_profile(tmp_path: Path) -> None: + profile = tmp_path / "profile" + profile.mkdir() + (profile / "state").write_text("temporary", encoding="utf-8") + browser = FakeBrowser(profile) + + asyncio.run(captcha.kill_stray_browsers([browser])) + + assert browser.closed is True + assert not profile.exists() + + +@pytest.mark.parametrize( + ("entry_url", "initial_origin"), + [ + ("https://example.test/course/item?id=3#section", "https://example.test"), + ("https://example.test:443/course", "https://example.test:443"), + ("https://EXAMPLE.test/course", "https://EXAMPLE.test"), + ("http://example.test/course", "http://example.test"), + ], + ids=["unchanged", "default-port", "hostname-case", "https-redirect"], +) +@pytest.mark.parametrize("cancel_during_navigation", [False, True]) +def test_build_page_uses_origin_and_restores_local_storage( + monkeypatch: pytest.MonkeyPatch, + entry_url: str, + initial_origin: str, + cancel_during_navigation: bool, +) -> None: + class FakeTab: + def __init__(self) -> None: + self.navigated_to: list[str] = [] + self.scripts: list[str] = [] + self.closed = False + + async def evaluate( + self, + expression: str, + *, + return_by_value: bool = False, + ) -> object: + self.scripts.append(expression) + if "localStorage.length" in expression: + return { + "origin": "https://example.test", + "items": { + "user": "previous-user", + "theme": "dark", + }, + } + if return_by_value and "typeof TencentCaptcha" in expression: + return True + if return_by_value and "window.location.origin" in expression: + return "https://example.test" + return None + + async def get(self, url: str) -> None: + self.navigated_to.append(url) + if cancel_during_navigation: + raise asyncio.CancelledError + + async def close(self) -> None: + self.closed = True + + class PageBrowser(FakeBrowser): + def __init__(self, tab: FakeTab) -> None: + super().__init__() + self.tab = tab + self.opened: list[tuple[str, bool]] = [] + + async def get(self, url: str, *, new_tab: bool = False) -> FakeTab: + self.opened.append((url, new_tab)) + return self.tab + + handler = make_handler(port=19323) + tab = FakeTab() + browser = PageBrowser(tab) + + async def no_health_check() -> None: + return None + + async def create_browser(headless: bool = False) -> PageBrowser: + assert headless is True + return browser + + monkeypatch.setattr(handler, "_ensure_browser_ready", no_health_check) + monkeypatch.setattr(handler, "_create_browser", create_browser) + + async def scenario() -> None: + if cancel_during_navigation: + with pytest.raises(asyncio.CancelledError): + await handler._build_page(entry_url, headless=True) + assert handler._browser_states == {} + return + built_browser, built_tab = await handler._build_page( + entry_url, + headless=True, + ) + assert (built_browser, built_tab) == (browser, tab) + assert handler._browser_states[id(browser)]["origin"] == "https://example.test" + browser_any: Any = browser + await handler._quit_browser(browser_any, "test") + + asyncio.run(scenario()) + + assert browser.opened == [(f"{initial_origin}/", True)] + assert tab.navigated_to == [entry_url] + assert any("previous-user" in script for script in tab.scripts) + assert any("localStorage.clear()" in script for script in tab.scripts) + assert tab.closed is True + assert browser.closed is True + + +def test_cleanup_skips_local_storage_restore_when_origin_navigation_fails() -> None: + class FailingNavigationTab: + def __init__(self) -> None: + self.scripts: list[str] = [] + self.navigated_to: list[str] = [] + self.closed = False + + async def evaluate( + self, + expression: str, + *, + return_by_value: bool = False, + ) -> object: + self.scripts.append(expression) + if return_by_value and "window.location.origin" in expression: + return "https://other.test" + return None + + async def get(self, url: str) -> None: + self.navigated_to.append(url) + raise RuntimeError("navigation failed") + + async def close(self) -> None: + self.closed = True + + handler = make_handler(port=19328) + browser = FakeBrowser() + tab = FailingNavigationTab() + handler._browser_states[id(browser)] = { + "tab": tab, + "storage": {"user": "previous-user"}, + "close_tab": True, + "origin": "https://example.test", + } + + browser_any: Any = browser + asyncio.run(handler._quit_browser(browser_any, "navigation-failure")) + + assert tab.navigated_to == ["https://example.test/"] + assert not any("localStorage.clear()" in script for script in tab.scripts) + assert tab.closed is True + assert browser.closed is True + + +def test_cleanup_skips_local_storage_restore_after_origin_redirect() -> None: + class RedirectingTab: + def __init__(self) -> None: + self.current_origin = "https://other.test" + self.origin_checks = 0 + self.scripts: list[str] = [] + self.navigated_to: list[str] = [] + self.closed = False + + async def evaluate( + self, + expression: str, + *, + return_by_value: bool = False, + ) -> object: + self.scripts.append(expression) + if return_by_value and "window.location.origin" in expression: + self.origin_checks += 1 + return self.current_origin + return None + + async def get(self, url: str) -> None: + self.navigated_to.append(url) + self.current_origin = "https://redirected.test" + + async def close(self) -> None: + self.closed = True + + handler = make_handler(port=19329) + browser = FakeBrowser() + tab = RedirectingTab() + handler._browser_states[id(browser)] = { + "tab": tab, + "storage": {"user": "previous-user"}, + "close_tab": True, + "origin": "https://example.test", + } + + browser_any: Any = browser + asyncio.run(handler._quit_browser(browser_any, "origin-redirect")) + + assert tab.navigated_to == ["https://example.test/"] + assert tab.origin_checks == 2 + assert not any("localStorage.clear()" in script for script in tab.scripts) + assert tab.closed is True + assert browser.closed is True + + +def test_empty_local_storage_snapshot_is_valid() -> None: + class EmptyStorageTab: + async def evaluate( + self, + expression: str, + *, + return_by_value: bool = False, + ) -> object: + del expression, return_by_value + return {"origin": "https://example.test", "items": {}} + + handler = make_handler(port=19325) + + assert asyncio.run(handler._snapshot_local_storage(EmptyStorageTab())) == ( + "https://example.test", + {}, + ) + + +def test_local_storage_snapshot_requires_its_origin() -> None: + class MissingOriginTab: + async def evaluate( + self, + expression: str, + *, + return_by_value: bool = False, + ) -> object: + return {"items": {"user": "previous-user"}} + + handler = make_handler(port=19371) + with pytest.raises(RuntimeError, match="localStorage 快照"): + asyncio.run(handler._snapshot_local_storage(MissingOriginTab())) + + +def test_sdk_loading_has_hard_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class NeverReadyTab: + async def evaluate( + self, + expression: str, + *, + return_by_value: bool = False, + ) -> object: + del expression + return False if return_by_value else None + + monkeypatch.setattr(captcha, "SDK_LOAD_TIMEOUT", 0.02) + + with pytest.raises(RuntimeError, match="SDK 加载超时"): + asyncio.run(make_handler(port=19326)._ensure_captcha_sdk(NeverReadyTab())) + + +def test_cdp_script_call_has_hard_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class SlowTab: + async def evaluate(self, *args: object, **kwargs: object) -> None: + del args, kwargs + await asyncio.Event().wait() + + monkeypatch.setattr(captcha, "CDP_CALL_TIMEOUT", 0.02) + + with pytest.raises(RuntimeError, match="CDP 脚本执行超时"): + asyncio.run(make_handler(port=19327)._eval_json(SlowTab(), "1")) + + +def test_browser_close_has_hard_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from nodriver.core import util as nd_util + + class HangingBrowser(FakeBrowser): + async def aclose(self) -> None: + await asyncio.Event().wait() + + monkeypatch.setattr(captcha, "CLOSE_TIMEOUT", 0.03) + browser = HangingBrowser() + browser_any: Any = browser + registry = nd_util.get_registered_instances() + registry_any: Any = registry + registry_any.add(browser) + try: + asyncio.run( + asyncio.wait_for( + make_handler(port=19327)._quit_browser(browser_any, "timeout-test"), + timeout=0.2, + ) + ) + assert browser not in registry + finally: + registry.discard(browser) + + +def test_auto_solver_offloads_download_and_opencv( + monkeypatch: pytest.MonkeyPatch, +) -> None: + handler = make_handler(port=19324) + calls: list[str] = [] + image = np.zeros((10, 10, 3), dtype=np.uint8) + + async def fake_to_thread(func: Any, *args: object, **kwargs: object) -> Any: + calls.append(func.__name__) + return func(*args, **kwargs) + + async def ready_state(*args: object, **kwargs: object) -> dict[str, Any]: + del args, kwargs + return { + "bgUrl": "https://captcha.test/main.png", + "ansUrl": "https://captcha.test/prompt.png", + "bgRect": {"x": 0, "y": 0, "w": 10, "h": 10}, + } + + def fake_fetch_image(url: str) -> np.ndarray: + del url + return image.copy() + + def fake_detect_points( + prompt: np.ndarray, + main: np.ndarray, + ) -> tuple[list[None], list[dict]]: + del prompt, main + return [None, None, None], [] + + monkeypatch.setattr(captcha.asyncio, "to_thread", fake_to_thread) + monkeypatch.setattr(captcha, "fetch_image", fake_fetch_image) + monkeypatch.setattr(captcha, "detect_points", fake_detect_points) + monkeypatch.setattr(handler, "_wait_until", ready_state) + + result = asyncio.run(handler._auto_solve_once(object(), 1, False)) + + assert result is None + assert calls.count("fake_fetch_image") == 2 + assert "fake_detect_points" in calls + + +def test_atomic_debug_write_cleans_temporary_file_on_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(captcha.cv2, "imencode", lambda *args, **kwargs: (False, None)) + + with pytest.raises(OSError, match="无法写入"): + captcha._write_png_atomic( + tmp_path / "debug.png", + np.zeros((2, 2, 3), dtype=np.uint8), + ) + + assert list(tmp_path.iterdir()) == [] + + +def test_missing_login_model_has_clear_safe_fallback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + log = StubLog() + monkeypatch.setattr(captcha, "__file__", str(tmp_path / "captcha.py")) + monkeypatch.setattr(captcha.LoginCaptchaSolver, "_initialized", False) + monkeypatch.setattr(captcha.LoginCaptchaSolver, "_ocr", None) + monkeypatch.setattr(captcha.sys, "frozen", False, raising=False) + + assert captcha.LoginCaptchaSolver.get_ocr(log) is None + assert captcha.LoginCaptchaSolver.recognize(b"invalid", log) is None + message = "\n".join(log.messages) + assert "模型文件不存在" in message + assert "交互模式可人工输入" in message + assert "无交互模式将安全失败" in message + + +@pytest.mark.parametrize("frozen", [False, True]) +def test_real_login_model_loads_from_unicode_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, frozen: bool +) -> None: + model_bytes = Path(captcha.__file__).with_name("captcha_model.onnx").read_bytes() + model_dir = tmp_path / "中文模型目录" + model_dir.mkdir() + (model_dir / "captcha_model.onnx").write_bytes(model_bytes) + monkeypatch.setattr(captcha, "__file__", str(model_dir / "captcha.py")) + monkeypatch.setattr(captcha.sys, "frozen", frozen, raising=False) + monkeypatch.setattr(captcha.sys, "_MEIPASS", str(model_dir), raising=False) + monkeypatch.setattr(captcha.LoginCaptchaSolver, "_initialized", False) + monkeypatch.setattr(captcha.LoginCaptchaSolver, "_ocr", None) + log = StubLog() + + net = captcha.LoginCaptchaSolver.get_ocr(log) + assert net is not None, log.messages + net.setInput(np.zeros((1, 1, 28, 28), dtype=np.float32)) + output = net.forward() + assert output.shape == (1, 36) + assert np.isfinite(output).all() + + +def test_atomic_debug_png_roundtrips_in_unicode_directory(tmp_path: Path) -> None: + destination = tmp_path / "中文调试目录" / "验证码.png" + pixels = np.arange(36, dtype=np.uint8).reshape(3, 4, 3) + + captcha._write_png_atomic(destination, pixels) + + decoded = captcha.cv2.imdecode( + np.frombuffer(destination.read_bytes(), dtype=np.uint8), + captcha.cv2.IMREAD_COLOR, + ) + np.testing.assert_array_equal(decoded, pixels) + assert list(destination.parent.iterdir()) == [destination] diff --git a/tests/test_client_protocol.py b/tests/test_client_protocol.py new file mode 100644 index 0000000..ed74469 --- /dev/null +++ b/tests/test_client_protocol.py @@ -0,0 +1,40 @@ +import pytest + +from client import _check_code_ok + + +@pytest.mark.parametrize("code", [0, "0", 1, "1", 200, "200"]) +def test_main_api_accepts_verified_numeric_and_string_codes( + code: int | str, +) -> None: + assert _check_code_ok({"code": code}) + + +@pytest.mark.parametrize("code", [-1, "-1", 2, "2", "", "invalid"]) +def test_business_error_codes_are_not_success(code: int | str) -> None: + assert not _check_code_ok({"code": code}) + + +@pytest.mark.parametrize("code", [0, "0", 1, "1"]) +def test_jsonp_finish_accepts_only_verified_codes(code: int | str) -> None: + assert _check_code_ok({"code": code}, allow_200=False) + + +@pytest.mark.parametrize("code", [200, "200"]) +def test_jsonp_finish_rejects_code_200(code: int | str) -> None: + assert not _check_code_ok({"code": code}, allow_200=False) + + +def test_missing_code_is_not_misclassified_as_success() -> None: + assert not _check_code_ok({"data": {"value": 1}}) + + +@pytest.mark.parametrize("allow_200", [True, False]) +def test_explicit_null_code_keeps_official_compatibility( + allow_200: bool, +) -> None: + assert _check_code_ok({"code": None}, allow_200=allow_200) + + +def test_empty_response_is_not_success() -> None: + assert not _check_code_ok({}) diff --git a/tests/test_client_safety.py b/tests/test_client_safety.py new file mode 100644 index 0000000..c7409e5 --- /dev/null +++ b/tests/test_client_safety.py @@ -0,0 +1,1221 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from urllib.parse import parse_qs, urlsplit + +import pytest + +import client as client_module +from client import WeBanClient, clean_text +from errors import ( + AccountBlockedError, + APIResponseError, + ResponseValidationError, + WorkflowResult, + WorkflowStatus, +) + + +class _NullLog: + def __getattr__(self, name: str): + del name + return lambda *args, **kwargs: None + + +def _progress_response( + finished: int, + *, + required: int = 1, + optional: int = 0, + push: int = 0, +) -> dict: + return { + "code": "0", + "data": { + "requiredNum": required, + "requiredFinishedNum": finished, + "optionalNum": optional, + "optionalFinishedNum": 0, + "pushNum": push, + "pushFinishedNum": 0, + "examNum": 0, + "examFinishedNum": 0, + }, + } + + +class _CourseAPI: + def __init__(self, *, jupiter_success: bool = True) -> None: + self.user = {"userId": "user", "userName": "张 三&同学"} + self.jupiter_success = jupiter_success + self.apinext_calls: list[dict] = [] + self.finish_calls: list[dict] = [] + self.list_question_calls = 0 + + def study(self, course_id: str, project_id: str) -> dict: + del course_id, project_id + return {"code": "0"} + + def get_course_url(self, course_id: str, project_id: str) -> dict: + del course_id, project_id + return { + "code": "0", + "data": ( + "https://mcwk.mycourse.cn/course/C/C.html?" + "userCourseId=user-course&weiban=weiban&csCapt=false" + ), + } + + def apinext(self, *args, **kwargs) -> dict: + del args + self.apinext_calls.append(dict(kwargs)) + return { + "code": 200, + "success": self.jupiter_success, + } + + def list_question(self, course_id: str) -> dict: + del course_id + self.list_question_calls += 1 + return { + "code": "0", + "data": { + "viewpointQuestionList": [], + "examQuestionList": [], + }, + } + + def save_question(self, *args) -> dict: + del args + return {"code": "0", "data": []} + + def save_exam_question(self, *args) -> dict: + del args + return {"code": "0", "data": {}} + + def finish_by_token(self, user_course_id: str, **kwargs) -> dict: + self.finish_calls.append({"user_course_id": user_course_id, **kwargs}) + return {"code": "0"} + + +def _course_client(api: _CourseAPI, *, uses_apinext: bool) -> WeBanClient: + client = object.__new__(WeBanClient) + client.api = api + client.log = _NullLog() + client.study_base_time = 0 + client.study_random_upper = 0 + client.video_speed = 0 + client.jupiter_fallback = False + client._captcha_handler = None + client.parse_item_js = lambda *args, **kwargs: { + "uses_apinext": uses_apinext, + "nonstr_map": {1: "nonce"} if uses_apinext else {}, + "has_exam": False, + "total_step": 1 if uses_apinext else 0, + "video_duration": 0, + } + return client + + +@pytest.mark.parametrize( + "response", + [ + {"code": "0", "data": None}, + {"code": "-1"}, + {"code": "0", "data": [None, {"list": None}, {"list": [None, {}]}]}, + ], + ids=["null-data", "business-failure", "malformed-entries"], +) +def test_tenant_lookup_returns_empty_on_malformed_list(response: dict) -> None: + client = object.__new__(WeBanClient) + client.log = _NullLog() + client.tenant_name = "测试学校" + client.api = SimpleNamespace(get_tenant_list_with_letter=lambda: response) + + assert client.get_tenant_code() == "" + + +def test_tenant_lookup_matches_trimmed_name() -> None: + client = object.__new__(WeBanClient) + client.log = _NullLog() + client.tenant_name = "测试学校" + client.api = SimpleNamespace( + get_tenant_list_with_letter=lambda: { + "code": "0", + "data": [{"list": [{"name": " 测试学校 ", "code": "0000123"}]}], + } + ) + + assert client.get_tenant_code() == "0000123" + + +@pytest.mark.parametrize( + ("course", "expected"), + [ + ({"finished": 1}, True), + ({"finished": "1"}, True), + ({"finished": 2}, False), + ({"finished": None}, False), + ({"finished": "unknown"}, False), + ({"finished": float("inf")}, False), + ({"finished": float("nan")}, False), + ({}, False), + ], +) +def test_course_finished_tolerates_unexpected_values( + course: dict, expected: bool +) -> None: + assert client_module._course_finished(course) is expected + + +def test_course_finished_handles_json_overflow_literal() -> None: + # JSON 1e309 在 Python 中解析为 inf,int(inf) 抛 OverflowError + course = json.loads('{"finished": 1e309}') + assert client_module._course_finished(course) is False + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (100, 100.0), + ("60", 60.0), + (0, 0.0), + (float("inf"), None), + (float("-inf"), None), + (float("nan"), None), + (-1, None), + (1e9, None), + (True, None), + (None, None), + ("abc", None), + ], +) +def test_finite_score_rejects_non_finite_and_out_of_range( + value: object, expected: float | None +) -> None: + assert client_module._finite_score(value) == expected + + +def test_brief_response_strips_body_and_control_characters() -> None: + response = { + "code": "-1", + "msg": "bad\x00\x1bline\n" + "x" * 500, + "data": {"token": "secret-token", "realName": "张三"}, + } + + brief = client_module._brief_response(response) + + assert "secret-token" not in brief + assert "张三" not in brief + assert "\x00" not in brief and "\x1b" not in brief and "\n" not in brief + assert "data=" in brief + assert len(brief) < 300 + assert client_module._brief_response(["not", "a", "dict"]) == "" + + +def test_tenant_lookup_failure_log_does_not_echo_response_body() -> None: + class RecordingLog(_NullLog): + def __init__(self) -> None: + self.errors: list[str] = [] + + def error(self, message: str) -> None: + self.errors.append(message) + + log = RecordingLog() + client = object.__new__(WeBanClient) + client.log = log + client.tenant_name = "测试学校" + client.api = SimpleNamespace( + get_tenant_list_with_letter=lambda: { + "code": "-1", + "data": {"token": "leaked-token"}, + } + ) + + assert client.get_tenant_code() == "" + assert log.errors + assert all("leaked-token" not in line for line in log.errors) + + +def test_apinext_and_finish_share_one_unique_number( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = _CourseAPI() + client = _course_client(api, uses_apinext=True) + monkeypatch.setattr(client_module.time, "sleep", lambda _: None) + + assert client._study_one_course( + {"resourceId": "course", "resourceName": "课程"}, + {"userProjectId": "project"}, + "分类", + "项目", + {}, + False, + ) + + trace_numbers = {call["unique_no"] for call in api.apinext_calls} + assert len(trace_numbers) == 1 + assert api.finish_calls[0]["unique_no"] == trace_numbers.pop() + + +def test_ordinary_course_does_not_send_unique_number( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = _CourseAPI() + client = _course_client(api, uses_apinext=False) + monkeypatch.setattr(client_module.time, "sleep", lambda _: None) + + assert client._study_one_course( + {"resourceId": "course", "resourceName": "课程"}, + {"userProjectId": "project"}, + "分类", + "项目", + {}, + False, + ) + + assert "unique_no" not in api.finish_calls[0] + + +def test_failed_jupiter_step_stops_before_questions_and_finish( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = _CourseAPI(jupiter_success=False) + client = _course_client(api, uses_apinext=True) + monkeypatch.setattr(client_module.time, "sleep", lambda _: None) + + with pytest.raises(ResponseValidationError, match="apinext"): + client._study_one_course( + {"resourceId": "course", "resourceName": "课程"}, + {"userProjectId": "project"}, + "分类", + "项目", + {}, + False, + ) + + assert api.list_question_calls == 0 + assert api.finish_calls == [] + + +def test_course_completion_polls_until_delayed_progress_is_visible() -> None: + responses = [ + _progress_response(0), + _progress_response(0), + _progress_response(1), + ] + calls = 0 + delays: list[float] = [] + client = object.__new__(WeBanClient) + client.log = _NullLog() + + def get_progress(*args, **kwargs) -> dict: + nonlocal calls + del args, kwargs + response = responses[min(calls, len(responses) - 1)] + calls += 1 + return response + + client.get_progress = get_progress + client._sleep = lambda seconds: delays.append(seconds) + client._study_one_course = lambda *args, **kwargs: True + + assert client._learn_course( + {"resourceId": "course", "resourceName": "课程"}, + {"userProjectId": "project"}, + "分类", + "项目", + {}, + False, + ) + assert calls == 3 + assert delays == [client_module.PROGRESS_POLL_DELAYS[0]] + + +def test_course_completion_with_no_progress_update_remains_incomplete() -> None: + response = _progress_response(0) + responses = [response] * (len(client_module.PROGRESS_POLL_DELAYS) + 2) + calls = 0 + delays: list[float] = [] + client = object.__new__(WeBanClient) + client.log = _NullLog() + + def get_progress(*args, **kwargs) -> dict: + nonlocal calls + del args, kwargs + calls += 1 + return responses[min(calls - 1, len(responses) - 1)] + + client.get_progress = get_progress + client._sleep = lambda seconds: delays.append(seconds) + client._study_one_course = lambda *args, **kwargs: True + + assert not client._learn_course( + {"resourceId": "course", "resourceName": "课程"}, + {"userProjectId": "project"}, + "分类", + "项目", + {}, + False, + ) + assert calls == len(client_module.PROGRESS_POLL_DELAYS) + 2 + assert delays == list(client_module.PROGRESS_POLL_DELAYS) + + +def test_project_completion_polls_before_declaring_study_incomplete() -> None: + responses = [_progress_response(0), _progress_response(1)] + calls = 0 + delays: list[float] = [] + client = object.__new__(WeBanClient) + client.log = _NullLog() + + def get_progress(*args, **kwargs) -> dict: + nonlocal calls + del args, kwargs + response = responses[min(calls, len(responses) - 1)] + calls += 1 + return response + + client.get_progress = get_progress + client._sleep = lambda seconds: delays.append(seconds) + + assert client._check_project_course_done({"userProjectId": "project"}, "项目") + assert calls == 2 + assert delays == [client_module.PROGRESS_POLL_DELAYS[0]] + + +def test_manual_captcha_cleanup_failure_does_not_skip_api_login( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class LoginAPI: + def __init__(self) -> None: + self.user = {"userId": ""} + self.login_calls: list[tuple[str, int]] = [] + + def get_timestamp(self, length: int, offset: int) -> int: + assert (length, offset) == (13, 0) + return 123 + + def rand_letter_image(self, timestamp: int) -> bytes: + assert timestamp == 123 + return b"captcha" + + def login(self, verify_code: str, verify_time: int) -> dict: + self.login_calls.append((verify_code, verify_time)) + self.user["userId"] = "user" + return {"code": "0"} + + api = LoginAPI() + client = object.__new__(WeBanClient) + client.api = api + client.log = _NullLog() + client.non_interactive = False + client.captcha_debug_dir = tmp_path + client._prompt = lambda message: "ABCD" + + monkeypatch.setattr( + client_module.LoginCaptchaSolver, + "recognize", + lambda image, log: None, + ) + monkeypatch.setattr(client_module.webbrowser, "open", lambda url: True) + real_unlink = Path.unlink + + def fail_captcha_unlink(path: Path, *, missing_ok: bool = False) -> None: + if path == tmp_path / "verify_code.png": + raise OSError("captcha file is still in use") + real_unlink(path, missing_ok=missing_ok) + + monkeypatch.setattr(Path, "unlink", fail_captcha_unlink) + + assert client.login() == api.user + assert api.login_calls == [("ABCD", 123)] + + +def test_parse_item_js_does_not_stop_before_later_nonstr_map( + monkeypatch: pytest.MonkeyPatch, +) -> None: + html = """ + + + + + + """ + + def fetch(session, url: str, referer: str | None = None) -> str: + del session, referer + if url.endswith(".html"): + return html + if url.endswith("/js/item.js"): + return "saveExamQuestion();" + if url.endswith("/build/js/COURSE.js"): + return 'const nonstrMap = new Map([[1, "n1"], [2, "n2"]]);' + return "" + + monkeypatch.setattr(client_module, "_fetch_text", fetch) + client = object.__new__(WeBanClient) + client.api = SimpleNamespace(session=object()) + client.log = _NullLog() + + result = client.parse_item_js( + "COURSE", + "https://mcwk.mycourse.cn/course/COURSE/COURSE.html", + ) + + assert result["has_exam"] is True + assert result["nonstr_map"] == {1: "n1", 2: "n2"} + + +def test_course_url_uses_standard_query_encoding() -> None: + api = _CourseAPI() + client = object.__new__(WeBanClient) + client.api = api + + result = client._build_course_url( + {"resourceId": "课程&id", "praiseNum": "1 2"}, + {"userProjectId": "项目&id"}, + ) + query = parse_qs(urlsplit(result).query) + + assert query["userName"][-1] == "张 三&同学" + assert query["courseId"][-1] == "课程&id" + assert query["userProjectId"][-1] == "项目&id" + assert "张 三&同学" not in result + + +def _question( + title: str, + *, + option_b: str = "否", +) -> dict: + return { + "id": "question", + "title": title, + "type": 1, + "typeLabel": "单选题", + "optionList": [ + {"id": "yes-id", "content": "是"}, + {"id": "no-id", "content": option_b}, + ], + } + + +def _entry(correct: tuple[int, ...] = (0,), *, option_b: str = "否") -> dict: + return { + "type": 1, + "optionList": [ + {"content": "是", "isCorrect": 1 if 0 in correct else 2}, + {"content": option_b, "isCorrect": 1 if 1 in correct else 2}, + ], + } + + +def test_answer_matching_preserves_semantic_signs_and_rejects_stale_options() -> None: + answers = { + "温度>0吗?": _entry((0,)), + "温度<0吗?": _entry((1,)), + } + + assert clean_text("温度>0吗?") != clean_text("温度<0吗?") + assert WeBanClient._answer_ids_for_question( + answers, + _question("温度>0吗?"), + ) == ["yes-id"] + assert ( + WeBanClient._answer_ids_for_question( + answers, + _question("温度>0吗?", option_b="未知"), + ) + == [] + ) + + +def test_fuzzy_match_must_be_unique_and_single_choice_cannot_union() -> None: + ambiguous = { + "以下正确?": _entry((0,)), + "以下正确。": _entry((0,)), + } + assert ( + WeBanClient._answer_ids_for_question( + ambiguous, + _question("以下正确!"), + ) + == [] + ) + assert ( + WeBanClient._answer_ids_for_question( + {"单选题": _entry((0, 1))}, + _question("单选题"), + ) + == [] + ) + + +class _ExamAPI: + def __init__( + self, + *, + before: dict, + paper: dict | None = None, + odd_num: int = 2, + finish_num: int = 0, + history_prepare: dict | Exception | None = None, + exam_score: object = 0, + pass_score: object = 60, + ) -> None: + self.user = {"userId": "user", "realName": "用户"} + self.before = before + self.paper = paper + self.odd_num = odd_num + self.finish_num = finish_num + self.exam_score = exam_score + self.pass_score = pass_score + # 已考过的计划会先用 preparePaper 读取满分;None 表示与正式响应一致 + self.history_prepare = history_prepare + self.prepare_calls = 0 + self.start_calls = 0 + self.record_calls = 0 + self.submit_calls = 0 + + def exam_list_plan(self, project_id: str) -> dict: + del project_id + return { + "code": "0", + "data": [ + { + "id": "user-plan", + "examPlanId": "plan", + "examPlanName": "考试", + "examOddNum": self.odd_num, + "examFinishNum": self.finish_num, + "examScore": self.exam_score, + "passScore": self.pass_score, + } + ], + } + + def exam_before_paper(self, plan_id: str) -> dict: + del plan_id + return self.before + + def exam_prepare_paper(self, plan_id: str) -> dict: + del plan_id + self.prepare_calls += 1 + if self.finish_num > 0 and self.prepare_calls == 1: + if isinstance(self.history_prepare, Exception): + raise self.history_prepare + if self.history_prepare is not None: + return self.history_prepare + return { + "code": "0", + "data": { + "questionNum": 1, + "paperScore": 100, + "answerTime": 30, + "realName": "用户", + "userIDLabel": "学号", + }, + } + + def exam_check(self, *args) -> dict: + del args + return {"code": "0"} + + def exam_start_paper(self, plan_id: str) -> dict: + del plan_id + self.start_calls += 1 + return self.paper or {"code": "0", "data": {"questionList": []}} + + def exam_record_question(self, *args) -> dict: + del args + self.record_calls += 1 + return {"code": "0"} + + def exam_submit_paper(self, plan_id: str) -> dict: + del plan_id + self.submit_calls += 1 + return {"code": "0", "data": {"score": 100}} + + +def _exam_client(api: _ExamAPI, answers: dict) -> WeBanClient: + client = object.__new__(WeBanClient) + client.api = api + client.log = _NullLog() + client.ai_config = None + client._ai_key_warned = False + client._eta_exam_avg = None + client._captcha_handler = SimpleNamespace( + handle_exam_captcha=lambda plan_id: { + "randstr": f"rand-{plan_id}", + "ticket": "ticket", + } + ) + client._load_answers_json = lambda warn_on_fail=False: answers + return client + + +def _run_one_exam( + client: WeBanClient, + *, + threshold: int = 90, +) -> WorkflowResult: + return client.run_exam( + exam_question_time="0,0", + exam_submit_match_rate=threshold, + only_project={ + "projectName": "项目", + "userProjectId": "project", + "completion": {"grey": 2, "active": 1}, + }, + ) + + +def test_before_paper_failure_stops_current_exam_plan() -> None: + api = _ExamAPI(before={"code": "-1"}) + result = _run_one_exam(_exam_client(api, {})) + + assert result.status is WorkflowStatus.INCOMPLETE + assert api.prepare_calls == 0 + assert api.start_calls == 0 + + +def test_empty_paper_is_never_submitted() -> None: + api = _ExamAPI( + before={"code": "0", "data": {"isExistedNotSubmit": False}}, + ) + result = _run_one_exam(_exam_client(api, {}), threshold=0) + + assert result.status is WorkflowStatus.INCOMPLETE + assert api.record_calls == 0 + assert api.submit_calls == 0 + + +def test_last_attempt_requires_all_questions_to_map_to_legal_ids() -> None: + api = _ExamAPI( + before={"code": "0", "data": {"isExistedNotSubmit": False}}, + paper={"code": "0", "data": {"questionList": [_question("未知题")]}}, + odd_num=1, + ) + result = _run_one_exam(_exam_client(api, {}), threshold=0) + + assert result.status is WorkflowStatus.INCOMPLETE + assert api.record_calls == 0 + assert api.submit_calls == 0 + + +def test_valid_fully_mapped_paper_can_be_recorded_and_submitted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + api = _ExamAPI( + before={"code": "0", "data": {"isExistedNotSubmit": False}}, + paper={"code": "0", "data": {"questionList": [_question("已知题")]}}, + ) + client = _exam_client(api, {"已知题": _entry((0,))}) + monkeypatch.setattr(client_module.time, "sleep", lambda _: None) + + result = _run_one_exam(client) + + assert result.status is WorkflowStatus.SUCCESS + assert api.record_calls == 1 + assert api.submit_calls == 1 + + +@pytest.mark.parametrize( + "history_prepare", + [ + {"code": "0", "data": None}, + {"code": "-1"}, + {"code": "0", "data": {"paperScore": "not-a-number"}}, + {"code": "0", "data": {"paperScore": float("inf")}}, + {"code": "0", "data": {"paperScore": float("nan")}}, + APIResponseError( + "请求失败", + status_code=502, + endpoint="preparePaper", + summary="bad gateway", + ), + ], + ids=[ + "null-data", + "business-failure", + "bad-score", + "inf-score", + "nan-score", + "http-error", + ], +) +def test_history_score_probe_failure_does_not_abort_exam( + monkeypatch: pytest.MonkeyPatch, history_prepare: dict | Exception +) -> None: + api = _ExamAPI( + before={"code": "0", "data": {"isExistedNotSubmit": False}}, + paper={"code": "0", "data": {"questionList": [_question("已知题")]}}, + finish_num=1, + history_prepare=history_prepare, + ) + client = _exam_client(api, {"已知题": _entry((0,))}) + monkeypatch.setattr(client_module.time, "sleep", lambda _: None) + + result = client.run_exam( + exam_mode="force", + exam_question_time="0,0", + exam_submit_match_rate=90, + only_project={ + "projectName": "项目", + "userProjectId": "project", + "completion": {"grey": 2, "active": 1}, + }, + ) + + assert result.status is WorkflowStatus.SUCCESS + assert api.submit_calls == 1 + + +def test_negative_infinite_paper_score_never_marks_perfect_mode_as_done( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # 旧实现里 0 >= -inf 为真,perfect 模式会把这场考试当成"已满分"跳过 + api = _ExamAPI( + before={"code": "0", "data": {"isExistedNotSubmit": False}}, + paper={"code": "0", "data": {"questionList": [_question("已知题")]}}, + finish_num=1, + history_prepare={"code": "0", "data": {"paperScore": float("-inf")}}, + ) + client = _exam_client(api, {"已知题": _entry((0,))}) + monkeypatch.setattr(client_module.time, "sleep", lambda _: None) + + result = client.run_exam( + exam_mode="perfect", + exam_question_time="0,0", + exam_submit_match_rate=90, + only_project={ + "projectName": "项目", + "userProjectId": "project", + "completion": {"grey": 2, "active": 1}, + }, + ) + + assert result.status is WorkflowStatus.SUCCESS + assert api.submit_calls == 1 + + +@pytest.mark.parametrize( + ("exam_score", "pass_score"), + [ + (float("-inf"), 60), + (float("inf"), 60), + (float("nan"), 60), + (0, float("nan")), + (0, float("-inf")), + (0, 1e12), + ], + ids=[ + "neg-inf-score", + "inf-score", + "nan-score", + "nan-pass", + "neg-inf-pass", + "huge-pass", + ], +) +def test_non_finite_plan_scores_fail_closed_without_touching_paper( + exam_score: object, pass_score: object +) -> None: + api = _ExamAPI( + before={"code": "0", "data": {"isExistedNotSubmit": False}}, + paper={"code": "0", "data": {"questionList": [_question("已知题")]}}, + finish_num=1, + exam_score=exam_score, + pass_score=pass_score, + ) + result = _run_one_exam(_exam_client(api, {"已知题": _entry((0,))})) + + assert result.status is WorkflowStatus.INCOMPLETE + assert api.prepare_calls == 0 + assert api.start_calls == 0 + assert api.submit_calls == 0 + + +def test_review_merge_replaces_single_choice_truth_instead_of_union() -> None: + answers = {"同一道题": _entry((0,))} + reviewed = { + "title": "同一道题", + "type": 1, + "optionList": _entry((1,))["optionList"], + } + + assert WeBanClient._merge_reviewed_answer(answers, reviewed) + correct = [ + option["content"] + for option in answers["同一道题"]["optionList"] + if option["isCorrect"] == 1 + ] + assert correct == ["否"] + + +def test_history_response_supports_both_known_shapes_and_id_fields() -> None: + history = {"examId": "exam"} + assert WeBanClient._extract_history_list({"code": "0", "data": [history]}) == [ + history + ] + assert WeBanClient._extract_history_list( + {"code": "0", "data": {"examHistoryList": [history]}} + ) == [history] + + +def test_sync_skips_one_bad_plan_and_atomically_keeps_good_review( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root_path = tmp_path / "answer.json" + root_path.write_text( + json.dumps({"旧题": _entry((0,))}, ensure_ascii=False), + encoding="utf-8", + ) + monkeypatch.setattr(client_module, "root_answer_path", str(root_path)) + monkeypatch.setattr( + client_module, + "answer_path", + str(tmp_path / "answer" / "answer.json"), + ) + monkeypatch.setattr( + client_module, + "bundle_answer_path", + str(tmp_path / "bundle" / "answer.json"), + ) + + class SyncAPI: + def list_my_project(self, ended: int = 2) -> dict: + return ( + { + "code": "0", + "data": [{"userProjectId": "project"}], + } + if ended == 2 + else {"code": "0", "data": []} + ) + + def list_completion(self) -> dict: + return {"code": "0", "data": []} + + def exam_list_plan(self, project_id: str) -> dict: + assert project_id == "project" + return { + "code": "0", + "data": [ + {"examPlanId": "good", "examType": 1}, + {"examPlanId": "bad", "examType": 1}, + ], + } + + def exam_list_history(self, plan_id: str, exam_type: int) -> dict: + assert exam_type == 1 + if plan_id == "bad": + return {"code": "-1"} + return { + "code": "0", + "data": {"examHistoryList": [{"examId": "exam-good"}]}, + } + + def exam_review_paper(self, exam_id: str, is_retake: int) -> dict: + assert (exam_id, is_retake) == ("exam-good", 2) + return { + "code": "0", + "data": { + "questions": [ + { + "title": "新题", + "type": 1, + "optionList": _entry((1,))["optionList"], + } + ] + }, + } + + client = object.__new__(WeBanClient) + client.api = SyncAPI() + client.log = _NullLog() + + result = client.sync_answers() + stored = json.loads(root_path.read_text(encoding="utf-8")) + + assert result.status is WorkflowStatus.INCOMPLETE + assert "旧题" in stored + assert "新题" in stored + + +def test_sync_propagates_account_lock_without_further_work( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root_path = tmp_path / "answer.json" + root_path.write_text( + json.dumps({"旧题": _entry((0,))}, ensure_ascii=False), + encoding="utf-8", + ) + monkeypatch.setattr(client_module, "root_answer_path", str(root_path)) + monkeypatch.setattr( + client_module, + "answer_path", + str(tmp_path / "answer" / "answer.json"), + ) + monkeypatch.setattr( + client_module, + "bundle_answer_path", + str(tmp_path / "bundle" / "answer.json"), + ) + + class LockedAPI: + def list_my_project(self, ended: int = 2) -> dict: + del ended + raise AccountBlockedError(detail_code="701") + + client = object.__new__(WeBanClient) + client.api = LockedAPI() + client.log = _NullLog() + + with pytest.raises(AccountBlockedError) as caught: + client.sync_answers() + assert caught.value.status is WorkflowStatus.LOCKED + + +def test_sync_downgrades_project_listing_network_error_to_incomplete( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root_path = tmp_path / "answer.json" + root_path.write_text( + json.dumps({"旧题": _entry((0,))}, ensure_ascii=False), + encoding="utf-8", + ) + monkeypatch.setattr(client_module, "root_answer_path", str(root_path)) + monkeypatch.setattr( + client_module, + "answer_path", + str(tmp_path / "answer" / "answer.json"), + ) + monkeypatch.setattr( + client_module, + "bundle_answer_path", + str(tmp_path / "bundle" / "answer.json"), + ) + + class FlakyAPI: + def list_my_project(self, ended: int = 2) -> dict: + if ended == 2: + raise APIResponseError( + "请求失败", + status_code=503, + endpoint="listMyProject", + summary="unavailable", + ) + return {"code": "0", "data": []} + + def list_completion(self) -> dict: + raise OSError("connection reset") + + client = object.__new__(WeBanClient) + client.api = FlakyAPI() + client.log = _NullLog() + + result = client.sync_answers() + + assert result.status is WorkflowStatus.INCOMPLETE + assert result.failed == 2 + assert "旧题" in json.loads(root_path.read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("endpoint", ["completion", "lab"]) +@pytest.mark.parametrize( + "response", + [ + {}, + {"code": "0", "data": None}, + OSError("connection reset"), + APIResponseError( + "请求失败", status_code=503, endpoint="list", summary="unavailable" + ), + ], + ids=["empty-object", "null-data", "network-error", "http-error"], +) +def test_sync_counts_module_failure_once_and_preserves_answers( + tmp_path: Path, endpoint: str, response: dict | Exception +) -> None: + class ModuleAPI: + def __init__(self) -> None: + self.lab_calls = 0 + + def list_my_project(self, ended: int = 2) -> dict: + return {"code": "0", "data": []} + + def list_completion(self) -> dict: + if endpoint == "completion": + if isinstance(response, Exception): + raise response + return response + return { + "code": "0", + "data": [{"module": "labProject", "showable": 1}], + } + + def lab_index(self) -> dict: + self.lab_calls += 1 + if isinstance(response, Exception): + raise response + return response + + answers = {"旧题": _entry((0,))} + store = client_module.AnswerStore( + tmp_path / "answer.json", validator=WeBanClient._is_valid_answers + ) + store.write(answers) + api = ModuleAPI() + client = object.__new__(WeBanClient) + client.api = api + client.log = _NullLog() + client.__dict__["_answer_store_instance"] = store + + result = client.sync_answers() + + assert result.status is WorkflowStatus.INCOMPLETE + assert result.failed == 1 + assert api.lab_calls == (1 if endpoint == "lab" else 0) + assert store.load() == WeBanClient._normalize_answers(answers) + + +def test_remote_answer_baseline_is_merged_inside_final_store_update() -> None: + class Store: + def __init__(self) -> None: + self.write_calls = 0 + self.update_calls = 0 + self.result: dict | None = None + + def load(self) -> dict: + raise client_module.AnswerStoreError("missing") + + def write(self, value: dict) -> None: + del value + self.write_calls += 1 + + def update(self, mutator, *, default) -> dict: + del default + self.update_calls += 1 + # 模拟远程下载期间另一进程已经写入的新题目。 + result = mutator({"并发题": _entry((0,))}) + assert isinstance(result, dict) + self.result = result + return result + + class RemoteAPI: + def download_answer(self) -> str: + return json.dumps({"远程题": _entry((1,))}, ensure_ascii=False) + + def list_my_project(self, ended: int = 2) -> dict: + del ended + return {"code": "0", "data": []} + + def list_completion(self) -> dict: + return {"code": "0", "data": []} + + store = Store() + client = object.__new__(WeBanClient) + client.api = RemoteAPI() + client.log = _NullLog() + client.__dict__["_answer_store_instance"] = store + + result = client.sync_answers() + + assert result.status is WorkflowStatus.SUCCESS + assert store.write_calls == 0 + assert store.update_calls == 1 + assert store.result is not None + assert {"远程题", "并发题"}.issubset(store.result) + + +@pytest.mark.parametrize("payload", ["[]", "null", '"text"']) +def test_remote_answer_rejects_non_object_before_normalizing(payload: str) -> None: + class Store: + def load(self) -> dict: + raise client_module.AnswerStoreError("missing") + + def update(self, mutator, *, default) -> dict: + del mutator, default + raise AssertionError("invalid remote data must not be committed") + + class RemoteAPI: + def download_answer(self) -> str: + return payload + + client = object.__new__(WeBanClient) + client.api = RemoteAPI() + client.log = _NullLog() + client.__dict__["_answer_store_instance"] = Store() + + result = client.sync_answers() + + assert result.status is WorkflowStatus.FAILED + + +def test_answer_validator_rejects_nonempty_object_without_usable_questions() -> None: + assert not WeBanClient._is_valid_answers({"无效题目": {}}) + assert not WeBanClient._is_valid_answers( + {"无效题目": {"optionList": [{"content": ""}]}} + ) + assert WeBanClient._is_valid_answers({"有效题目": _entry((0,))}) + + +def test_project_cycle_skips_exam_when_study_is_incomplete() -> None: + client = object.__new__(WeBanClient) + client.log = _NullLog() + client._get_project_list = lambda: [ + {"projectName": "项目", "userProjectId": "project"} + ] + client.run_study = lambda *args, **kwargs: WorkflowResult.incomplete("学习未完成") + exam_calls = 0 + + def run_exam(*args, **kwargs) -> WorkflowResult: + nonlocal exam_calls + del args, kwargs + exam_calls += 1 + return WorkflowResult.success() + + client.run_exam = run_exam + result = client.run_project_cycle( + study_time="0,0", + study_mode="true", + exam_mode="true", + random_answer=True, + exam_question_time="0,0", + exam_submit_match_rate=90, + ) + + assert result.status is WorkflowStatus.INCOMPLETE + assert exam_calls == 0 + + +def test_client_close_releases_owned_resources_once() -> None: + api_closes = 0 + handler_closes = 0 + + def close_api() -> None: + nonlocal api_closes + api_closes += 1 + + def close_handler() -> None: + nonlocal handler_closes + handler_closes += 1 + + client = object.__new__(WeBanClient) + client.api = SimpleNamespace(close=close_api) + client._captcha_handler = SimpleNamespace(close=close_handler) + client._closed = False + + client.close() + client.close() + + assert api_closes == 1 + assert handler_closes == 1 diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..c2f1eec --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,30 @@ +from errors import WorkflowResult, WorkflowStatus + + +def test_combine_uses_message_from_more_severe_result() -> None: + failed = WorkflowResult.failed_result("网络失败") + incomplete = WorkflowResult.incomplete("因学习不完整跳过考试") + + result = failed.combine(incomplete) + + assert result.status is WorkflowStatus.FAILED + assert result.message == "网络失败" + + +def test_combine_uses_later_message_when_later_result_is_more_severe() -> None: + incomplete = WorkflowResult.incomplete("学习未完成") + failed = WorkflowResult.failed_result("提交失败") + + result = incomplete.combine(failed) + + assert result.status is WorkflowStatus.FAILED + assert result.message == "提交失败" + + +def test_combine_explicit_message_still_overrides_deciding_result() -> None: + result = WorkflowResult.success("阶段完成").combine( + WorkflowResult.incomplete("阶段不完整"), + message="汇总信息", + ) + + assert result.message == "汇总信息" diff --git a/tests/test_main_runtime.py b/tests/test_main_runtime.py new file mode 100644 index 0000000..8d65359 --- /dev/null +++ b/tests/test_main_runtime.py @@ -0,0 +1,447 @@ +from __future__ import annotations + +import copy +import os +import threading +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Any, ClassVar + +import pytest +from loguru import logger as base_logger + +import main +from runtime_config import ( + InteractionPolicy, + build_runtime_config, + load_toml, + parse_args, + resolve_paths, +) + + +def _runtime(tmp_path: Path, *, non_interactive: bool = True): + args = ["--data-dir", str(tmp_path)] + args.append("--non-interactive" if non_interactive else "--no-non-interactive") + opts = parse_args(args) + paths = resolve_paths( + opts, + {}, + script_path=tmp_path / "app" / "main.py", + cwd=tmp_path, + frozen=False, + ) + return build_runtime_config( + opts, + { + "account": [ + { + "tenant_name": "测试学校", + "username": "student-001", + "password": "password-secret", + } + ] + }, + paths, + {}, + stdin_is_tty=not non_interactive, + ) + + +def test_log_redactor_covers_credentials_and_registered_identifiers() -> None: + redactor = main.LogRedactor() + redactor.register("student-001", "password-secret", "token-secret") + raw = ( + 'username="student-001" password=password-secret ' + '"token":"token-secret" Cookie: session-cookie ' + "Authorization: Bearer abc.def" + ) + + redacted = redactor.redact(raw) + + assert "student-001" not in redacted + assert "password-secret" not in redacted + assert "token-secret" not in redacted + assert "session-cookie" not in redacted + assert "abc.def" not in redacted + assert "" in redacted + + +@pytest.mark.parametrize("non_interactive", [False, True]) +def test_startup_import_failure_keeps_interactive_window_open( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, non_interactive: bool +) -> None: + runtime = _runtime(tmp_path, non_interactive=non_interactive) + monkeypatch.setattr(main, "_build_runtime", lambda *args: (None, {}, runtime, False)) + prompts: list[str] = [] + + def read_input(prompt: str) -> str: + prompts.append(prompt) + return "" + + def fail_import(*args: object) -> None: + raise ModuleNotFoundError("No module named 'nodriver'") + + monkeypatch.setattr("builtins.input", read_input) + monkeypatch.setattr(main, "_apply_runtime_adapters", fail_import) + + assert main.main([], env={}) == main.EXIT_FAILURE + if non_interactive: + assert prompts == [] + else: + assert prompts[-1] == "按回车键退出" + log_text = next(runtime.paths.logs_dir.glob("*.log")).read_text(encoding="utf-8") + assert "nodriver" in log_text + + +def test_console_rendering_does_not_mutate_shared_record() -> None: + record = { + "message": "line one\nline two", + "level": SimpleNamespace(name="DEBUG"), + "time": SimpleNamespace(strftime=lambda _: "2026-08-30 12:00:00"), + "extra": {"account": "账号01-deadbeef"}, + } + before = copy.deepcopy(record["message"]) + + rendered = main.render_console_record(record) + + assert record["message"] == before + assert "line one\\nline two" in rendered + + +def test_noninteractive_prompt_never_calls_input() -> None: + called = False + + def forbidden(_: str) -> str: + nonlocal called + called = True + raise AssertionError("不应读取输入") + + with pytest.raises(RuntimeError, match="禁止"): + main.prompt_account_interactive( + InteractionPolicy(non_interactive=True), + input_fn=forbidden, + password_fn=forbidden, + ) + + assert called is False + + +def test_password_prompt_uses_hidden_input_callback() -> None: + answers = iter(["测试学校", "student-001"]) + password_prompts: list[str] = [] + + account = main.prompt_account_interactive( + InteractionPolicy(non_interactive=False), + input_fn=lambda _: next(answers), + password_fn=lambda prompt: password_prompts.append(prompt) or "secret", + ) + + assert account == { + "tenant_name": "测试学校", + "username": "student-001", + "password": "secret", + } + assert password_prompts == [" 密码(默认同用户名):"] + + +def test_ctrl_c_during_credential_prompt_propagates_for_exit_130() -> None: + with pytest.raises(KeyboardInterrupt): + main.prompt_account_interactive( + InteractionPolicy(non_interactive=False), + input_fn=lambda _: (_ for _ in ()).throw(KeyboardInterrupt), + ) + + +def test_interruptible_sleep_stops_immediately() -> None: + stop_event = threading.Event() + clock = main.InterruptibleTime(stop_event) + stop_event.set() + + with pytest.raises(main.StopRequested): + clock.sleep(3_600) + + +def test_waiting_for_sync_lock_is_interruptible() -> None: + lock = threading.Lock() + lock.acquire() + stop_event = threading.Event() + timer = threading.Timer(0.01, stop_event.set) + timer.start() + try: + with ( + pytest.raises(main.StopRequested), + main._interruptible_lock(lock, stop_event), + ): + raise AssertionError("不应取得锁") + finally: + lock.release() + timer.cancel() + + +@pytest.mark.parametrize( + ("statuses", "expected"), + [ + ([main.AccountRunStatus.SUCCESS], main.EXIT_SUCCESS), + ([main.AccountRunStatus.FAILED], main.EXIT_FAILURE), + ([main.AccountRunStatus.INCOMPLETE], main.EXIT_FAILURE), + ( + [main.AccountRunStatus.SUCCESS, main.AccountRunStatus.FAILED], + main.EXIT_PARTIAL_FAILURE, + ), + ( + [main.AccountRunStatus.SUCCESS, main.AccountRunStatus.INCOMPLETE], + main.EXIT_PARTIAL_FAILURE, + ), + ([main.AccountRunStatus.CANCELLED], main.EXIT_FAILURE), + ], +) +def test_structured_summary_maps_to_exit_codes( + statuses: list[main.AccountRunStatus], expected: int +) -> None: + summary = main.RunSummary( + tuple( + main.AccountRunResult(index, f"account-{index}", status) + for index, status in enumerate(statuses) + ) + ) + + assert summary.exit_code == expected + + +def test_run_account_returns_structured_success(tmp_path: Path) -> None: + runtime = _runtime(tmp_path) + + class FakeClient: + instances: ClassVar[list[FakeClient]] = [] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.args = args + self.kwargs = kwargs + self.exam_mode = "" + self.sync_count = 0 + self.closed = False + self.cycle_args: dict[str, Any] = {} + self.__class__.instances.append(self) + + def login(self) -> bool: + return True + + def simulate_home_page(self) -> None: + return None + + def sync_answers(self) -> None: + self.sync_count += 1 + + def run_project_cycle(self, **kwargs: Any) -> SimpleNamespace: + self.cycle_args = kwargs + return SimpleNamespace( + status=SimpleNamespace(value="success"), + message="", + ok=True, + ) + + def close(self) -> None: + self.closed = True + + result = main.run_account( + runtime.accounts[0], + runtime, + 0, + threading.Event(), + main.RuntimeDependencies(FakeClient), + base_logger.bind(account="系统"), + "20260830-120000", + ) + + assert result.status is main.AccountRunStatus.SUCCESS + assert FakeClient.instances[0].sync_count == 2 + assert FakeClient.instances[0].cycle_args["study_mode"] == "true" + assert FakeClient.instances[0].closed is True + + +def test_run_account_reports_incomplete_workflow_and_closes_client( + tmp_path: Path, +) -> None: + runtime = _runtime(tmp_path) + + class IncompleteClient: + instance: ClassVar[IncompleteClient | None] = None + + def __init__(self, *args: Any, **kwargs: Any) -> None: + del args, kwargs + self.exam_mode = "" + self.closed = False + self.__class__.instance = self + + def login(self) -> bool: + return True + + def simulate_home_page(self) -> None: + return None + + def sync_answers(self) -> SimpleNamespace: + return SimpleNamespace( + status=SimpleNamespace(value="success"), + message="", + ok=True, + ) + + def run_project_cycle(self, **kwargs: Any) -> SimpleNamespace: + del kwargs + return SimpleNamespace( + status=SimpleNamespace(value="incomplete"), + message="部分课程未完成", + ok=False, + ) + + def close(self) -> None: + self.closed = True + + result = main.run_account( + runtime.accounts[0], + runtime, + 0, + threading.Event(), + main.RuntimeDependencies(IncompleteClient), + base_logger.bind(account="系统"), + "20260830-120000", + ) + + assert result.status is main.AccountRunStatus.INCOMPLETE + assert "部分课程未完成" in result.detail + assert IncompleteClient.instance is not None + assert IncompleteClient.instance.closed is True + + +def test_runtime_adapter_injects_paths_policy_and_safe_captcha_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime = _runtime(tmp_path) + captcha_module = ModuleType("captcha") + client_module = ModuleType("client") + + class FakeCaptchaHandler: + def __init__(self, **kwargs: Any) -> None: + self.debug_dir = kwargs["debug_dir"] + + class FakeClient: + pass + + captcha_module.is_non_interactive = lambda: False # type: ignore[attr-defined] + client_module.is_non_interactive = lambda: False # type: ignore[attr-defined] + client_module.CaptchaHandler = FakeCaptchaHandler # type: ignore[attr-defined] + client_module.WeBanClient = FakeClient # type: ignore[attr-defined] + monkeypatch.setattr( + main, + "_load_business_modules", + lambda: (captcha_module, client_module), + ) + monkeypatch.setenv("WB_DATA_DIR", "previous") + monkeypatch.setenv("WB_NON_INTERACTIVE", "0") + + dependencies = main._apply_runtime_adapters(runtime, threading.Event()) + handler = client_module.CaptchaHandler( # type: ignore[attr-defined] + tenant_code="../../tenant", + user_id="../CON", + ) + + assert dependencies.client_class is FakeClient + assert client_module.is_non_interactive() is True # type: ignore[attr-defined] + assert captcha_module.is_non_interactive() is True # type: ignore[attr-defined] + assert Path(client_module.answer_dir) == runtime.paths.answer_dir # type: ignore[attr-defined] + assert Path(handler.debug_dir).is_relative_to(runtime.paths.captcha_debug_dir) + assert ".." not in Path(handler.debug_dir).name + + +def test_interactive_account_save_is_atomic_and_parseable( + tmp_path: Path, +) -> None: + runtime = _runtime(tmp_path, non_interactive=False) + + class CapturingLogger: + def success(self, _: str) -> None: + return None + + main.save_interactive_account( + runtime, + runtime.accounts[0].credentials, + CapturingLogger(), + ) + document = load_toml(runtime.paths.config_path) + + assert document["account"][0]["username"] == "student-001" + assert document["account"][0]["password"] == "password-secret" + assert not list(runtime.paths.config_path.parent.glob("*.tmp")) + + +def test_noninteractive_missing_config_never_prompts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + "builtins.input", + lambda *_: (_ for _ in ()).throw(AssertionError("不应读取输入")), + ) + + exit_code = main.main( + ["--non-interactive", "--data-dir", str(tmp_path)], + env={}, + ) + + assert exit_code == main.EXIT_CONFIG_ERROR + assert (tmp_path / "config.toml").exists() + log_files = list((tmp_path / "logs").glob("*.log")) + assert log_files + for log_file in log_files: + log_file.unlink() + + +def test_main_restores_runtime_environment_after_embedded_run( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime = _runtime(tmp_path) + captcha_module = ModuleType("captcha") + client_module = ModuleType("client") + client_module.CaptchaHandler = object # type: ignore[attr-defined] + client_module.WeBanClient = object # type: ignore[attr-defined] + + class CapturingLogger: + def info(self, _: str) -> None: + return None + + def error(self, _: str) -> None: + return None + + def execute_accounts(*args: Any, **kwargs: Any) -> main.RunSummary: + del args, kwargs + assert os.environ["WB_DATA_DIR"] == str(runtime.paths.data_dir) + assert os.environ["WB_NON_INTERACTIVE"] == "1" + return main.RunSummary( + (main.AccountRunResult(0, "account-0", main.AccountRunStatus.SUCCESS),) + ) + + monkeypatch.setattr( + main, + "_build_runtime", + lambda *_: (SimpleNamespace(), {}, runtime, False), + ) + monkeypatch.setattr( + main, + "_setup_logging", + lambda *_: (CapturingLogger(), "20260831-120000"), + ) + monkeypatch.setattr( + main, + "_load_business_modules", + lambda: (captcha_module, client_module), + ) + monkeypatch.setattr(main, "_check_update_async", lambda *_: None) + monkeypatch.setattr(main, "_execute_accounts", execute_accounts) + monkeypatch.setenv("WB_DATA_DIR", "before-run") + monkeypatch.delenv("WB_NON_INTERACTIVE", raising=False) + + exit_code = main.main(env={}) + + assert exit_code == main.EXIT_SUCCESS + assert os.environ["WB_DATA_DIR"] == "before-run" + assert "WB_NON_INTERACTIVE" not in os.environ diff --git a/tests/test_runtime_config.py b/tests/test_runtime_config.py new file mode 100644 index 0000000..4ab0252 --- /dev/null +++ b/tests/test_runtime_config.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import argparse +import os +import stat +from pathlib import Path + +import pytest + +from runtime_config import ( + ConfigError, + ResolvedPaths, + atomic_write_text, + build_runtime_config, + load_toml, + parse_args, + resolve_interaction_policy, + resolve_paths, +) + + +def _document( + *, + settings: dict[str, object] | None = None, + account: dict[str, object] | None = None, + ai: dict[str, object] | None = None, +) -> dict[str, object]: + return { + "settings": settings or {}, + "account": [ + account + or { + "tenant_name": "测试学校", + "username": "student-001", + "password": "secret-password", + } + ], + "ai": ai or {}, + } + + +def _paths( + tmp_path: Path, + opts: argparse.Namespace | None = None, + env: dict[str, str] | None = None, +) -> ResolvedPaths: + parsed = parse_args([]) if opts is None else opts + return resolve_paths( + parsed, + env or {}, + script_path=tmp_path / "app" / "main.py", + cwd=tmp_path, + frozen=False, + ) + + +def test_cli_paths_override_environment_and_share_one_data_root( + tmp_path: Path, +) -> None: + opts = parse_args( + [ + "--config", + "cli/config.toml", + "--data-dir", + "cli-data", + ] + ) + paths = _paths( + tmp_path, + opts, + { + "WB_CONFIG": "env/config.toml", + "WB_DATA_DIR": "env-data", + }, + ) + + assert paths.config_path == (tmp_path / "cli" / "config.toml").resolve() + assert paths.data_dir == (tmp_path / "cli-data").resolve() + assert paths.logs_dir == paths.data_dir / "logs" + assert paths.answer_dir == paths.data_dir / "answer" + assert paths.captcha_debug_dir == paths.logs_dir / "captcha" + + +def test_wb_config_is_honored_and_anchors_default_data_root( + tmp_path: Path, +) -> None: + paths = _paths( + tmp_path, + env={"WB_CONFIG": "profile/custom.toml"}, + ) + + assert paths.config_path == (tmp_path / "profile" / "custom.toml").resolve() + assert paths.data_dir == paths.config_path.parent + + +def test_cli_then_env_then_account_then_global_precedence( + tmp_path: Path, +) -> None: + opts = parse_args( + [ + "--study-mode", + "force", + "--video-speed", + "2", + ] + ) + document = _document( + settings={ + "study_mode": "true", + "exam_mode": "true", + "video_speed": 1, + "debug": False, + }, + account={ + "tenant_name": "测试学校", + "username": "student-001", + "study_mode": "false", + "exam_mode": "perfect", + "video_speed": 3, + }, + ) + runtime = build_runtime_config( + opts, + document, + _paths(tmp_path, opts), + { + "WB_STUDY_MODE": "true", + "WB_EXAM_MODE": "force", + "WB_DEBUG": "yes", + }, + stdin_is_tty=True, + ) + settings = runtime.accounts[0].settings + + assert settings.study_mode == "force" + assert settings.exam_mode == "force" + assert settings.video_speed == 2 + assert settings.debug is True + + +def test_account_toml_overrides_global_when_cli_and_env_are_absent( + tmp_path: Path, +) -> None: + opts = parse_args([]) + runtime = build_runtime_config( + opts, + _document( + settings={"study_mode": "true"}, + account={ + "tenant_name": "测试学校", + "username": "student-001", + "study_mode": "false", + }, + ), + _paths(tmp_path, opts), + {}, + stdin_is_tty=True, + ) + + assert runtime.accounts[0].settings.study_mode == "false" + + +def test_ai_cli_and_environment_overrides_are_typed( + tmp_path: Path, +) -> None: + opts = parse_args(["--ai-enable", "true", "--ai-timeout", "25"]) + runtime = build_runtime_config( + opts, + _document( + ai={ + "enable": False, + "base_url": "https://toml.invalid/v1", + "model": "toml-model", + "timeout": 60, + "max_retries": 2, + } + ), + _paths(tmp_path, opts), + { + "WB_AI_BASE_URL": "https://example.test/v1", + "WB_AI_MODEL": "env-model", + "WB_AI_MAX_RETRIES": "4", + }, + stdin_is_tty=True, + ) + + assert runtime.ai.enable is True + assert runtime.ai.base_url == "https://example.test/v1" + assert runtime.ai.model == "env-model" + assert runtime.ai.timeout == 25 + assert runtime.ai.max_retries == 4 + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("study_mode", "unsafe"), + ("exam_mode", "loop"), + ("random_answer", "perhaps"), + ("study_time", "-1,0"), + ("study_time", "86400,1"), + ("video_speed", -0.1), + ("video_speed", "nan"), + ("exam_question_time", "1,3600"), + ("exam_submit_match_rate", 101), + ("max_workers", 0), + ("max_workers", 65), + ("cdp_port", 65_536), + ], +) +def test_invalid_runtime_values_fail_before_network( + tmp_path: Path, key: str, value: object +) -> None: + opts = parse_args([]) + settings = {key: value} + + with pytest.raises(ConfigError): + build_runtime_config( + opts, + _document(settings=settings), + _paths(tmp_path, opts), + {}, + stdin_is_tty=True, + ) + + +def test_half_configured_cdp_is_rejected(tmp_path: Path) -> None: + opts = parse_args([]) + + with pytest.raises(ConfigError, match="必须同时设置"): + build_runtime_config( + opts, + _document(settings={"cdp_host": "127.0.0.1"}), + _paths(tmp_path, opts), + {}, + stdin_is_tty=True, + ) + + +def test_nonexistent_explicit_browser_is_rejected(tmp_path: Path) -> None: + opts = parse_args([]) + + with pytest.raises(ConfigError, match="文件不存在"): + build_runtime_config( + opts, + _document(settings={"browser_path": "missing-browser.exe"}), + _paths(tmp_path, opts), + {}, + stdin_is_tty=True, + ) + + +def test_complete_cdp_takes_precedence_over_invalid_browser_path( + tmp_path: Path, +) -> None: + opts = parse_args([]) + runtime = build_runtime_config( + opts, + _document( + settings={ + "browser_path": "missing-browser.exe", + "cdp_host": "127.0.0.1", + "cdp_port": 9222, + } + ), + _paths(tmp_path, opts), + {}, + stdin_is_tty=True, + ) + + settings = runtime.accounts[0].settings + assert settings.browser_path is None + assert settings.cdp_host == "127.0.0.1" + assert settings.cdp_port == 9222 + + +def test_invalid_utf8_config_is_reported_as_config_error(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + path.write_bytes(b'[settings]\nname = "\xff"\n') + + with pytest.raises(ConfigError, match="编码错误"): + load_toml(path) + + +def test_account_scalars_are_normalized_to_strings(tmp_path: Path) -> None: + opts = parse_args([]) + runtime = build_runtime_config( + opts, + _document( + account={ + "tenant_name": 1001, + "username": 20_240_001, + "password": "", + } + ), + _paths(tmp_path, opts), + {}, + stdin_is_tty=True, + ) + credentials = runtime.accounts[0].credentials + + assert credentials.tenant_name == "1001" + assert credentials.username == "20240001" + assert credentials.password == "20240001" + + +def test_password_only_environment_override_applies_to_single_toml_account( + tmp_path: Path, +) -> None: + opts = parse_args([]) + runtime = build_runtime_config( + opts, + _document(), + _paths(tmp_path, opts), + {"WB_PASSWORD": "environment-secret"}, + stdin_is_tty=True, + ) + + assert runtime.accounts[0].credentials.password == "environment-secret" + + +def test_cli_noninteractive_choice_overrides_environment(tmp_path: Path) -> None: + del tmp_path + opts = parse_args(["--no-non-interactive"]) + + policy = resolve_interaction_policy( + opts, + {}, + {"WB_NON_INTERACTIVE": "true"}, + stdin_is_tty=False, + ) + + assert policy.non_interactive is False + + +def test_unknown_cli_argument_is_an_error() -> None: + with pytest.raises(SystemExit) as caught: + parse_args(["--unknown-option"]) + + assert caught.value.code == 2 + + +def test_account_log_identity_contains_only_hash_components( + tmp_path: Path, +) -> None: + opts = parse_args([]) + raw_username = "../CON/secret-user" + runtime = build_runtime_config( + opts, + _document( + account={ + "tenant_name": "../../租户", + "username": raw_username, + } + ), + _paths(tmp_path, opts), + {}, + stdin_is_tty=True, + ) + identity = runtime.accounts[0].identity + log_path = ( + runtime.paths.logs_dir / identity.tenant_dir / identity.account_dir + ).resolve() + + log_path.relative_to(runtime.paths.logs_dir.resolve()) + assert raw_username not in str(log_path) + assert identity.tenant_dir.startswith("tenant-") + assert identity.account_dir.startswith("account-") + assert "/" not in identity.account_dir + assert "\\" not in identity.account_dir + + +def test_atomic_write_replaces_content_and_leaves_no_temp_file( + tmp_path: Path, +) -> None: + target = tmp_path / "private" / "config.toml" + atomic_write_text(target, "first") + atomic_write_text(target, "second") + + assert target.read_text(encoding="utf-8") == "second" + assert not list(target.parent.glob("*.tmp")) + if os.name != "nt": + assert stat.S_IMODE(target.stat().st_mode) == 0o600 + + +def test_atomic_write_failure_after_fdopen_never_closes_foreign_descriptor( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """文件对象已接管描述符后再失败,不得再次 os.close 同一编号。""" + + import runtime_config + + closed: list[int] = [] + real_close = os.close + + def tracking_close(fd: int) -> None: + closed.append(fd) + real_close(fd) + + def failing_replace(src: object, dst: object) -> None: + del src, dst + raise OSError("replace failed") + + monkeypatch.setattr(runtime_config.os, "close", tracking_close) + monkeypatch.setattr(runtime_config.os, "replace", failing_replace) + + target = tmp_path / "config.toml" + with pytest.raises(OSError, match="replace failed"): + atomic_write_text(target, "content") + + assert closed == [] + assert not target.exists() + assert not list(tmp_path.glob("*.tmp")) diff --git a/uv.lock b/uv.lock index 826a3e1..5992618 100644 --- a/uv.lock +++ b/uv.lock @@ -77,6 +77,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -95,7 +104,7 @@ name = "macholib" version = "1.16.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "altgraph" }, + { name = "altgraph", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } wheels = [ @@ -185,12 +194,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pyaes" version = "1.6.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536, upload-time = "2017-09-20T21:17:54.23Z" } +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + [[package]] name = "pyinstaller" version = "6.21.0" @@ -244,6 +271,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -322,7 +365,7 @@ wheels = [ [[package]] name = "weban" -version = "3.10.0" +version = "3.10.1" source = { virtual = "." } dependencies = [ { name = "loguru" }, @@ -337,6 +380,7 @@ dependencies = [ dev = [ { name = "pyinstaller" }, { name = "pyright" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -354,6 +398,7 @@ requires-dist = [ dev = [ { name = "pyinstaller", specifier = ">=6.20.0" }, { name = "pyright", specifier = ">=1.1.409" }, + { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.15" }, ]