Files
hello_js_reverse_skill/tcaptcha/web/runner.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

513 lines
23 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 -*-
"""`SolveJob` / `JobManager` —— 把**同步阻塞**的求解包成"可观测的后台任务"。
为什么必须要有这一层(三条都是硬约束,不是设计偏好):
1. `client.solve()` 是同步阻塞的,一次 7~60s(内含 5.5~7.5s 拟人等待,
换题还要再等 2.8~4.2s)——**不能挂在 HTTP 请求上**,否则前端必超时。
2. ONNX / ddddocr 是阻塞的 CPU 计算,且 `onnxruntime` 的 session **不是线程安全的**
-> 必须丢到工作线程,并且**全局串行**(同一时刻只跑一个求解)。
3. 中间态(题型 / 题目图 / 点位 / 段几何 / errorCode)散落在局部变量和日志里
-> 用「**日志 handler 桥接** + 求解器 hook」把它们事件化,前端才能实时看到。
`--` 这里刻意**不修改** `protocol/client.py`:日志桥接零侵入,
求解器的 hook 只在本 job 存活期间(成对安装/卸载),且被串行锁保护。
"""
from __future__ import annotations
import base64
import importlib
import json
import logging
import threading
import time
import uuid
from typing import Any, Callable, Dict, List, Optional, Tuple
from tcaptcha.protocol.client import (KIND_ICON_CLICK, KIND_SLIDER, KIND_TEXT_CLICK,
TencentCaptchaClient)
from tcaptcha.render import annotate_points, icon_centroid_offset, to_jpeg
from tcaptcha.targets import get_target
from .events import (EVT_CHALLENGE, EVT_ERROR, EVT_FINISHED, EVT_LOG, EVT_SOLVE,
EVT_STARTED, EVT_TELEMETRY, EVT_VERIFY, EventLog)
#: 题型 -> accept_kinds
KIND_MAP: Dict[str, Tuple[str, ...]] = {
"text": (KIND_TEXT_CLICK,),
"icon": (KIND_ICON_CLICK,),
"slider": (KIND_SLIDER,),
"any": (KIND_TEXT_CLICK, KIND_ICON_CLICK, KIND_SLIDER),
}
#: 要 hook 的求解器: (标签, 模块, 类, 方法)
_SOLVER_TARGETS = (
("icon", "tcaptcha.solvers.icon_click", "CaptchaSolver", "solve"),
("text", "tcaptcha.solvers.text_click", "TextClickSolver", "solve_ex"),
("slider", "tcaptcha.solvers.slider", "SliderSolver", "solve"),
)
#: 一次任务最多在内存里留多少张图(超出就丢最早的,保证内存有界)
MAX_IMAGES = 24
#: 进程内复用的求解器实例 —— ONNX(YOLO+ResNet18, ~56MB) 与 ddddocr 加载一次要几秒,
#: 而 `TencentCaptchaClient` 默认**每次新建**一个 solver;把实例传进去才能真正复用。
#: ⚠️ 复用安全的前提是**求解被全局串行**(见 `JobManager._serial`)——
#: onnxruntime 的 session 不是线程安全的,别在没锁的情况下并发调用。
_SOLVER_CACHE: Dict[str, Any] = {}
def cached_solvers() -> Dict[str, Any]:
"""按需加载并缓存求解器(进程内复用)。供 `app.py` 预热与 `SolveJob` 共用。"""
if "icon" not in _SOLVER_CACHE:
try:
from tcaptcha.solvers.icon_click import CaptchaSolver
_SOLVER_CACHE["icon"] = CaptchaSolver()
except Exception as exc: # noqa: BLE001 加载失败就退回 client 自身懒加载
logging.getLogger("web.runner").warning("图标求解器加载失败: %s", exc)
_SOLVER_CACHE["icon"] = None
if "text" not in _SOLVER_CACHE:
try:
from tcaptcha.solvers.text_click import TextClickSolver
_SOLVER_CACHE["text"] = TextClickSolver()
except Exception as exc: # noqa: BLE001
logging.getLogger("web.runner").warning("文字点选求解器加载失败: %s", exc)
_SOLVER_CACHE["text"] = None
return _SOLVER_CACHE
class _LogBridge(logging.Handler):
"""把 tcaptcha 的日志**原样**变成事件 —— 零侵入拿到全过程。
client 里已经有 20+ 条 `logger.info`(抽题 / PoW 命中 / 题型、图片落盘、
遥测密文来源与长度、求解结论、verify 结果…),接上它就等于免费得到一份
"和 CLI 一模一样"的执行轨迹,不必去 client 里逐个加回调。
"""
def __init__(self, emit: Callable[[str, str, str], None]) -> None:
super().__init__(level=logging.INFO)
self._emit = emit
def emit(self, record: logging.LogRecord) -> None:
try:
self._emit(record.levelname, record.name, record.getMessage())
except Exception: # noqa: BLE001 日志桥绝不能反过来炸主流程
pass
class _Hooks:
"""成对安装/卸载的观测 hook(必须被串行锁保护)。
两类:
* **求解器 hook** —— 抓题型 / 题目图 / 点位,用来画标注图与量点位质量;
* **遥测 hook** —— 抓 `generate_telemetry` 的产物,解出 4 段几何与轨迹点数
(这是判断"包是否正确生成"的关键指标,CLI 里一直靠它做诊断)。
"""
def __init__(self, job: "SolveJob") -> None:
self._job = job
self._saved: List[Tuple[Any, str, Any]] = []
self._gen_saved: Optional[Tuple[Any, Any]] = None
def __enter__(self) -> "_Hooks":
for key, modname, clsname, fname in _SOLVER_TARGETS:
try:
cls = getattr(importlib.import_module(modname), clsname)
original = getattr(cls, fname)
except Exception: # noqa: BLE001 某个求解器不在也不该影响别的
continue
setattr(cls, fname, self._job._wrap_solver(key, original))
self._saved.append((cls, fname, original))
try:
import tcaptcha.protocol.client as client_mod
self._gen_saved = (client_mod, client_mod.generate_telemetry)
client_mod.generate_telemetry = self._job._wrap_telemetry(client_mod.generate_telemetry)
except Exception: # noqa: BLE001
self._gen_saved = None
return self
def __exit__(self, *exc_info) -> bool:
for cls, fname, original in self._saved:
setattr(cls, fname, original)
self._saved.clear()
if self._gen_saved is not None:
setattr(self._gen_saved[0], "generate_telemetry", self._gen_saved[1])
self._gen_saved = None
return False
class SolveJob:
"""一次真实求解任务。"""
def __init__(self, job_id: str, target_key: str, params: Dict[str, Any],
throttle: Optional[Callable[["SolveJob"], float]] = None,
on_finish: Optional[Callable[["SolveJob"], None]] = None) -> None:
self.id = job_id
self.target_key = target_key
self.params = dict(params)
self.events = EventLog()
self.state = "pending" # pending|throttled|running|done|error|cancelled
self.result: Optional[Dict[str, Any]] = None
self.error = ""
self.created_at = time.time()
self.finished_at: Optional[float] = None
self._throttle = throttle
self._on_finish = on_finish
self._cancel = threading.Event()
self._challenge_no = 0
self._solves: List[Dict[str, Any]] = []
self._images: Dict[str, bytes] = {}
self._img_lock = threading.Lock()
# ------------------------------------------------------------------ 图片
def add_image(self, name: str, data: bytes) -> str:
"""把一张图存进本 job 的内存(返回给前端的 URL)。"""
with self._img_lock:
while len(self._images) >= MAX_IMAGES:
self._images.pop(next(iter(self._images)))
self._images[name] = data
return "/api/jobs/%s/image/%s" % (self.id, name)
def get_image(self, name: str) -> Optional[bytes]:
with self._img_lock:
return self._images.get(name)
# ------------------------------------------------------------- 对外状态
@property
def cancelled(self) -> bool:
return self._cancel.is_set()
def cancel(self) -> None:
"""标记取消。注意:`solve()` 内部是同步阻塞的,
所以取消只在**节流等待中**或**当前这一发结束后**生效。"""
self._cancel.set()
self.events.append(EVT_LOG, level="WARNING", logger="web.runner",
message="已请求取消(当前这一发结束后生效)")
def stats(self) -> Dict[str, Any]:
offs = [v for s in self._solves for v in (s.get("off_px") or []) if isinstance(v, (int, float))]
return {
"challenges": self._challenge_no,
"solves": len(self._solves),
"points": sum(len(s.get("points") or []) for s in self._solves),
"mean_offset_px": round(sum(offs) / len(offs), 1) if offs else None,
}
def snapshot(self) -> Dict[str, Any]:
return {
"id": self.id,
"target": self.target_key,
"params": self.params,
"state": self.state,
"last_seq": self.events.last_seq,
"created_at": self.created_at,
"finished_at": self.finished_at,
"result": self.result,
"stats": self.stats(),
"error": self.error,
}
# ------------------------------------------------------------- 执行流程
def run(self) -> None:
"""在工作线程里跑(由 JobManager 串行调度)。"""
try:
self._execute()
except BaseException as exc: # noqa: BLE001 兜住一切,别让线程静默死掉
self.state = "error"
self.error = "%s: %s" % (type(exc).__name__, exc)
self.events.append(EVT_ERROR, message=self.error)
logging.getLogger("web.runner").exception("任务 %s 异常", self.id)
finally:
self.finished_at = time.time()
if self._on_finish:
self._on_finish(self)
def _execute(self) -> None:
tgt = get_target(self.target_key)
kind = str(self.params.get("kind") or "icon")
self.events.append(EVT_STARTED, target=self.target_key, aid=tgt["aid"],
kind=kind, mode="python_values",
note=tgt["note"], params={k: v for k, v in self.params.items()})
if self._throttle:
waited = self._throttle(self)
if waited:
self.events.append(EVT_LOG, level="INFO", logger="web.runner",
message="频控节流结束(等待 %.1fs)" % waited)
if self.cancelled:
self.state = "cancelled"
self.events.append(EVT_FINISHED, success=False, errorCode=None, ticket="",
elapsed_ms=0, error="任务已取消", stats=self.stats())
return
self.state = "running"
bridge = _LogBridge(self._emit_log)
loggers = [logging.getLogger("tcaptcha"), logging.getLogger("tcaptcha_client")]
for lg in loggers:
if lg.level == logging.NOTSET or lg.level > logging.INFO:
lg.setLevel(logging.INFO)
lg.addHandler(bridge)
try:
with _Hooks(self):
res = self._call_client(tgt, kind)
finally:
for lg in loggers:
lg.removeHandler(bridge)
self._finalize(res)
def _emit_log(self, level: str, name: str, message: str) -> None:
self.events.append(EVT_LOG, level=level, logger=name, message=message)
def _call_client(self, tgt, kind: str) -> Dict[str, Any]:
solvers = cached_solvers()
client = TencentCaptchaClient(
aid=tgt["aid"], entry_url=tgt["entry_url"], referer=tgt["referer"],
wb=tgt["wb"], accept_kinds=KIND_MAP.get(kind, KIND_MAP["any"]),
telemetry_mode="python_values", proxy=None,
solver=solvers.get("icon"), # 复用实例:不传的话 client 每发都重建
text_solver=solvers.get("text"),
)
try:
return client.solve(max_retries=int(self.params.get("max_retries", 2)),
max_refreshes=int(self.params.get("max_refreshes", 20)))
finally:
try:
client.close()
except Exception: # noqa: BLE001
pass
def _finalize(self, res: Dict[str, Any]) -> None:
raw = res.get("raw_response") or {}
ec = raw.get("errorCode")
self.result = {
"success": bool(res.get("success")),
"errorCode": ec,
"ticket": res.get("ticket") or "",
"randstr": res.get("randstr") or "",
"elapsed_ms": res.get("elapsed_ms"),
"kind": res.get("kind") or "",
"instruction": res.get("instruction") or "",
"error": res.get("error") or "",
}
self.error = res.get("error") or ""
self.state = "done" if res.get("success") else ("error" if self.error else "done")
self.events.append(EVT_VERIFY, success=bool(res.get("success")), errorCode=ec,
ticket=res.get("ticket") or "", randstr=res.get("randstr") or "",
elapsed_ms=res.get("elapsed_ms"))
self.events.append(EVT_FINISHED, success=bool(res.get("success")), errorCode=ec,
ticket=res.get("ticket") or "",
elapsed_ms=res.get("elapsed_ms"),
error=self.error, stats=self.stats())
# --------------------------------------------------------- 求解器 hook
def _wrap_solver(self, key: str, original: Callable) -> Callable:
def wrapper(*args, **kwargs):
result = original(*args, **kwargs)
try:
self._capture(key, args, result)
except Exception as exc: # noqa: BLE001 捕获失败不能影响求解
self.events.append(EVT_LOG, level="WARNING", logger="web.runner",
message="捕获求解中间态失败: %s" % exc)
return result
return wrapper
def _capture(self, key: str, args: tuple, result: Any) -> None:
"""把「题目图 + 点位」事件化。
⚠️ `args[0]` 是**求解器实例自身**(hook 的是实例方法,`client` 里是
`self.icon_solver.solve(bg, prompt)` 这种调用),真正的入参从 `args[1]` 开始。
"""
if key == "icon":
bg = args[1]
prompt = args[2] if len(args) > 2 else None
points = [(int(p[0]), int(p[1])) for p in (result or [])]
self._emit_challenge(KIND_ICON_CLICK, "请依次点击:", bg, prompt)
if points:
self._emit_solve(KIND_ICON_CLICK, bg, points, prompt,
"YOLOv8 召回 + ResNet18 分类 + 全局指派")
elif key == "text":
bg = args[1]
instr = args[2] if len(args) > 2 else ""
points = [(int(p[0]), int(p[1])) for p in (getattr(result, "points", None) or [])]
self._emit_challenge(KIND_TEXT_CLICK, instr, bg, None)
if points:
self._emit_solve(KIND_TEXT_CLICK, bg, points, None, _describe(result))
else: # slider
bg = args[1]
piece = args[2] if len(args) > 2 else None
tx = getattr(result, "target_x_image", None)
ty = getattr(result, "y_image", None)
points = [(int(round(tx)), int(round(ty)))] if tx is not None and ty is not None else []
self._emit_challenge(KIND_SLIDER, "拖动下方滑块完成拼图", bg, piece)
if points:
self._emit_solve(KIND_SLIDER, bg, points, piece, _describe(result))
def _emit_challenge(self, kind: str, instruction: str, bg: Any, prompt: Any) -> None:
self._challenge_no += 1
n = self._challenge_no
data: Dict[str, Any] = {"index": n, "kind": kind, "instruction": instruction}
data["bg_url"] = self._safe_image("c%d_bg.jpg" % n, bg)
if prompt is not None:
data["prompt_url"] = self._safe_image("c%d_prompt.jpg" % n, prompt)
self.events.append(EVT_CHALLENGE, **data)
def _emit_solve(self, kind: str, bg: Any, points, prompt: Any, note: str) -> None:
"""`off_px` **只对点选题**(图标/文字)有意义 —— 它量的是"点击点离该处
暗色笔画质心多远"。滑块的答案是**缺口位置**(那里通常没有笔画),
硬套这个量尺会给出十几到几十 px 的噪声,所以滑块一律置 None。
"""
n = self._challenge_no
measurable = kind in (KIND_ICON_CLICK, KIND_TEXT_CLICK)
offsets: List[Optional[float]] = []
centroids: List[Optional[List[float]]] = []
for (x, y) in points:
est = icon_centroid_offset(bg, x, y) if measurable else None
offsets.append(round(est[2], 1) if est else None)
centroids.append([round(est[0], 1), round(est[1], 1)] if est else None)
data: Dict[str, Any] = {"index": n, "kind": kind, "points": [list(p) for p in points],
"off_px": offsets, "centroids": centroids, "note": note}
try:
data["annotated_url"] = self.add_image(
"c%d_annotated.jpg" % n,
annotate_points(bg, points, prompt=prompt, offsets=offsets,
centroids=centroids, title="第 %d 题" % n))
except Exception: # noqa: BLE001
pass
self._solves.append(data)
self.events.append(EVT_SOLVE, **data)
def _safe_image(self, name: str, image: Any) -> str:
try:
return self.add_image(name, to_jpeg(image))
except Exception: # noqa: BLE001
return ""
# ------------------------------------------------------------- 遥测 hook
def _wrap_telemetry(self, original: Callable) -> Callable:
def wrapper(tdc_code, points, **kwargs):
out = original(tdc_code, points, **kwargs)
try:
self._capture_telemetry(out)
except Exception as exc: # noqa: BLE001 解析失败不能影响发出去的包
self.events.append(EVT_LOG, level="WARNING", logger="web.runner",
message="解析遥测段几何失败: %s" % exc)
return out
return wrapper
def _capture_telemetry(self, out: Dict[str, Any]) -> None:
"""解出「4 段几何 + 轨迹点数」—— 判断包是否**按本次会话构建**正确生成的关键指标。"""
info: Dict[str, Any] = {"source": out.get("source"),
"collect_len": len(out.get("collect") or "")}
try:
from tcaptcha.telemetry.crypto import split_collect_segments, xtea_decrypt_bytes
plain = xtea_decrypt_bytes(base64.b64decode(out["collect"]),
out["key"]).decode("latin1").rstrip("\x00")
segs = split_collect_segments(plain)
info["segments"] = [len(s) for s in segs]
info["traj_points"] = len(json.loads(segs[1]))
except Exception as exc: # noqa: BLE001
info["segments_err"] = str(exc)[:160]
self.events.append(EVT_TELEMETRY, **info)
def _describe(result: Any) -> str:
"""取求解器自带的 describe()(各 solver 都有,但签名不同,统一兜住)。"""
try:
return str(result.describe())
except Exception: # noqa: BLE001
return ""
class JobManager:
"""任务注册表 + **全局串行**调度 + 按靶子节流。"""
def __init__(self, max_jobs: int = 50) -> None:
self._jobs: Dict[str, SolveJob] = {}
self._order: List[str] = []
self._lock = threading.Lock()
self._serial = threading.Lock() # 同一时刻只跑一个求解(ONNX 非线程安全)
self._last_end: Dict[str, float] = {}
self.max_jobs = max_jobs
def submit(self, target_key: str, params: Dict[str, Any]) -> SolveJob:
get_target(target_key) # 未知靶子直接抛错
job_id = uuid.uuid4().hex[:12]
job = SolveJob(job_id, target_key, params,
throttle=self._throttle, on_finish=self._on_finish)
with self._lock:
self._jobs[job_id] = job
self._order.append(job_id)
while len(self._order) > self.max_jobs:
self._jobs.pop(self._order.pop(0), None)
threading.Thread(target=self._worker, args=(job,),
name="solve-%s" % job_id, daemon=True).start()
return job
def get(self, job_id: str) -> Optional[SolveJob]:
with self._lock:
return self._jobs.get(job_id)
def list_jobs(self, limit: int = 30) -> List[Dict[str, Any]]:
with self._lock:
ids = list(self._order)[-limit:]
jobs = [self._jobs[i] for i in ids if i in self._jobs]
return [j.snapshot() for j in reversed(jobs)]
# ------------------------------------------------------------ 内部调度
def _worker(self, job: SolveJob) -> None:
if not self._serial.acquire(timeout=0.1):
job.events.append(EVT_LOG, level="INFO", logger="web.runner",
message="排队中:同一时刻只跑一个求解(ONNX 非线程安全),"
"前面的任务结束后自动开始")
self._serial.acquire()
try:
job.run()
finally:
self._serial.release()
def _throttle(self, job: SolveJob) -> float:
"""按靶子**实测**的最小间隔节流(1ms=60s,mliev=3s)。返回实际等待秒数。"""
tgt = get_target(job.target_key)
with self._lock:
last = self._last_end.get(job.target_key)
if last is None:
return 0.0
need = float(tgt["min_interval"]) - (time.time() - last)
if need <= 0:
return 0.0
job.state = "throttled"
job.events.append(EVT_LOG, level="INFO", logger="web.runner",
message="频控节流:%s 实测最小间隔 %.0fs,还需等待 %.1fs"
"(连打会被判 errorCode=12,不是求解失败)"
% (job.target_key, tgt["min_interval"], need))
waited = 0.0
while waited < need:
if job.cancelled:
return waited
step = min(1.0, need - waited)
time.sleep(step)
waited += step
return waited
def _on_finish(self, job: SolveJob) -> None:
with self._lock:
self._last_end[job.target_key] = time.time()
def cooldown(self) -> Dict[str, float]:
"""每个靶子还需等多少秒才能再打(给界面显示倒计时)。"""
now = time.time()
out: Dict[str, float] = {}
with self._lock:
for key, last in self._last_end.items():
try:
need = float(get_target(key)["min_interval"]) - (now - last)
except KeyError:
need = 0.0
out[key] = round(max(0.0, need), 1)
return out
__all__ = ["SolveJob", "JobManager", "KIND_MAP", "EVT_TELEMETRY"]