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:
yml2213
2026-10-14 19:10:54 +08:00
parent 74a863c8b3
commit 99565f65f0
15 changed files with 1827 additions and 1189 deletions
+22 -2
View File
@@ -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。
+2
View File
@@ -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**(依赖隔离) | 确证 |
---
+6
View File
@@ -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 栈。
+29 -64
View File
@@ -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)))
+115
View File
@@ -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)
+60
View File
@@ -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()]
+121
View File
@@ -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
```
+19
View File
@@ -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 = []
+207
View File
@@ -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())
+77
View File
@@ -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)
+512
View File
@@ -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"]
+294
View File
@@ -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();
+68
View File
@@ -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>
+114
View File
@@ -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); }
Generated
+181 -1123
View File
File diff suppressed because it is too large Load Diff