- tcaptcha/web/:FastAPI + SSE + 零构建单页。把同步阻塞的 solve() 包成可观测 后台任务(工作线程 + 全局串行,因 onnxruntime session 非线程安全),用 logging.Handler 零侵入把 client 日志变成事件流;按靶子实测 min_interval 强制节流。 - tcaptcha/render.py:标注图(点击顺序编号 + 质心偏差)与点位量尺,CLI 与 web 共用。 - tcaptcha/targets.py:靶子表提为单一来源(含实测频控间隔 1ms=60s / mliev=3s)。 - fastapi/uvicorn 走 optional extra `web`,核心包零 web 依赖(带回归断言)。 - 修两个真 bug:hook 求解器时 args[0] 是实例自身(真入参从 args[1] 起); client 默认每次新建 solver,预热实例未被复用(每发重载 56MB ONNX)。 - 复验:icon/text/slider 三题型各拿到 errorCode=0;selftest 全绿;依赖隔离通过。 用法:uv run --extra web python -m tcaptcha.web.app
122 lines
5.8 KiB
Markdown
122 lines
5.8 KiB
Markdown
# tcaptcha Web 观测台
|
||
|
||
把 CLI 的黑盒变成**实时可见的过程**:启一次任务,就能看到
|
||
「抽到哪道题 → PoW 命中 → 求解出点位(带标注图与偏离量)→ collect 段几何 → verify 的 errorCode / ticket」。
|
||
|
||
它**不是**另一套求解逻辑:后端就是 `TencentCaptchaClient.solve()`,与
|
||
`scripts/verify/send_test.py` / `send_campaign.py` 跑的是同一条链路。
|
||
|
||
## 启动
|
||
|
||
```bash
|
||
uv run --extra web python -m tcaptcha.web.app # 默认 127.0.0.1:8770
|
||
uv run --extra web python -m tcaptcha.web.app --port 9000 --host 0.0.0.0
|
||
uv run --extra web python -m tcaptcha.web.app --no-warmup # 不预热 AI 引擎
|
||
```
|
||
|
||
> `fastapi` / `uvicorn` 装在 **optional extra `web`** 里,核心包(协议 / 遥测 / 求解器 / CLI)
|
||
> 完全不依赖它们 —— `import tcaptcha` 不会把 fastapi 拉进来(有回归断言:见本文件末)。
|
||
|
||
## 架构
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
FE["前端(零构建单页)<br/>index.html + app.js"]
|
||
API["app.py(FastAPI)<br/>建任务 / SSE / 图片"]
|
||
RUN["runner.py<br/>SolveJob + JobManager"]
|
||
HOOK["_Hooks / _LogBridge<br/>(临时、成对安装)"]
|
||
CORE["protocol.client.solve()<br/>solvers/*"]
|
||
R["render.py<br/>标注图 + 点位量尺"]
|
||
|
||
FE -->|"POST /api/jobs"| API
|
||
API --> RUN
|
||
RUN -->|"工作线程 + 全局串行 + 频控节流"| CORE
|
||
RUN --> HOOK
|
||
HOOK -.->|"题型/点位/段几何"| RUN
|
||
CORE -.->|"日志"| HOOK
|
||
RUN --> R
|
||
RUN -->|"SSE 事件"| FE
|
||
```
|
||
|
||
| 文件 | 职责 |
|
||
| --- | --- |
|
||
| `events.py` | 事件模型;`EVT_*` 类型名前后端共用;`EventLog` 只追加 + 带锁(保证 `seq` 不重复) |
|
||
| `runner.py` | `SolveJob`(一次任务)与 `JobManager`(注册表 + 串行 + 节流 + solver 复用) |
|
||
| `app.py` | 路由;SSE 支持 `Last-Event-ID` 续传 |
|
||
| `static/` | 原生 JS 单页,无打包器、无框架 |
|
||
|
||
## 接口
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
| --- | --- | --- |
|
||
| `GET` | `/` | 单页界面 |
|
||
| `GET` | `/api/targets` | 靶子列表(含实测 `min_interval`)+ 冷却倒计时 |
|
||
| `POST` | `/api/jobs` | 建任务 `{target, kind, max_retries, max_refreshes}` → 快照 |
|
||
| `GET` | `/api/jobs` | 最近任务列表 |
|
||
| `GET` | `/api/jobs/{id}` | 任务快照 |
|
||
| `GET` | `/api/jobs/{id}/events` | **SSE** 事件流(`?since=N`,兼容 `Last-Event-ID`) |
|
||
| `GET` | `/api/jobs/{id}/image/{name}` | 题目图 / 提示图 / 标注图(内存里,任务结束后仍可取) |
|
||
| `POST` | `/api/jobs/{id}/cancel` | 取消(**当前这一发结束后**生效,见"已知限制") |
|
||
| `GET` | `/api/health` | 健康检查 |
|
||
|
||
## 事件
|
||
|
||
信封统一为 `{"seq", "ts", "type", "data"}`,前端按 `data.type` 分派:
|
||
|
||
| type | 关键字段 |
|
||
| --- | --- |
|
||
| `job.started` | `target` `aid` `kind` `mode` `note` |
|
||
| `challenge` | `index` `kind` `instruction` `bg_url` `prompt_url` |
|
||
| `solve` | `points`(**按点击顺序**)`off_px` `centroids` `annotated_url` `note` |
|
||
| `telemetry` | `source` `collect_len` `segments`(4 段几何)`traj_points` |
|
||
| `verify` | `success` `errorCode` `ticket` `randstr` `elapsed_ms` |
|
||
| `log` | `level` `logger` `message`(client 原始日志,见下) |
|
||
| `job.finished` | `success` `errorCode` `ticket` `elapsed_ms` `error` `stats` |
|
||
|
||
**errorCode 语义**(界面里有配色与图例,别误读):
|
||
`0` 通过 / `50`·`9` 答案被否(点位或目标错)/ `12` 包级拒答(轨迹指纹或频控)。
|
||
|
||
## 三个关键设计(都是被约束逼出来的,不是偏好)
|
||
|
||
1. **工作线程 + 全局串行**
|
||
`solve()` 同步阻塞 7~60s,不能挂 HTTP 请求上;且 `onnxruntime` 的 session **不是线程安全的**
|
||
—— 所以 `JobManager._serial` 保证同一时刻只跑一个求解。**去掉这把锁就会踩 ONNX 并发**。
|
||
|
||
2. **日志桥接(零侵入)**
|
||
`_LogBridge` 把 `tcaptcha.*` / `tcaptcha_client` 的日志原样变成 `log` 事件。client 里已有 20+ 条
|
||
`logger.info`(抽题、PoW 命中、题型、求解结论、遥测长度、verify…),接上它就等于免费得到
|
||
一份"和 CLI 一模一样"的轨迹 —— **不需要改 `protocol/client.py` 一行**。
|
||
|
||
3. **频控节流**
|
||
靶子自带**实测**的 `min_interval`(1ms=60s、mliev=3s,依据见 `tcaptcha/targets.py`)。
|
||
到点前任务停在 `throttled` 状态并在事件流里说明,否则界面上会出现"一片红"的假故障
|
||
—— 那些 `12` 全是频控噪声,不是求解失败。
|
||
|
||
## 已知限制 / 演进接口
|
||
|
||
* **取消是"软取消"**:`solve()` 内部不可中断,所以取消只在节流等待中、或当前这一发结束后生效。
|
||
* **任务在内存里**(上限 50 个,图片每任务 24 张):重启即丢。要持久化(sqlite)属于 P2。
|
||
* **单机自用定位**:默认只监听 `127.0.0.1`,没做鉴权。要暴露出去必须先加认证。
|
||
* **B/C 形态的预留接口**:`POST /api/jobs` 的 `params` 会原样进 `SolveJob.params`,
|
||
所以"人机协作"(回填人工坐标)与"批量/队列"(多靶子调度)都可以在不改事件模型的前提下接上
|
||
—— 前端多一个交互面板、后端多一个 `params` 分支即可。
|
||
|
||
## 回归
|
||
|
||
```bash
|
||
.venv/bin/python -m tcaptcha.selftest # 核心四套(不受 web 影响)
|
||
.venv/bin/python -c "import sys, tcaptcha; assert 'fastapi' not in sys.modules" # 依赖隔离
|
||
# SSE 端到端(建任务 -> 事件流 -> 图片可访问)
|
||
.venv/bin/python - <<'PY'
|
||
import json, httpx
|
||
c = httpx.Client(base_url="http://127.0.0.1:8770", timeout=120)
|
||
j = c.post("/api/jobs", json={"target": "mliev", "kind": "any"}).json()
|
||
print("job", j["id"])
|
||
with c.stream("GET", f"/api/jobs/{j['id']}/events") as r:
|
||
for line in r.iter_lines():
|
||
if line.startswith("data: "):
|
||
e = json.loads(line[6:])
|
||
print(e["type"], (e["data"].get("errorCode") if e["type"] == "verify" else ""))
|
||
PY
|
||
```
|