feat(web): 靶子显示友好名 + 支持连打次数

- targets.py 增加 label 字段(1ms 签到 / mliev 登录),下拉框、说明、历史表统一用友好名
- POST /api/jobs 支持 count(1~20):一次任务连打 N 发,发间自动按靶子频控节流、可取消
- 每发结果记入 stats.rounds,EVT_VERIFY 带 round,结果区展示「每发结果(通过 x/y)」彩色一览
- 滑块不再生成标注图(红圈+序号只表达点击顺序,对滑块是误导)
- 观测台 README 补齐启动/后台运行/端口占用/自检说明
This commit is contained in:
yml2213
2026-10-15 11:20:24 +08:00
parent 99565f65f0
commit 5c4ff8c4a1
8 changed files with 534 additions and 222 deletions
+59 -26
View File
@@ -158,6 +158,8 @@ class SolveJob:
self._cancel = threading.Event()
self._challenge_no = 0
self._solves: List[Dict[str, Any]] = []
self._rounds: List[Dict[str, Any]] = [] # 每一发的结果(count>1 时)
self._count = 1
self._images: Dict[str, bytes] = {}
self._img_lock = threading.Lock()
@@ -188,11 +190,17 @@ class SolveJob:
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))]
ews = [r["errorCode"] for r in self._rounds if r.get("errorCode") is not None]
ok_n = sum(1 for r in self._rounds if r.get("success"))
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,
"count": self._count,
"rounds": list(self._rounds),
"ok": ok_n,
"errorCodes": ews,
}
def snapshot(self) -> Dict[str, Any]:
@@ -227,21 +235,12 @@ class SolveJob:
def _execute(self) -> None:
tgt = get_target(self.target_key)
kind = str(self.params.get("kind") or "icon")
self._count = max(1, min(20, int(self.params.get("count") or 1)))
self.events.append(EVT_STARTED, target=self.target_key, aid=tgt["aid"],
kind=kind, mode="python_values",
label=tgt.get("label", self.target_key),
kind=kind, mode="python_values", count=self._count,
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")]
@@ -250,12 +249,35 @@ class SolveJob:
lg.setLevel(logging.INFO)
lg.addHandler(bridge)
try:
with _Hooks(self):
res = self._call_client(tgt, kind)
for i in range(1, self._count + 1):
if self.cancelled:
break
if i > 1:
self.events.append(EVT_LOG, level="INFO", logger="web.runner",
message="—— 第 %d/%d 发 ——" % (i, self._count))
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:
break
with _Hooks(self):
res = self._call_client(tgt, kind)
self._finalize(res, round_no=i)
finally:
for lg in loggers:
lg.removeHandler(bridge)
self._finalize(res)
if self.cancelled:
self.state = "cancelled"
self.events.append(EVT_FINISHED, success=any(r.get("success") for r in self._rounds),
errorCode=self._rounds[-1]["errorCode"] if self._rounds else None,
ticket=self._rounds[-1].get("ticket") or "",
elapsed_ms=self._rounds[-1].get("elapsed_ms"),
error=self.error or ("已取消(完成 %d/%d 发)" % (len(self._rounds), self._count)
if self.cancelled else ""),
stats=self.stats())
def _emit_log(self, level: str, name: str, message: str) -> None:
self.events.append(EVT_LOG, level=level, logger=name, message=message)
@@ -278,7 +300,7 @@ class SolveJob:
except Exception: # noqa: BLE001
pass
def _finalize(self, res: Dict[str, Any]) -> None:
def _finalize(self, res: Dict[str, Any], round_no: int = 1) -> None:
raw = res.get("raw_response") or {}
ec = raw.get("errorCode")
self.result = {
@@ -293,13 +315,19 @@ class SolveJob:
}
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 "",
self._rounds.append({"round": round_no, "success": bool(res.get("success")),
"errorCode": ec, "kind": res.get("kind") or "",
"ticket": res.get("ticket") or "",
"elapsed_ms": res.get("elapsed_ms"),
"error": res.get("error") or ""})
self.events.append(EVT_VERIFY, round=round_no, 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())
self.events.append(EVT_LOG, level="INFO", logger="web.runner",
message="第 %d/%d 发结束:errorCode=%s %s"
% (round_no, self._count, ec,
"✅" if res.get("success") else (self.error or "未通过")))
# --------------------------------------------------------- 求解器 hook
def _wrap_solver(self, key: str, original: Callable) -> Callable:
@@ -369,10 +397,15 @@ class SolveJob:
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))
# title 不画(卡片标题里已有),提示图不贴(界面上是独立缩略图,
# 贴进去会遮挡右上角的目标图标)—— 详见 render.annotate_points。
# 标注图只给点选题画:红圈+序号表达的是"点击顺序",滑块答案是
# 缺口位置,画上去只会误导(界面上底图+滑块图已足够核对)。
if measurable:
data["annotated_url"] = self.add_image(
"c%d_annotated.jpg" % n,
annotate_points(bg, points, offsets=offsets, centroids=centroids,
embed_prompt=False))
except Exception: # noqa: BLE001
pass
self._solves.append(data)