Files
hello_js_reverse_skill/tcaptcha/web/events.py
T
yml2213 99565f65f0 feat(web): 新增 Web 观测台(形态 A)+ 抽出 render/targets 共用层
- 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
2026-10-14 19:10:54 +08:00

78 lines
3.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""事件模型 —— 把一次求解的中间态结构化,经 SSE 推给前端。
设计要点:
* 前端只认**一种信封**:`{"seq", "ts", "type", "data"}`,分支全在 `type` 上。
* `type` 的取值集中在下面的 `EVT_*` 常量里,`runner.py` 与前端 `app.js` 共用同一套名字;
新增事件类型时**只改这里 + 前端一处**。
* 事件是**追加式**的:每个 job 维护一个列表,客户端可带 `since=<seq>` 续传 ——
断线重连、或任务跑到一半才打开页面,都能补齐历史(SSE 的 `Last-Event-ID` 走同一套)。
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Dict
# ---- 事件类型 ------------------------------------------------------------- #
EVT_STARTED = "job.started" # data: {target, kind, mode, params}
EVT_CHALLENGE = "challenge" # data: {kind, instruction, bg_url, prompt_url, subcapclass}
EVT_SOLVE = "solve" # data: {points, off_px, annotated_url, note}
EVT_TELEMETRY = "telemetry" # data: {source, collect_len, segments, traj_points}
EVT_VERIFY = "verify" # data: {errorCode, ticket, randstr, elapsed_ms, success}
EVT_LOG = "log" # data: {level, logger, message}
EVT_FINISHED = "job.finished" # data: {success, errorCode, ticket, elapsed_ms, error, stats}
EVT_ERROR = "job.error" # data: {message}
#: 一次任务最终会落到哪些终态(前端据此停止重连)
TERMINAL_TYPES = frozenset({EVT_FINISHED, EVT_ERROR})
@dataclass
class Event:
"""一条事件。SSE 的 `data:` 就是 `to_dict()` 的 JSON。"""
seq: int
type: str
data: Dict[str, Any] = field(default_factory=dict)
ts: float = field(default_factory=time.time)
def to_dict(self) -> Dict[str, Any]:
return {"seq": self.seq, "ts": round(self.ts, 3), "type": self.type, "data": self.data}
class EventLog:
"""一个 job 的事件缓冲区:追加 + 按 seq 续传。
注意它是**只追加**的:`since` 语义是"给我 seq > since 的全部",
这样 SSE 断开重连不会丢事件,也不会重复推送。
带锁:写在工作线程(求解),读在 HTTP 线程(SSE),
且 `seq = len + 1` 与 `append` 必须是原子的,否则会撞出重复 seq。
"""
def __init__(self) -> None:
self._items: list[Event] = []
self._lock = threading.Lock()
def append(self, type_: str, **data: Any) -> Event:
with self._lock:
evt = Event(seq=len(self._items) + 1, type=type_, data=dict(data))
self._items.append(evt)
return evt
def since(self, seq: int) -> list[Event]:
with self._lock:
return [e for e in self._items if e.seq > seq]
def __len__(self) -> int: # pragma: no cover - 方便调试
with self._lock:
return len(self._items)
@property
def last_seq(self) -> int:
with self._lock:
return len(self._items)