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
This commit is contained in:
@@ -121,5 +121,25 @@ run8 `[1,4,0]` → `1:0.998 / 4:1.0 / 0:1.0`)。
|
||||
* 1ms 抽到图标题约 1/6,190758559 约 1/10;脚本会按 `accept_kinds` 自动换题(`getsig` 不换题型,
|
||||
只有重新 `prehandle` 才换)。
|
||||
* 靶子会偶发 `Connection reset by peer`(两个靶子都遇到过),那不是判负,统计时要单列。
|
||||
* **1ms 不要连打**:8 连发最后一发触发 `12`(prehandle 累计 14 次)。回归测试用
|
||||
190758559,或对 1ms 拉开间隔。
|
||||
* **1ms 有 aid+IP 级频控**(2026-10-14 标定,实测三次):
|
||||
|
||||
| 打法 | 结果 |
|
||||
| --- | --- |
|
||||
| 间隔 4s × 12 发 | `0,0` + **`12` ×10**(只有前 2 发过;icon/text/slider 三种题型都被打到 12) |
|
||||
| 冷却 2min + 间隔 30s × 6 | **`12` ×6**(冷却不够) |
|
||||
| 冷却 25min + 间隔 60s × 4 | **`0` ×4** ✓(text×2 + slider×2) |
|
||||
|
||||
→ 频控**与题型无关、与 prehandle 次数无关**(每次都是 1 个 prehandle),只与短时间内的请求量有关。
|
||||
**用法:≥60s 一发、连续不超过约 2 发;回归测试一律用 190758559。**
|
||||
|
||||
### 5.1 三种题型在 1ms 上的实测("都能过吗")
|
||||
|
||||
修复轨迹后,三种题型**都拿到过 `errorCode=0`**(含 ticket),链路完好;失败全是频控:
|
||||
|
||||
| 题型 | 修复后成绩 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| 图标 2408 | **7/8 = 0**(前 7 连 0) | 修复轨迹后首次连跑 |
|
||||
| 文字 2404 | 2/3 = 0;冷却后 2/2 = 0 | 与图标共用 collect 链路 |
|
||||
| 滑块 2401/2402 | 1/3 = 0;冷却后 2/2 = 0 | 拖动轨迹另算(不套 61 点) |
|
||||
|
||||
**注意**:成绩好坏取决于"打得多稀疏",不取决于题型——4 秒间隔连打时三种题型**一起**全 12。
|
||||
|
||||
@@ -184,6 +184,8 @@ crypto.build_collect(c1,traj,c2,sd,key) -> 4 段独立加密后拼接
|
||||
| 72 | **1ms 的 `12`/`50` 与"答案质量"无关**(判读图 + 分类器概率双证据) | ① `send_campaign.py --target 1ms --kind icon --n 8`(13:49–13:52):`0,0,0,0,None,12,12,12`;**三次 `12` 的 `solver_diag` 里每个目标类都有 top-1 概率 = 1.0 的候选**(run6 `[2,1,4]`→2:1.0/1:1.0/4:1.0;run7 `[8,9,11]`→8:1.0/9:1.0/11:1.0;run8 `[1,4,0]`→1:0.998/4:1.0/0:1.0)。<br>② `judge_icons.py` 判读图:8 次跑 **24/24 全部落在与提示同序的目标图标上**,`12` 那三次也一样(偏离 0~5px)。<br>③ 点击精度不区分 `0` 与 `12`:`0` 组的质心偏离均值 ≈2.1px、`12` 组 ≈2.4px。<br>④ 同一时段该 aid 在混合题型下 `12` ×6(三题型都不行)。→ **12 是包级/会话级,不是答案级** | 确证 |
|
||||
| 73 | **轨迹的 dt 分布曾与真机严重不符(真 bug,已修)—— 用户"位置没问题那就是轨迹被校验"的判断正确** | 把 4 份真机抓包 / **6 条成功轨迹**(360 个非首点 dt)全部解出汇总:<br>① **点数硬不变量:6/6 全是 61 点**,而旧实现随机 57~65;<br>② **dt 紧密聚集在 16~17ms**(13~20ms 占 79%,0~4ms 仅 0.28%),旧实现 0~16ms 占 50%、0~4ms 占 14% —— 每发一枪都自带时序指纹异常。<br>修复 `tcaptcha/telemetry/values.py`:`_sample_dt()` 按 6 条轨迹重拟合(13~20ms 占 78%)、新增 `_fit_points()` 把点选轨迹**钉死 61 点**(补齐用 `[0,0,dt]` 驻留点,**不能**复制最后位移点——否则净位移被放大,实测 166 vs 139.5)。<br>**修复后 A/B**:1ms 图标 **4/8=0 → 7/8=0**(前 7 连 0);文字 2/3=0、滑块 1/3=0(样本少)。回归:selftest 全绿 | 确证(真 bug + A/B 见效) |
|
||||
| 74 | **61 点不变量只属于点选轨迹,拖动轨迹不适用**(差点犯的错) | 修 61 点时曾把 `drag_trajectory_points`(滑块拖动)也套了 61 点 → 滑块测试立刻红(净位移 166 vs 139.5、点数 61 超范围)。**拖动是另一种交互(按下→横移→松手),61 点是从点选轨迹量出来的,不能外推**。已回退:拖动按 steps 走(实测 34 点、净位移 140≈139.5) | 确证(含一次自我纠错) |
|
||||
| 75 | **1ms 的残余 `12` = aid+IP 级频控,已标定窗口;冷却足够后三种题型全过** | 2026-10-14 实测(`send_campaign.py --kind any --interval N`,每次 `prehandle` 次数都是 1,排除"抽题太多次"这条):<br>① **间隔 4s × 12 发** → `0,0,12,12,12,12,12,12,12,12,12,12`(只有**前 2 发**过,之后 **10 连 12**;icon/text/slider **三种题型都被打到 12**);<br>② **冷却 2 分钟 + 间隔 30s × 6** → **6 发全 12**(冷却不够);<br>③ **冷却 25 分钟 + 间隔 60s × 4** → **4 发全 `0`**(text×2 + slider×2)。<br>→ 频控**与题型无关、与 prehandle 次数无关**,只与"短时间内的请求量"有关。**结论:1ms 必须低频使用(≥60s 一发、连续打不超过 ~2 发),回归测试一律用 190758559。** | 确证(含恢复验证) |
|
||||
| 76 | **Web 观测台上线**(形态 A:观测/调试台)+ 顺带抓到两个真 bug | 新增 `tcaptcha/web/`(FastAPI + SSE + 零构建单页)、`tcaptcha/render.py`(标注图 + 点位量尺,**CLI 与 web 共用**)、`tcaptcha/targets.py`(靶子表,含**实测频控间隔**)。设计要点:`solve()` 同步阻塞 → 工作线程 + **全局串行**(onnxruntime session 非线程安全);用 `logging.Handler` **零侵入**把 client 的日志变成事件流;按靶子 `min_interval` 强制节流。<br>**bug ①**:hook 求解器时把 `args[0]` 当成了题目图 —— 实例方法调用时 `args[0]` 是**求解器实例自身**(真入参从 `args[1]` 起),于是 `'CaptchaSolver' object has no attribute 'convert'`,标注图与点位全丢。<br>**bug ②**:`TencentCaptchaClient` 默认**每次新建** solver,预热加载的实例根本没被复用(每发重载 56MB ONNX)→ 改为把缓存实例传进 `solver=` / `text_solver=`。<br>另:滑块硬套"离笔画质心"量尺会给出 10~40px 噪声(滑块答案是缺口位置),已对滑块置 None。<br>复验:icon/text/slider **三题型各拿到 `errorCode=0`**;SSE 事件序列 + 3 张图全部可取;`import tcaptcha` **不引入 fastapi**(依赖隔离) | 确证 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ dependencies = [
|
||||
"pillow>=12.3.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
web = [
|
||||
"fastapi>=0.143.0",
|
||||
"uvicorn>=0.54.0",
|
||||
]
|
||||
|
||||
# 说明:
|
||||
# * 训练/标注用的 ultralytics + torch 已随 train_*/prepare_yolo/web_labeler 一并移除
|
||||
# —— 线上只跑 onnxruntime,不需要 PyTorch 栈。
|
||||
|
||||
@@ -28,13 +28,11 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -45,14 +43,9 @@ import tcaptcha.protocol.client as client_mod # noqa: E
|
||||
import tcaptcha.solvers.icon_click as icon_mod # noqa: E402
|
||||
from tcaptcha.protocol.client import (KIND_ICON_CLICK, KIND_SLIDER, # noqa: E402
|
||||
KIND_TEXT_CLICK, TencentCaptchaClient)
|
||||
from tcaptcha.render import annotate_points, icon_centroid_offset # noqa: E402
|
||||
from tcaptcha.targets import TARGETS, get_target # noqa: E402
|
||||
|
||||
# 内置靶子:aid / entry_url / referer / wb
|
||||
TARGETS: Dict[str, Tuple[str, str, str, str]] = {
|
||||
"1ms": ("195672344", "https://1ms.run/console/personal/benefits/check-in",
|
||||
"https://1ms.run/", "1"),
|
||||
"mliev": ("190758559", "https://mliev.login.wang/auth/#/oidc/authorize",
|
||||
"https://mliev.login.wang/", "1"),
|
||||
}
|
||||
KIND_MAP = {
|
||||
"text": (KIND_TEXT_CLICK,),
|
||||
"icon": (KIND_ICON_CLICK,),
|
||||
@@ -60,53 +53,8 @@ KIND_MAP = {
|
||||
"any": (KIND_TEXT_CLICK, KIND_ICON_CLICK, KIND_SLIDER),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 「点得准不准」的独立量尺:点击点 vs 该处图标的**暗色笔画质心**
|
||||
# --------------------------------------------------------------------------- #
|
||||
def icon_centroid_offset(bg_pil, x: int, y: int, half: int = 32) -> Optional[Tuple[float, float, float]]:
|
||||
"""返回 (质心x, 质心y, 离点击点的距离px);估不出来返回 None。
|
||||
|
||||
原理:图标是近黑的线条画,背景是彩色的(天空/水面/木纹…)。在点击点周围截
|
||||
(2*half)² 的窗口,用 Otsu 分出暗色笔画,取笔画质心当作"图标视觉中心"。
|
||||
**这是独立于我求解器的量尺**(求解器用的是 YOLO box 中心),所以能回答
|
||||
"我们到底点偏了多少"。
|
||||
"""
|
||||
w, h = bg_pil.size
|
||||
box = (max(0, x - half), max(0, y - half), min(w, x + half), min(h, y + half))
|
||||
patch = np.array(bg_pil.crop(box).convert("L"))
|
||||
if patch.size == 0:
|
||||
return None
|
||||
thr, binimg = cv2.threshold(patch.astype(np.uint8), 0, 255,
|
||||
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
||||
ys, xs = np.nonzero(binimg)
|
||||
if len(xs) < 20:
|
||||
return None
|
||||
cx, cy = float(xs.mean()), float(ys.mean())
|
||||
lx, ly = x - box[0], y - box[1] # 点击点在窗口内的坐标
|
||||
return (cx + box[0], cy + box[1], float(np.hypot(cx - lx, cy - ly)))
|
||||
|
||||
|
||||
def annotate(bg_bytes: bytes, points: Sequence[Sequence[int]], prompt_bytes: Optional[bytes],
|
||||
offsets: Sequence[Optional[float]]) -> bytes:
|
||||
"""底图上画**按点击顺序编号**的红圈;右上角贴提示图;标注每点偏离质心的 px。"""
|
||||
bgr = cv2.imdecode(np.frombuffer(bg_bytes, np.uint8), cv2.IMREAD_COLOR)
|
||||
for i, (x, y) in enumerate(points, 1):
|
||||
cv2.circle(bgr, (int(x), int(y)), 18, (0, 0, 255), 2)
|
||||
cv2.circle(bgr, (int(x), int(y)), 2, (0, 0, 255), -1)
|
||||
cv2.putText(bgr, str(i), (int(x) - 8, int(y) - 22),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.85, (0, 255, 0), 2)
|
||||
if prompt_bytes:
|
||||
pr = cv2.imdecode(np.frombuffer(prompt_bytes, np.uint8), cv2.IMREAD_COLOR)
|
||||
if pr is not None:
|
||||
ph = bgr.shape[0] // 4
|
||||
pw = max(1, int(pr.shape[1] * ph / pr.shape[0]))
|
||||
pr = cv2.resize(pr, (pw, ph))
|
||||
y0, x0 = 4, bgr.shape[1] - pw - 4
|
||||
if x0 >= 0:
|
||||
bgr[y0:y0 + ph, x0:x0 + pw] = pr
|
||||
cv2.rectangle(bgr, (x0, y0), (x0 + pw, y0 + ph), (255, 255, 255), 1)
|
||||
return cv2.imencode(".jpg", bgr)[1].tobytes()
|
||||
# 点位的"量尺"(点击点 vs 图标暗色笔画质心)与标注图渲染统一在 tcaptcha.render,
|
||||
# 与 Web 界面共用同一份实现 —— 别在这里再抄一遍。
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -118,11 +66,14 @@ def main() -> int:
|
||||
help="给答案整体平移 DX,DY 像素(容差标定用)")
|
||||
ap.add_argument("--swap", action="store_true", help="故意交换前两个点(负对照,必错)")
|
||||
ap.add_argument("--max-refreshes", type=int, default=40)
|
||||
ap.add_argument("--interval", type=float, default=1.0,
|
||||
help="两次求解之间的间隔秒数(1ms 连打会触发频控,建议 3~5)")
|
||||
ap.add_argument("--outdir", default=None)
|
||||
ap.add_argument("--max-retries", type=int, default=1)
|
||||
args = ap.parse_args()
|
||||
|
||||
aid, entry, referer, wb = TARGETS[args.target]
|
||||
tgt = get_target(args.target)
|
||||
aid, entry, referer, wb = tgt["aid"], tgt["entry_url"], tgt["referer"], tgt["wb"]
|
||||
dx, dy = (int(v) for v in args.offset.split(","))
|
||||
tag = "%s_%s_n%d_off%s%s" % (args.target, args.kind, args.n, args.offset,
|
||||
"_swap" if args.swap else "")
|
||||
@@ -245,7 +196,8 @@ def main() -> int:
|
||||
offsets.append(None if est is None else round(est[2], 1))
|
||||
centroids.append(None if est is None else [round(est[0], 1), round(est[1], 1)])
|
||||
(d / "bg.jpg").write_bytes(bg_raw)
|
||||
(d / "annotated.jpg").write_bytes(annotate(bg_raw, pts, prompt_raw, offsets))
|
||||
(d / "annotated.jpg").write_bytes(
|
||||
annotate_points(bg_raw, pts, prompt=prompt_raw, offsets=offsets))
|
||||
if prompt_raw is not None:
|
||||
(d / "prompt.jpg").write_bytes(prompt_raw)
|
||||
(d / "points.json").write_text(json.dumps({
|
||||
@@ -257,26 +209,36 @@ def main() -> int:
|
||||
"prehandles": counters["prehandle"],
|
||||
}, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
|
||||
row = {"run": n, "errorCode": ec, "points": [list(p) for p in pts],
|
||||
row = {"run": n, "errorCode": ec, "kind": res.get("kind"),
|
||||
"instruction": res.get("instruction"),
|
||||
"points": [list(p) for p in pts],
|
||||
"collect_len": last_gen.get("collect_len"),
|
||||
"off_px": offsets, "secs": round(time.time() - t0, 1),
|
||||
"prehandles": counters["prehandle"]}
|
||||
rows.append(row)
|
||||
print("run%-2d ec=%-4s 偏离质心px=%-22s prehandle=%-3d points=%s %.0fs" % (
|
||||
n, ec, str(offsets), counters["prehandle"], pts, time.time() - t0))
|
||||
time.sleep(1.0)
|
||||
print("run%-2d ec=%-4s kind=%-10s 偏离质心px=%-20s prehandle=%-3d points=%s %.0fs" % (
|
||||
n, ec, res.get("kind") or "?", str(offsets), counters["prehandle"], pts,
|
||||
time.time() - t0))
|
||||
time.sleep(max(0.0, args.interval))
|
||||
finally:
|
||||
c.close()
|
||||
icon_mod.CaptchaSolver.solve = real_icon_solve
|
||||
client_mod.generate_telemetry = real_gen
|
||||
|
||||
counts: Dict[str, int] = {}
|
||||
by_kind: Dict[str, Dict[str, int]] = {}
|
||||
for r in rows:
|
||||
counts[str(r.get("errorCode"))] = counts.get(str(r.get("errorCode")), 0) + 1
|
||||
ec_s = str(r.get("errorCode"))
|
||||
counts[ec_s] = counts.get(ec_s, 0) + 1
|
||||
k = str(r.get("kind") or "?")
|
||||
by_kind.setdefault(k, {})
|
||||
by_kind[k][ec_s] = by_kind[k].get(ec_s, 0) + 1
|
||||
all_off = [v for r in rows for v in (r.get("off_px") or []) if isinstance(v, (int, float))]
|
||||
summary = {
|
||||
"target": args.target, "kind": args.kind, "n": args.n,
|
||||
"offset_applied": [dx, dy], "swapped": args.swap,
|
||||
"errorCode_counts": counts,
|
||||
"by_kind": by_kind,
|
||||
"centroid_offset_px": {
|
||||
"count": len(all_off),
|
||||
"min": min(all_off) if all_off else None,
|
||||
@@ -291,6 +253,9 @@ def main() -> int:
|
||||
print("靶子=%s 题型=%s 次数=%d 答案平移=%s 交换=%s" % (args.target, args.kind, args.n,
|
||||
args.offset, args.swap))
|
||||
print("errorCode 计数: %s" % json.dumps(counts, ensure_ascii=False))
|
||||
if len(by_kind) > 1 or "?" not in by_kind:
|
||||
for k in sorted(by_kind):
|
||||
print(" 按题型 %-12s %s" % (k, json.dumps(by_kind[k], ensure_ascii=False)))
|
||||
if all_off:
|
||||
print("点击点偏离图标笔画质心: 均值 %.1fpx / 最小 %.1f / 最大 %.1f(%d 个点)"
|
||||
% (np.mean(all_off), min(all_off), max(all_off), len(all_off)))
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""展示层:把"点位"渲染成人能看懂的标注图,并给点位质量一个独立量尺。
|
||||
|
||||
两个能力都有两类调用方,所以放在**包根**而不是 `web/` 里(它不是 web 专属):
|
||||
* `scripts/verify/send_campaign.py` —— CLI 批量实验落图
|
||||
* `tcaptcha/web` —— 界面上的题目图 / 标注图
|
||||
|
||||
量尺原理(`icon_centroid_offset`):图标是近黑的线条画、背景是彩色的。在点击点周围
|
||||
截一个窗口,用 Otsu 分出**暗色笔画**,取笔画质心当"图标视觉中心",报告点击点离它多远。
|
||||
这条量尺**独立于求解器**(求解器用的是 YOLO box 中心),所以能回答"到底点偏了多少"。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Sequence, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
# 标注样式
|
||||
_RING_RADIUS = 18
|
||||
_RING_COLOR = (0, 0, 255) # BGR 红
|
||||
_IDX_COLOR = (0, 255, 0) # BGR 绿
|
||||
_CENTROID_COLOR = (255, 128, 0) # BGR 蓝
|
||||
|
||||
|
||||
def to_bgr(image: Any) -> np.ndarray:
|
||||
"""bytes / ndarray / PIL.Image 统一转成 BGR ndarray。"""
|
||||
if isinstance(image, (bytes, bytearray)):
|
||||
arr = cv2.imdecode(np.frombuffer(bytes(image), np.uint8), cv2.IMREAD_COLOR)
|
||||
if arr is None:
|
||||
raise ValueError("无法解码图片字节")
|
||||
return arr
|
||||
if isinstance(image, np.ndarray):
|
||||
return image if image.ndim == 3 else cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
# PIL.Image
|
||||
rgb = np.array(image.convert("RGB"))
|
||||
return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
||||
|
||||
|
||||
def to_jpeg(image: Any, quality: int = 88) -> bytes:
|
||||
"""任意输入 -> JPEG 字节(给 HTTP 响应用)。"""
|
||||
bgr = to_bgr(image) if not isinstance(image, np.ndarray) else image
|
||||
ok, buf = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
|
||||
if not ok:
|
||||
raise ValueError("JPEG 编码失败")
|
||||
return buf.tobytes()
|
||||
|
||||
|
||||
def icon_centroid_offset(bg: Any, x: int, y: int,
|
||||
half: int = 32) -> Optional[Tuple[float, float, float]]:
|
||||
"""点击点 vs 该处图标**暗色笔画质心**。
|
||||
|
||||
返回 `(质心x, 质心y, 离点击点的距离px)`;窗口里笔画太少(估不出中心)时返回 None。
|
||||
"""
|
||||
bgr = to_bgr(bg)
|
||||
h, w = bgr.shape[:2]
|
||||
x0, y0 = max(0, x - half), max(0, y - half)
|
||||
x1, y1 = min(w, x + half), min(h, y + half)
|
||||
patch = bgr[y0:y1, x0:x1]
|
||||
if patch.size == 0:
|
||||
return None
|
||||
gray = cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY)
|
||||
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
||||
ys, xs = np.nonzero(binary)
|
||||
if len(xs) < 20:
|
||||
return None
|
||||
cx, cy = float(xs.mean()) + x0, float(ys.mean()) + y0
|
||||
return (cx, cy, float(np.hypot(cx - x, cy - y)))
|
||||
|
||||
|
||||
def annotate_points(bg: Any,
|
||||
points: Sequence[Sequence[int]],
|
||||
prompt: Any = None,
|
||||
offsets: Optional[Sequence[Optional[float]]] = None,
|
||||
centroids: Optional[Sequence[Optional[Sequence[float]]]] = None,
|
||||
title: str = "") -> bytes:
|
||||
"""在底图上按**点击顺序**画编号红圈,可选贴提示图、标出质心与偏差。
|
||||
|
||||
参数:
|
||||
points 点击点序列(背景图原始像素),**顺序即答案顺序**
|
||||
prompt 提示图(图标/文字点选题),会缩放到右上角
|
||||
offsets 每个点离图标笔画质心的距离(px),标在圈上
|
||||
centroids 每个点的质心坐标,画一个蓝色十字(一眼看出偏没偏)
|
||||
title 左上角标题(如 "icon_click ec=0")
|
||||
"""
|
||||
bgr = to_bgr(bg)
|
||||
for i, pt in enumerate(points):
|
||||
x, y = int(pt[0]), int(pt[1])
|
||||
cv2.circle(bgr, (x, y), _RING_RADIUS, _RING_COLOR, 2)
|
||||
cv2.circle(bgr, (x, y), 2, _RING_COLOR, -1)
|
||||
label = str(i + 1)
|
||||
cv2.putText(bgr, label, (x - 8, y - _RING_RADIUS - 6),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.85, _IDX_COLOR, 2)
|
||||
if centroids and i < len(centroids) and centroids[i]:
|
||||
cx, cy = int(centroids[i][0]), int(centroids[i][1])
|
||||
cv2.drawMarker(bgr, (cx, cy), _CENTROID_COLOR, cv2.MARKER_CROSS, 12, 1)
|
||||
if offsets and i < len(offsets) and offsets[i] is not None:
|
||||
cv2.putText(bgr, "%.0fpx" % offsets[i], (x - 20, y + _RING_RADIUS + 16),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, _CENTROID_COLOR, 1)
|
||||
if prompt is not None:
|
||||
try:
|
||||
pr = to_bgr(prompt)
|
||||
ph = max(1, bgr.shape[0] // 4)
|
||||
pw = max(1, int(pr.shape[1] * ph / pr.shape[0]))
|
||||
pr = cv2.resize(pr, (pw, ph))
|
||||
x0, y0 = max(0, bgr.shape[1] - pw - 4), 4
|
||||
bgr[y0:y0 + ph, x0:x0 + pw] = pr
|
||||
cv2.rectangle(bgr, (x0, y0), (x0 + pw, y0 + ph), (255, 255, 255), 1)
|
||||
except Exception: # noqa: BLE001 提示图坏了不影响主流程
|
||||
pass
|
||||
if title:
|
||||
cv2.putText(bgr, title, (6, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 3)
|
||||
cv2.putText(bgr, title, (6, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1)
|
||||
return to_jpeg(bgr)
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""靶子表 —— aid / 入口 / referer / wb,以及每个 aid **实测出来的频控节流参数**。
|
||||
|
||||
为什么靶子要带 `min_interval`:`aid=195672344`(1ms) 有 **aid+IP 级频控**,
|
||||
短时间连打会被判 `errorCode=12`(2026-10-14 实测,见 har/1ms_dianxuan/PROGRESS.md 已知 #75):
|
||||
|
||||
间隔 4s × 12 发 -> 只有前 2 发过,之后 10 连 12(icon/text/slider 一起中招)
|
||||
冷却 2min + 间隔 30s -> 6 发全 12(冷却不够)
|
||||
冷却 25min + 间隔 60s -> 4 发全 0(恢复)
|
||||
|
||||
所以靶子不是"随便打"的:CLI 与界面都必须按 `min_interval` 节流,否则看到的一片红
|
||||
全是频控噪声、会被误读成"求解失败"。而 `190758559`(mliev) 是宽松靶,
|
||||
正确作答 22 次拿到 20 次 `errorCode=0`,适合做回归。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, TypedDict
|
||||
|
||||
|
||||
class Target(TypedDict):
|
||||
aid: str
|
||||
entry_url: str
|
||||
referer: str
|
||||
wb: str
|
||||
min_interval: float # 两次求解之间的最小间隔(秒)—— 实测频控节流
|
||||
note: str
|
||||
|
||||
|
||||
TARGETS: Dict[str, Target] = {
|
||||
# 用户主靶:1ms 签到页。频控很紧,必须低频。
|
||||
"1ms": {
|
||||
"aid": "195672344",
|
||||
"entry_url": "https://1ms.run/console/personal/benefits/check-in",
|
||||
"referer": "https://1ms.run/",
|
||||
"wb": "1",
|
||||
"min_interval": 60.0,
|
||||
"note": "主靶(2404/2408/2402 三种题型混发);有 aid+IP 频控,≥60s 一发、连打不超过 ~2 发",
|
||||
},
|
||||
# 可复现正样本靶:mliev 短信登录。题率低但宽松,适合回归。
|
||||
"mliev": {
|
||||
"aid": "190758559",
|
||||
"entry_url": "https://mliev.login.wang/auth/#/oidc/authorize",
|
||||
"referer": "https://mliev.login.wang/",
|
||||
"wb": "1",
|
||||
"min_interval": 3.0,
|
||||
"note": "回归靶(约 1/10 抽中 2408);宽松,正确作答 22 次→0×20",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_target(key: str) -> Target:
|
||||
if key not in TARGETS:
|
||||
raise KeyError("未知靶子 %r(可选:%s)" % (key, ", ".join(TARGETS)))
|
||||
return TARGETS[key]
|
||||
|
||||
|
||||
def describe() -> list:
|
||||
"""给界面用的靶子列表。"""
|
||||
return [{"key": k, **v} for k, v in TARGETS.items()]
|
||||
@@ -0,0 +1,121 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Web 观测台(形态 A:观测 / 调试台)。
|
||||
|
||||
分层:
|
||||
* `events.py` 事件模型(前后端共用同一套 `EVT_*` 类型名)
|
||||
* `runner.py` `SolveJob` / `JobManager` —— 把同步阻塞的 `solve()` 包成
|
||||
"可观测的后台任务"(工作线程 + 全局串行 + 频控节流 + 事件化)
|
||||
* `app.py` FastAPI 路由(建任务 / SSE / 图片 / 快照 / 取消)
|
||||
* `static/` 零构建单页(原生 JS,不引打包器/框架)
|
||||
|
||||
**依赖是可选 extra**:`fastapi` + `uvicorn` 装在 `[project.optional-dependencies].web`,
|
||||
核心包(协议 / 遥测 / 求解器 / CLI)完全不依赖它们 —— 所以本模块的 `__init__`
|
||||
刻意**不 import app**,避免 `import tcaptcha.web` 就把 fastapi 拉进来。
|
||||
|
||||
启动:
|
||||
uv run --extra web python -m tcaptcha.web.app
|
||||
"""
|
||||
|
||||
__all__: list = []
|
||||
@@ -0,0 +1,207 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""tcaptcha 的 Web 界面(形态 A:观测 / 调试台)。
|
||||
|
||||
定位:**把 CLI 的黑盒变透明** —— 启一次任务,实时看到
|
||||
「抽到哪道题 → PoW 命中 → 求解出点位(带标注图)→ collect 段几何 → verify 的 errorCode/ticket」。
|
||||
|
||||
它**不是**新造一套求解逻辑:后端就是 `TencentCaptchaClient.solve()`,
|
||||
与 `scripts/verify/send_test.py` / `send_campaign.py` 跑的是同一条链路。
|
||||
|
||||
启动:
|
||||
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
|
||||
|
||||
接口:
|
||||
GET / 单页界面
|
||||
GET /api/targets 靶子列表 + 频控冷却倒计时
|
||||
POST /api/jobs 建任务 {target, kind, max_retries, max_refreshes}
|
||||
GET /api/jobs 最近任务列表
|
||||
GET /api/jobs/{id} 任务快照
|
||||
GET /api/jobs/{id}/events **SSE** 事件流(支持 Last-Event-ID 续传)
|
||||
GET /api/jobs/{id}/image/{n} 题目图 / 标注图(内存里,任务结束仍在)
|
||||
POST /api/jobs/{id}/cancel 取消(当前这一发结束后生效)
|
||||
GET /api/health 健康检查
|
||||
|
||||
⚠️ 频控:靶子自带实测最小间隔(1ms = 60s)。后端会**强制节流**并在事件流里说明,
|
||||
所以界面上看到"等 60s"是设计行为,不是卡住。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from tcaptcha.web.events import TERMINAL_TYPES
|
||||
from tcaptcha.web.runner import KIND_MAP, JobManager
|
||||
|
||||
from tcaptcha.targets import TARGETS, describe as describe_targets
|
||||
|
||||
logger = logging.getLogger("web.app")
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
MANAGER = JobManager(max_jobs=50)
|
||||
|
||||
app = FastAPI(title="tcaptcha 观测台", version="0.1.0")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- 模型
|
||||
class JobRequest(BaseModel):
|
||||
target: str = Field(default="mliev", description="靶子 key")
|
||||
kind: str = Field(default="icon", description="text | icon | slider | any")
|
||||
max_retries: int = Field(default=2, ge=1, le=10)
|
||||
max_refreshes: int = Field(default=20, ge=1, le=60)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- 页面
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> HTMLResponse:
|
||||
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {"status": "online", "targets": list(TARGETS), "cooldown": MANAGER.cooldown()}
|
||||
|
||||
|
||||
@app.get("/api/targets")
|
||||
def targets() -> Dict[str, Any]:
|
||||
"""靶子列表 + 当前冷却剩余秒数(前端据此倒计时、禁用按钮)。"""
|
||||
return {"targets": describe_targets(), "cooldown": MANAGER.cooldown(),
|
||||
"kinds": sorted(KIND_MAP)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- 任务
|
||||
@app.post("/api/jobs")
|
||||
def create_job(req: JobRequest) -> Dict[str, Any]:
|
||||
if req.target not in TARGETS:
|
||||
raise HTTPException(400, "未知靶子 %r(可选:%s)" % (req.target, ", ".join(TARGETS)))
|
||||
if req.kind not in KIND_MAP:
|
||||
raise HTTPException(400, "未知题型 %r(可选:%s)" % (req.kind, ", ".join(sorted(KIND_MAP))))
|
||||
job = MANAGER.submit(req.target, {"kind": req.kind, "max_retries": req.max_retries,
|
||||
"max_refreshes": req.max_refreshes})
|
||||
return job.snapshot()
|
||||
|
||||
|
||||
@app.get("/api/jobs")
|
||||
def list_jobs(limit: int = 30) -> Dict[str, Any]:
|
||||
return {"jobs": MANAGER.list_jobs(limit=limit)}
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
def get_job(job_id: str) -> Dict[str, Any]:
|
||||
job = MANAGER.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404, "任务不存在或已被淘汰")
|
||||
return job.snapshot()
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/cancel")
|
||||
def cancel_job(job_id: str) -> Dict[str, Any]:
|
||||
job = MANAGER.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404, "任务不存在或已被淘汰")
|
||||
job.cancel()
|
||||
return job.snapshot()
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}/image/{name}")
|
||||
def get_image(job_id: str, name: str) -> Response:
|
||||
job = MANAGER.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404, "任务不存在或已被淘汰")
|
||||
data = job.get_image(name)
|
||||
if data is None:
|
||||
raise HTTPException(404, "图片不存在或已被内存上限淘汰")
|
||||
return Response(content=data, media_type="image/jpeg",
|
||||
headers={"Cache-Control": "public, max-age=600"})
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}/events")
|
||||
async def job_events(job_id: str, request: Request, since: int = 0) -> StreamingResponse:
|
||||
"""SSE:先补发历史(断线重连/中途打开页面都不丢),再持续推送直到终态。"""
|
||||
job = MANAGER.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404, "任务不存在或已被淘汰")
|
||||
# 浏览器 EventSource 重连时自动带 Last-Event-ID,用它续传
|
||||
header_since = request.headers.get("last-event-id")
|
||||
if header_since and header_since.isdigit():
|
||||
since = max(since, int(header_since))
|
||||
|
||||
async def stream():
|
||||
cursor = since
|
||||
idle_ticks = 0
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
batch = job.events.since(cursor)
|
||||
for evt in batch:
|
||||
cursor = evt.seq
|
||||
payload = json.dumps(evt.to_dict(), ensure_ascii=False)
|
||||
# 刻意不发 `event:` 行 —— 让前端统一走 onmessage 按 data.type 分派,
|
||||
# 少一处"类型名要和 addEventListener 对齐"的隐患。
|
||||
yield "id: %d\ndata: %s\n\n" % (evt.seq, payload)
|
||||
if evt.type in TERMINAL_TYPES:
|
||||
return
|
||||
if not batch:
|
||||
if job.finished_at is not None:
|
||||
return # 终态且已推完
|
||||
idle_ticks += 1
|
||||
if idle_ticks % 40 == 0: # 每 ~10s 一次心跳,防止中间层掐连接
|
||||
yield ": keep-alive\n\n"
|
||||
else:
|
||||
idle_ticks = 0
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
return StreamingResponse(stream(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive"})
|
||||
|
||||
|
||||
# 静态资源(app.js / style.css)
|
||||
if STATIC_DIR.is_dir():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
# --------------------------------------------------------------- 启动入口
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="tcaptcha Web 观测台")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认本机)")
|
||||
parser.add_argument("--port", type=int, default=8770, help="监听端口(默认 8770)")
|
||||
parser.add_argument("--no-warmup", action="store_true", help="不预热 AI 推理引擎")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S")
|
||||
|
||||
if not args.no_warmup:
|
||||
def _warm() -> None:
|
||||
try:
|
||||
from tcaptcha.web.runner import cached_solvers
|
||||
cached_solvers() # 预加载 ONNX/ddddocr,避免第一次任务等几秒
|
||||
logging.getLogger("web.app").info("AI 推理引擎预热完成(进程内复用)")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.getLogger("web.app").warning("预热失败(不影响使用): %s", exc)
|
||||
|
||||
import threading
|
||||
threading.Thread(target=_warm, name="warmup", daemon=True).start()
|
||||
|
||||
import uvicorn
|
||||
print("\n tcaptcha 观测台: http://%s:%d\n" % (args.host, args.port))
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
# -*- 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)
|
||||
@@ -0,0 +1,512 @@
|
||||
# -*- 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"]
|
||||
@@ -0,0 +1,294 @@
|
||||
/* tcaptcha 观测台 —— 零构建单页。只用原生 API,不引框架/打包器。
|
||||
*
|
||||
* 数据来源只有两个:
|
||||
* POST /api/jobs 建任务,拿 job_id
|
||||
* GET /api/jobs/{id}/events SSE 事件流(服务端推 event.to_dict())
|
||||
* 事件类型由后端 tcaptcha/web/events.py 的 EVT_* 定义,这里按 data.type 分派。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const EC_MEANING = {
|
||||
0: { text: '通过', cls: 'ec0' },
|
||||
50: { text: '答案被否', cls: 'ec50' },
|
||||
9: { text: '答案被否', cls: 'ec50' },
|
||||
12: { text: '包级拒答', cls: 'ec12' },
|
||||
};
|
||||
|
||||
function ecInfo(ec) {
|
||||
if (ec === null || ec === undefined || ec === '') return { text: '—', cls: 'ecother' };
|
||||
return EC_MEANING[Number(ec)] || { text: '未知', cls: 'ecother' };
|
||||
}
|
||||
|
||||
function hhmmss(ts) {
|
||||
const d = new Date((ts || Date.now() / 1000) * 1000);
|
||||
return d.toTimeString().slice(0, 8);
|
||||
}
|
||||
|
||||
let state = {
|
||||
targets: [], kinds: [], currentJob: null, es: null,
|
||||
cooldownUntil: 0, cards: {}, lastResult: null,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 启动 */
|
||||
async function boot() {
|
||||
await loadTargets();
|
||||
await loadHistory();
|
||||
setInterval(tickCooldown, 1000);
|
||||
$('target').onchange = () => renderNote();
|
||||
$('start').onclick = startJob;
|
||||
$('cancel').onclick = cancelJob;
|
||||
}
|
||||
|
||||
async function loadTargets() {
|
||||
const r = await fetch('/api/targets').then((x) => x.json());
|
||||
state.targets = r.targets;
|
||||
state.kinds = r.kinds;
|
||||
$('target').innerHTML = r.targets
|
||||
.map((t) => `<option value="${t.key}">${t.key} aid=${t.aid}(最小间隔 ${t.min_interval}s)</option>`)
|
||||
.join('');
|
||||
renderNote();
|
||||
applyCooldown(r.cooldown);
|
||||
}
|
||||
|
||||
function renderNote() {
|
||||
const t = state.targets.find((x) => x.key === $('target').value);
|
||||
$('targetNote').textContent = t ? `【${t.key}】${t.note}` : '';
|
||||
}
|
||||
|
||||
function applyCooldown(cd) {
|
||||
const left = cd && cd[$('target').value];
|
||||
if (typeof left === 'number' && left > 0) state.cooldownUntil = Date.now() + left * 1000;
|
||||
}
|
||||
|
||||
function tickCooldown() {
|
||||
const left = Math.ceil((state.cooldownUntil - Date.now()) / 1000);
|
||||
const el = $('cooldown');
|
||||
if (left > 0) {
|
||||
el.textContent = `频控冷却 ${left}s`;
|
||||
el.className = 'cooldown';
|
||||
$('start').disabled = true;
|
||||
} else {
|
||||
el.textContent = '可发';
|
||||
el.className = 'cooldown ready';
|
||||
$('start').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 任务 */
|
||||
async function startJob() {
|
||||
const body = {
|
||||
target: $('target').value,
|
||||
kind: $('kind').value,
|
||||
max_retries: 2,
|
||||
max_refreshes: 20,
|
||||
};
|
||||
resetView();
|
||||
const job = await fetch('/api/jobs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((r) => r.json());
|
||||
subscribe(job.id);
|
||||
}
|
||||
|
||||
function subscribe(jobId) {
|
||||
if (state.es) { state.es.close(); state.es = null; }
|
||||
state.currentJob = jobId;
|
||||
$('jobId').textContent = jobId;
|
||||
setState('pending');
|
||||
$('cancel').disabled = false;
|
||||
const es = new EventSource(`/api/jobs/${jobId}/events?since=0`);
|
||||
es.onmessage = (e) => {
|
||||
let evt;
|
||||
try { evt = JSON.parse(e.data); } catch (err) { return; }
|
||||
onEvent(evt);
|
||||
if (evt.type === 'job.finished' || evt.type === 'job.error') {
|
||||
es.close(); // 关键:必须主动关闭,否则 EventSource 会自动重连
|
||||
if (state.es === es) state.es = null;
|
||||
}
|
||||
};
|
||||
es.onerror = () => { /* 断线时 EventSource 自动重连并带 Last-Event-ID 续传 */ };
|
||||
state.es = es;
|
||||
}
|
||||
|
||||
async function cancelJob() {
|
||||
if (!state.currentJob) return;
|
||||
await fetch(`/api/jobs/${state.currentJob}/cancel`, { method: 'POST' });
|
||||
$('cancel').disabled = true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 事件分派 */
|
||||
function onEvent(evt) {
|
||||
const { type, data } = evt;
|
||||
switch (type) {
|
||||
case 'job.started':
|
||||
setState('running');
|
||||
logRow('meta', `任务启动:靶子=${data.target} aid=${data.aid} 题型=${data.kind} 模式=${data.mode}`);
|
||||
return;
|
||||
case 'log':
|
||||
logRow(data.level, data.message);
|
||||
return;
|
||||
case 'challenge':
|
||||
addChallengeCard(data);
|
||||
logRow('meta', `抽到第 ${data.index} 题:${data.kind} ${data.instruction}`);
|
||||
return;
|
||||
case 'solve':
|
||||
fillSolve(data);
|
||||
return;
|
||||
case 'telemetry':
|
||||
logRow('meta', `collect:来源=${data.source} 长度=${data.collect_len} ` +
|
||||
`段几何=${data.segments ? JSON.stringify(data.segments) : '—'}` +
|
||||
(data.traj_points ? ` 轨迹点=${data.traj_points}` : '') +
|
||||
(data.segments_err ? ` ⚠ ${data.segments_err}` : ''));
|
||||
return;
|
||||
case 'verify':
|
||||
renderVerify(data);
|
||||
return;
|
||||
case 'job.finished':
|
||||
finishJob(data);
|
||||
return;
|
||||
case 'job.error':
|
||||
setState('error');
|
||||
logRow('ERROR', data.message || '任务异常');
|
||||
$('cancel').disabled = true;
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function setState(s) {
|
||||
const map = {
|
||||
pending: ['排队中', ''], running: ['执行中', 'running'], throttled: ['频控等待', 'warn'],
|
||||
done: ['已完成', 'ok'], error: ['失败', 'err'], cancelled: ['已取消', 'warn'],
|
||||
};
|
||||
const [text, cls] = map[s] || [s, ''];
|
||||
const el = $('state');
|
||||
el.textContent = text;
|
||||
el.className = 'badge ' + cls;
|
||||
}
|
||||
|
||||
function logRow(level, message) {
|
||||
const box = $('log');
|
||||
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 30;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row ' + level;
|
||||
row.innerHTML = `<span class="t">${hhmmss()}</span><span class="m"></span>`;
|
||||
row.querySelector('.m').textContent = message;
|
||||
box.appendChild(row);
|
||||
if (atBottom) box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 图片卡片 */
|
||||
function addChallengeCard(d) {
|
||||
const wrap = $('images');
|
||||
const card = document.createElement('div');
|
||||
card.className = 'shot';
|
||||
const imgs = [];
|
||||
if (d.bg_url) imgs.push(`<img src="${d.bg_url}" alt="底图">`);
|
||||
if (d.prompt_url) imgs.push(`<img src="${d.prompt_url}" alt="提示图">`);
|
||||
card.innerHTML =
|
||||
`<div class="cap"><span>第 ${d.index} 题 · ${d.kind}</span>
|
||||
<span class="muted">${d.instruction || ''}</span></div>
|
||||
<div class="pair">${imgs.join('')}</div>
|
||||
<div class="pts" data-pts>求解中…</div>`;
|
||||
wrap.prepend(card); // 最新的题放最上面
|
||||
state.cards[d.index] = card;
|
||||
}
|
||||
|
||||
function fillSolve(d) {
|
||||
const card = state.cards[d.index];
|
||||
if (!card) return;
|
||||
const offs = (d.off_px || []).map((v) => (v === null ? '—' : v + 'px')).join(' / ');
|
||||
card.querySelector('[data-pts]').textContent =
|
||||
`点位(按点击顺序): ${JSON.stringify(d.points)} 离图标中心: ${offs}`;
|
||||
if (d.annotated_url) {
|
||||
const box = card.querySelector('.pair');
|
||||
const img = document.createElement('img');
|
||||
img.src = d.annotated_url;
|
||||
img.alt = '标注图';
|
||||
box.appendChild(img);
|
||||
}
|
||||
if (d.note) logRow('meta', `求解结论: ${d.note}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 结果渲染 */
|
||||
function renderVerify(d) {
|
||||
const info = ecInfo(d.errorCode);
|
||||
state.lastResult = d;
|
||||
$('result').innerHTML = `
|
||||
<div class="card">
|
||||
<div class="kv"><div class="k">errorCode</div>
|
||||
<div class="v ${info.cls}">${d.errorCode ?? '—'} <span style="font-size:12px">${info.text}</span></div></div>
|
||||
<div class="kv"><div class="k">耗时</div><div class="v">${fmtMs(d.elapsed_ms)}</div></div>
|
||||
<div class="kv"><div class="k">randstr</div><div class="v">${d.randstr || '—'}</div></div>
|
||||
<div class="kv"><div class="k">ticket</div><div class="v">${d.ticket || '—'}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function finishJob(d) {
|
||||
const info = ecInfo(d.errorCode);
|
||||
setState(d.success ? 'done' : (info.cls === 'ec12' ? 'error' : 'warn'));
|
||||
$('cancel').disabled = true;
|
||||
const st = d.stats || {};
|
||||
$('result').insertAdjacentHTML('beforeend', `
|
||||
<div class="card">
|
||||
<div class="kv"><div class="k">结果</div><div class="v ${info.cls}">${d.success ? '成功' : (d.errorCode ?? '失败')}</div></div>
|
||||
<div class="kv"><div class="k">抽题次数</div><div class="v">${st.challenges ?? '—'}</div></div>
|
||||
<div class="kv"><div class="k">点位总数</div><div class="v">${st.points ?? '—'}</div></div>
|
||||
<div class="kv"><div class="k">平均偏离中心</div><div class="v">${st.mean_offset_px ?? '—'}${st.mean_offset_px ? 'px' : ''}</div></div>
|
||||
</div>`);
|
||||
if (d.error) logRow('WARNING', d.error);
|
||||
// 本地起冷却(服务端也会节流,这里只是让按钮状态立刻正确)
|
||||
const t = state.targets.find((x) => x.key === $('target').value);
|
||||
if (t) state.cooldownUntil = Date.now() + t.min_interval * 1000;
|
||||
tickCooldown();
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
function fmtMs(ms) {
|
||||
if (ms === null || ms === undefined) return '—';
|
||||
return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 历史任务 */
|
||||
async function loadHistory() {
|
||||
const r = await fetch('/api/jobs').then((x) => x.json());
|
||||
const tb = $('history').querySelector('tbody');
|
||||
tb.innerHTML = '';
|
||||
for (const j of r.jobs) {
|
||||
const ec = j.result ? j.result.errorCode : null;
|
||||
const info = ecInfo(ec);
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td class="mono muted">${hhmmss(j.created_at)}</td>
|
||||
<td>${j.target}</td>
|
||||
<td class="mono">${j.params.kind}</td>
|
||||
<td class="mono ${info.cls}">${ec ?? '—'} <span class="muted" style="font-size:11px">${info.text}</span></td>
|
||||
<td class="mono">${fmtMs(j.result ? j.result.elapsed_ms : null)}</td>
|
||||
<td class="mono">${(j.result && j.result.success) ? '✓' : '—'}</td>
|
||||
<td class="mono muted">${(j.result && j.result.ticket) ? j.result.ticket.slice(0, 14) + '…' : '—'}</td>`;
|
||||
tr.onclick = () => replay(j.id);
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
/** 点历史行 → 重新订阅该任务的事件流,把整个过程重放一遍。 */
|
||||
function replay(jobId) {
|
||||
resetView();
|
||||
logRow('meta', `重放任务 ${jobId} …`);
|
||||
subscribe(jobId);
|
||||
$('cancel').disabled = true;
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
$('log').innerHTML = '';
|
||||
$('images').innerHTML = '';
|
||||
$('result').innerHTML = '<p class="muted">等待事件…</p>';
|
||||
state.cards = {};
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>tcaptcha 观测台</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<h1>tcaptcha 观测台</h1>
|
||||
<span class="sub">腾讯天御验证码 · 纯协议求解 · 实时观测</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label>靶子
|
||||
<select id="target"></select>
|
||||
</label>
|
||||
<label>题型
|
||||
<select id="kind">
|
||||
<option value="any">any 遇到啥过啥(推荐)</option>
|
||||
<option value="icon">icon 图标点选 2408</option>
|
||||
<option value="text">text 文字点选 2404</option>
|
||||
<option value="slider">slider 滑块 2401/2402</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="start" class="primary">开始一发</button>
|
||||
<button id="cancel" disabled>取消</button>
|
||||
<span id="cooldown" class="cooldown"></span>
|
||||
</div>
|
||||
<div id="targetNote" class="note"></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="panel">
|
||||
<h2>执行轨迹 <span id="state" class="badge">空闲</span>
|
||||
<span id="jobId" class="mono muted"></span>
|
||||
</h2>
|
||||
<div id="log" class="log"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>题目 / 点位 / 结果</h2>
|
||||
<div id="result" class="result"><p class="muted">还没有任务。选好靶子与题型,点「开始一发」。</p></div>
|
||||
<div id="images" class="images"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section class="panel wide">
|
||||
<h2>历史任务</h2>
|
||||
<table id="history">
|
||||
<thead>
|
||||
<tr><th>时间</th><th>靶子</th><th>题型</th><th>errorCode</th><th>耗时</th><th>点位</th><th>ticket</th></tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<footer class="legend">
|
||||
<span><b class="ec0">0</b> 通过</span>
|
||||
<span><b class="ec50">50 / 9</b> 答案被否(点位或目标错)</span>
|
||||
<span><b class="ec12">12</b> 包级拒答(轨迹指纹 / 频控)</span>
|
||||
<span class="muted">靶子带实测最小间隔,后端会强制节流</span>
|
||||
</footer>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,114 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #171a21;
|
||||
--panel-2: #1e222b;
|
||||
--line: #2a2f3a;
|
||||
--fg: #e6e9ef;
|
||||
--muted: #8b93a3;
|
||||
--ok: #35c48a;
|
||||
--warn: #e5a13a;
|
||||
--err: #e05a5a;
|
||||
--accent: #4c8dff;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 14px/1.55 -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
padding: 16px 18px 40px;
|
||||
}
|
||||
|
||||
header { margin-bottom: 14px; }
|
||||
.brand { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
h1 { font-size: 18px; margin: 0; letter-spacing: .3px; }
|
||||
h2 { font-size: 13px; margin: 0 0 10px; color: var(--muted); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: .6px;
|
||||
display: flex; align-items: center; gap: 10px; }
|
||||
.sub { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.controls { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.controls label { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; }
|
||||
select, button {
|
||||
background: var(--panel-2); color: var(--fg);
|
||||
border: 1px solid var(--line); border-radius: 7px;
|
||||
padding: 7px 11px; font-size: 13px; font-family: inherit;
|
||||
}
|
||||
button { cursor: pointer; }
|
||||
button:hover:not(:disabled) { border-color: var(--accent); }
|
||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
||||
button.primary:disabled { background: var(--panel-2); color: var(--fg); border-color: var(--line); }
|
||||
|
||||
.note { margin-top: 8px; font-size: 12.5px; color: var(--muted); }
|
||||
.cooldown { font-family: var(--mono); font-size: 12.5px; color: var(--warn); }
|
||||
.cooldown.ready { color: var(--ok); }
|
||||
|
||||
main { display: grid; grid-template-columns: minmax(380px, 1fr) minmax(420px, 1.2fr); gap: 14px; }
|
||||
@media (max-width: 1080px) { main { grid-template-columns: 1fr; } }
|
||||
|
||||
.panel {
|
||||
background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 11px; padding: 13px 14px; margin-bottom: 14px;
|
||||
}
|
||||
.panel.wide { margin-bottom: 14px; }
|
||||
|
||||
.badge {
|
||||
font-size: 11px; padding: 2px 9px; border-radius: 999px;
|
||||
background: var(--panel-2); color: var(--muted); border: 1px solid var(--line);
|
||||
font-weight: 500; text-transform: none; letter-spacing: 0;
|
||||
}
|
||||
.badge.running { color: var(--accent); border-color: var(--accent); }
|
||||
.badge.ok { color: var(--ok); border-color: var(--ok); }
|
||||
.badge.warn { color: var(--warn); border-color: var(--warn); }
|
||||
.badge.err { color: var(--err); border-color: var(--err); }
|
||||
|
||||
.log {
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
height: 460px; overflow-y: auto;
|
||||
background: #0c0e12; border: 1px solid var(--line); border-radius: 8px;
|
||||
padding: 9px 11px;
|
||||
}
|
||||
.log .row { display: grid; grid-template-columns: 62px 1fr; gap: 9px; padding: 1px 0; }
|
||||
.log .t { color: #5a6376; }
|
||||
.log .m { white-space: pre-wrap; word-break: break-word; }
|
||||
.log .row.INFO .m { color: #cdd4e0; }
|
||||
.log .row.WARNING .m { color: var(--warn); }
|
||||
.log .row.ERROR .m { color: var(--err); }
|
||||
.log .row.meta .m { color: var(--muted); font-style: italic; }
|
||||
|
||||
.result { margin-bottom: 12px; }
|
||||
.result .card {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
|
||||
gap: 9px; margin-top: 9px;
|
||||
}
|
||||
.result .kv {
|
||||
background: var(--panel-2); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 8px 10px;
|
||||
}
|
||||
.result .kv .k { color: var(--muted); font-size: 11px; letter-spacing: .4px; }
|
||||
.result .kv .v { font-family: var(--mono); font-size: 14px; margin-top: 3px; word-break: break-all; }
|
||||
.v.ec0 { color: var(--ok); } .v.ec50 { color: var(--warn); }
|
||||
.v.ec12 { color: var(--err); } .v.ecother { color: var(--muted); }
|
||||
|
||||
.images { display: grid; gap: 11px; }
|
||||
.shot { background: var(--panel-2); border: 1px solid var(--line); border-radius: 9px; padding: 9px; }
|
||||
.shot .cap { display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 12px; color: var(--muted); margin-bottom: 7px; }
|
||||
.shot img { width: 100%; border-radius: 6px; display: block; background: #000; }
|
||||
.shot .pair { display: grid; grid-template-columns: 1fr; gap: 7px; }
|
||||
.shot .pts { font-family: var(--mono); font-size: 11.5px; color: #9fb0cc; margin-top: 6px; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
th, td { text-align: left; padding: 7px 9px; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 11px; letter-spacing: .4px; }
|
||||
td.mono, .mono { font-family: var(--mono); }
|
||||
tbody tr:hover { background: var(--panel-2); cursor: pointer; }
|
||||
|
||||
.legend { display: flex; gap: 20px; flex-wrap: wrap; color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||
.legend b { font-family: var(--mono); padding: 1px 6px; border-radius: 5px; }
|
||||
.ec0 { color: var(--ok); } .ec50 { color: var(--warn); } .ec12 { color: var(--err); }
|
||||
.muted { color: var(--muted); }
|
||||
Reference in New Issue
Block a user