diff --git a/tcaptcha/render.py b/tcaptcha/render.py
index d72f9c1..94e1f65 100644
--- a/tcaptcha/render.py
+++ b/tcaptcha/render.py
@@ -74,16 +74,19 @@ def annotate_points(bg: Any,
prompt: Any = None,
offsets: Optional[Sequence[Optional[float]]] = None,
centroids: Optional[Sequence[Optional[Sequence[float]]]] = None,
- title: str = "") -> bytes:
+ title: str = "",
+ embed_prompt: bool = True) -> bytes:
"""在底图上按**点击顺序**画编号红圈,可选贴提示图、标出质心与偏差。
参数:
- points 点击点序列(背景图原始像素),**顺序即答案顺序**
- prompt 提示图(图标/文字点选题),会缩放到右上角
- offsets 每个点离图标笔画质心的距离(px),标在圈上
- centroids 每个点的质心坐标,画一个蓝色十字(一眼看出偏没偏)
- title 左上角标题(如 "icon_click ec=0")
- """
+ points 点击点序列(背景图原始像素),**顺序即答案顺序**
+ prompt 提示图(图标/文字点选题)
+ offsets 每个点离图标笔画质心的距离(px),标在圈上
+ centroids 每个点的质心坐标,画一个蓝色十字(一眼看出偏没偏)
+ title 左上角标题(如 "第 1 题")
+ embed_prompt 是否把提示图贴进右上角。命令行/落盘单图查看时贴(默认 True);
+ **Web 界面传 False** —— 那边提示图是独立一张缩略图,
+ 贴进去只会挡住右上角的图标(实测会遮住一个目标)。"""
bgr = to_bgr(bg)
for i, pt in enumerate(points):
x, y = int(pt[0]), int(pt[1])
@@ -98,7 +101,7 @@ def annotate_points(bg: Any,
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:
+ if prompt is not None and embed_prompt:
try:
pr = to_bgr(prompt)
ph = max(1, bgr.shape[0] // 4)
diff --git a/tcaptcha/targets.py b/tcaptcha/targets.py
index 0f84a90..399a936 100644
--- a/tcaptcha/targets.py
+++ b/tcaptcha/targets.py
@@ -20,6 +20,7 @@ from typing import Dict, TypedDict
class Target(TypedDict):
aid: str
+ label: str # 界面上显示的友好名(下拉框/历史表都用它)
entry_url: str
referer: str
wb: str
@@ -31,6 +32,7 @@ TARGETS: Dict[str, Target] = {
# 用户主靶:1ms 签到页。频控很紧,必须低频。
"1ms": {
"aid": "195672344",
+ "label": "1ms 签到",
"entry_url": "https://1ms.run/console/personal/benefits/check-in",
"referer": "https://1ms.run/",
"wb": "1",
@@ -40,6 +42,7 @@ TARGETS: Dict[str, Target] = {
# 可复现正样本靶:mliev 短信登录。题率低但宽松,适合回归。
"mliev": {
"aid": "190758559",
+ "label": "mliev 登录",
"entry_url": "https://mliev.login.wang/auth/#/oidc/authorize",
"referer": "https://mliev.login.wang/",
"wb": "1",
diff --git a/tcaptcha/web/README.md b/tcaptcha/web/README.md
index d1c6586..c6efefa 100644
--- a/tcaptcha/web/README.md
+++ b/tcaptcha/web/README.md
@@ -9,13 +9,60 @@
## 启动
```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 引擎
+# 首次(或依赖变动后):把 web extra 同步进 .venv
+uv sync --extra web
+
+# 启动(默认 127.0.0.1:8770 —— 只有本机能访问)
+uv run --extra web python -m tcaptcha.web.app
```
+然后浏览器打开 。前台运行,`Ctrl+C` 停止。
+
+启动成功时终端会打印:
+
+```
+ tcaptcha 观测台已启动 → http://127.0.0.1:8770
+
+ (Ctrl+C 停止;靶子带实测最小间隔,1ms 为 60s,后端会自动节流)
+```
+
+常用变体:
+
+```bash
+uv run --extra web python -m tcaptcha.web.app --port 8899 # 换端口
+uv run --extra web python -m tcaptcha.web.app --host 0.0.0.0 # 局域网访问(⚠️ 界面无鉴权)
+uv run --extra web python -m tcaptcha.web.app --no-warmup # 跳过 AI 引擎预热,启动更快
+```
+
+不想用 uv 也可以直接调项目 venv(两条路径等价,`uv run` 用的就是 `.venv`):
+
+```bash
+.venv/bin/python -m tcaptcha.web.app
+```
+
+**后台运行 + 停止**(`&` 直接后台会随终端退出而挂掉,所以用 `nohup` 并记下 PID):
+
+```bash
+nohup .venv/bin/python -m tcaptcha.web.app > /tmp/tcaptcha-web.log 2>&1 &
+echo $! > /tmp/tcaptcha-web.pid
+# 停止
+kill "$(cat /tmp/tcaptcha-web.pid)"
+```
+
+**端口被占用**:启动时会直接给中文提示并以退出码 2 结束(不会打一堆 errno):
+
+```
+ ✗ 端口 8770 已被占用,服务没起来。
+ 查占用进程:lsof -nP -iTCP:8770 -sTCP:LISTEN
+ 停掉它 :kill <上面的 PID>
+ 或换端口 :--port 8771
+```
+
+**自检**:`curl -s http://127.0.0.1:8770/api/health` 应返回
+`{"status":"online","targets":["1ms","mliev"],"cooldown":{...}}`。
+
> `fastapi` / `uvicorn` 装在 **optional extra `web`** 里,核心包(协议 / 遥测 / 求解器 / CLI)
-> 完全不依赖它们 —— `import tcaptcha` 不会把 fastapi 拉进来(有回归断言:见本文件末)。
+> 完全不依赖它们 —— `import tcaptcha` 不会把 fastapi 拉进来(有回归断言)。
## 架构
diff --git a/tcaptcha/web/app.py b/tcaptcha/web/app.py
index b2e5206..ed3409f 100644
--- a/tcaptcha/web/app.py
+++ b/tcaptcha/web/app.py
@@ -57,6 +57,7 @@ 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")
+ count: int = Field(default=1, ge=1, le=20, description="连打次数(发间自动按靶子频控节流)")
max_retries: int = Field(default=2, ge=1, le=10)
max_refreshes: int = Field(default=20, ge=1, le=60)
@@ -87,7 +88,8 @@ def create_job(req: JobRequest) -> Dict[str, Any]:
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,
+ job = MANAGER.submit(req.target, {"kind": req.kind, "count": req.count,
+ "max_retries": req.max_retries,
"max_refreshes": req.max_refreshes})
return job.snapshot()
@@ -174,9 +176,21 @@ if STATIC_DIR.is_dir():
# --------------------------------------------------------------- 启动入口
+def port_in_use(host: str, port: int) -> bool:
+ """端口是否已被占用(用来给中文提示,而不是把 uvicorn 的 errno 48 直接甩给用户)。"""
+ import socket
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ try:
+ sock.bind((host, port))
+ return False
+ except OSError:
+ return True
+
+
def main() -> int:
parser = argparse.ArgumentParser(description="tcaptcha Web 观测台")
- parser.add_argument("--host", default="127.0.0.1", help="监听地址(默认本机)")
+ parser.add_argument("--host", default="127.0.0.1",
+ help="监听地址(默认 127.0.0.1 仅本机;0.0.0.0 供局域网访问,注意无鉴权)")
parser.add_argument("--port", type=int, default=8770, help="监听端口(默认 8770)")
parser.add_argument("--no-warmup", action="store_true", help="不预热 AI 推理引擎")
args = parser.parse_args()
@@ -185,6 +199,16 @@ def main() -> int:
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S")
+ if port_in_use(args.host, args.port):
+ print("\n ✗ 端口 %d 已被占用,服务没起来。\n"
+ " 查占用进程:lsof -nP -iTCP:%d -sTCP:LISTEN\n"
+ " 停掉它 :kill <上面的 PID>\n"
+ " 或换端口 :--port 8771\n" % (args.port, args.port), flush=True)
+ return 2
+
+ if args.host not in ("127.0.0.1", "localhost"):
+ print("\n ⚠️ 监听地址是 %s —— 界面**没有任何鉴权**,别暴露到公网。\n" % args.host, flush=True)
+
if not args.no_warmup:
def _warm() -> None:
try:
@@ -198,8 +222,13 @@ def main() -> int:
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")
+ print("\n tcaptcha 观测台已启动 → http://%s:%d\n"
+ " (Ctrl+C 停止;靶子带实测最小间隔,1ms 为 60s,后端会自动节流)\n"
+ % (args.host, args.port), flush=True)
+ try:
+ uvicorn.run(app, host=args.host, port=args.port, log_level="info")
+ except KeyboardInterrupt:
+ pass
return 0
diff --git a/tcaptcha/web/runner.py b/tcaptcha/web/runner.py
index 478e019..1d24186 100644
--- a/tcaptcha/web/runner.py
+++ b/tcaptcha/web/runner.py
@@ -158,6 +158,8 @@ class SolveJob:
self._cancel = threading.Event()
self._challenge_no = 0
self._solves: List[Dict[str, Any]] = []
+ self._rounds: List[Dict[str, Any]] = [] # 每一发的结果(count>1 时)
+ self._count = 1
self._images: Dict[str, bytes] = {}
self._img_lock = threading.Lock()
@@ -188,11 +190,17 @@ class SolveJob:
def stats(self) -> Dict[str, Any]:
offs = [v for s in self._solves for v in (s.get("off_px") or []) if isinstance(v, (int, float))]
+ ews = [r["errorCode"] for r in self._rounds if r.get("errorCode") is not None]
+ ok_n = sum(1 for r in self._rounds if r.get("success"))
return {
"challenges": self._challenge_no,
"solves": len(self._solves),
"points": sum(len(s.get("points") or []) for s in self._solves),
"mean_offset_px": round(sum(offs) / len(offs), 1) if offs else None,
+ "count": self._count,
+ "rounds": list(self._rounds),
+ "ok": ok_n,
+ "errorCodes": ews,
}
def snapshot(self) -> Dict[str, Any]:
@@ -227,21 +235,12 @@ class SolveJob:
def _execute(self) -> None:
tgt = get_target(self.target_key)
kind = str(self.params.get("kind") or "icon")
+ self._count = max(1, min(20, int(self.params.get("count") or 1)))
self.events.append(EVT_STARTED, target=self.target_key, aid=tgt["aid"],
- kind=kind, mode="python_values",
+ label=tgt.get("label", self.target_key),
+ kind=kind, mode="python_values", count=self._count,
note=tgt["note"], params={k: v for k, v in self.params.items()})
- if self._throttle:
- waited = self._throttle(self)
- if waited:
- self.events.append(EVT_LOG, level="INFO", logger="web.runner",
- message="频控节流结束(等待 %.1fs)" % waited)
- if self.cancelled:
- self.state = "cancelled"
- self.events.append(EVT_FINISHED, success=False, errorCode=None, ticket="",
- elapsed_ms=0, error="任务已取消", stats=self.stats())
- return
-
self.state = "running"
bridge = _LogBridge(self._emit_log)
loggers = [logging.getLogger("tcaptcha"), logging.getLogger("tcaptcha_client")]
@@ -250,12 +249,35 @@ class SolveJob:
lg.setLevel(logging.INFO)
lg.addHandler(bridge)
try:
- with _Hooks(self):
- res = self._call_client(tgt, kind)
+ for i in range(1, self._count + 1):
+ if self.cancelled:
+ break
+ if i > 1:
+ self.events.append(EVT_LOG, level="INFO", logger="web.runner",
+ message="—— 第 %d/%d 发 ——" % (i, self._count))
+ if self._throttle:
+ waited = self._throttle(self)
+ if waited:
+ self.events.append(EVT_LOG, level="INFO", logger="web.runner",
+ message="频控节流结束(等待 %.1fs)" % waited)
+ if self.cancelled:
+ break
+ with _Hooks(self):
+ res = self._call_client(tgt, kind)
+ self._finalize(res, round_no=i)
finally:
for lg in loggers:
lg.removeHandler(bridge)
- self._finalize(res)
+
+ if self.cancelled:
+ self.state = "cancelled"
+ self.events.append(EVT_FINISHED, success=any(r.get("success") for r in self._rounds),
+ errorCode=self._rounds[-1]["errorCode"] if self._rounds else None,
+ ticket=self._rounds[-1].get("ticket") or "",
+ elapsed_ms=self._rounds[-1].get("elapsed_ms"),
+ error=self.error or ("已取消(完成 %d/%d 发)" % (len(self._rounds), self._count)
+ if self.cancelled else ""),
+ stats=self.stats())
def _emit_log(self, level: str, name: str, message: str) -> None:
self.events.append(EVT_LOG, level=level, logger=name, message=message)
@@ -278,7 +300,7 @@ class SolveJob:
except Exception: # noqa: BLE001
pass
- def _finalize(self, res: Dict[str, Any]) -> None:
+ def _finalize(self, res: Dict[str, Any], round_no: int = 1) -> None:
raw = res.get("raw_response") or {}
ec = raw.get("errorCode")
self.result = {
@@ -293,13 +315,19 @@ class SolveJob:
}
self.error = res.get("error") or ""
self.state = "done" if res.get("success") else ("error" if self.error else "done")
- self.events.append(EVT_VERIFY, success=bool(res.get("success")), errorCode=ec,
- ticket=res.get("ticket") or "", randstr=res.get("randstr") or "",
+ self._rounds.append({"round": round_no, "success": bool(res.get("success")),
+ "errorCode": ec, "kind": res.get("kind") or "",
+ "ticket": res.get("ticket") or "",
+ "elapsed_ms": res.get("elapsed_ms"),
+ "error": res.get("error") or ""})
+ self.events.append(EVT_VERIFY, round=round_no, success=bool(res.get("success")),
+ errorCode=ec, ticket=res.get("ticket") or "",
+ randstr=res.get("randstr") or "",
elapsed_ms=res.get("elapsed_ms"))
- self.events.append(EVT_FINISHED, success=bool(res.get("success")), errorCode=ec,
- ticket=res.get("ticket") or "",
- elapsed_ms=res.get("elapsed_ms"),
- error=self.error, stats=self.stats())
+ self.events.append(EVT_LOG, level="INFO", logger="web.runner",
+ message="第 %d/%d 发结束:errorCode=%s %s"
+ % (round_no, self._count, ec,
+ "✅" if res.get("success") else (self.error or "未通过")))
# --------------------------------------------------------- 求解器 hook
def _wrap_solver(self, key: str, original: Callable) -> Callable:
@@ -369,10 +397,15 @@ class SolveJob:
data: Dict[str, Any] = {"index": n, "kind": kind, "points": [list(p) for p in points],
"off_px": offsets, "centroids": centroids, "note": note}
try:
- data["annotated_url"] = self.add_image(
- "c%d_annotated.jpg" % n,
- annotate_points(bg, points, prompt=prompt, offsets=offsets,
- centroids=centroids, title="第 %d 题" % n))
+ # title 不画(卡片标题里已有),提示图不贴(界面上是独立缩略图,
+ # 贴进去会遮挡右上角的目标图标)—— 详见 render.annotate_points。
+ # 标注图只给点选题画:红圈+序号表达的是"点击顺序",滑块答案是
+ # 缺口位置,画上去只会误导(界面上底图+滑块图已足够核对)。
+ if measurable:
+ data["annotated_url"] = self.add_image(
+ "c%d_annotated.jpg" % n,
+ annotate_points(bg, points, offsets=offsets, centroids=centroids,
+ embed_prompt=False))
except Exception: # noqa: BLE001
pass
self._solves.append(data)
diff --git a/tcaptcha/web/static/app.js b/tcaptcha/web/static/app.js
index 7b19ad4..2340f10 100644
--- a/tcaptcha/web/static/app.js
+++ b/tcaptcha/web/static/app.js
@@ -4,6 +4,9 @@
* POST /api/jobs 建任务,拿 job_id
* GET /api/jobs/{id}/events SSE 事件流(服务端推 event.to_dict())
* 事件类型由后端 tcaptcha/web/events.py 的 EVT_* 定义,这里按 data.type 分派。
+ *
+ * 布局要点:图片**不铺满容器**——底图/标注图限高缩略、提示图小缩略,
+ * 点任意图进 lightbox 看原始尺寸。否则 672x480 的图会把右栏撑成长条。
*/
'use strict';
@@ -11,15 +14,22 @@
const $ = (id) => document.getElementById(id);
const EC_MEANING = {
- 0: { text: '通过', cls: 'ec0' },
- 50: { text: '答案被否', cls: 'ec50' },
- 9: { text: '答案被否', cls: 'ec50' },
- 12: { text: '包级拒答', cls: 'ec12' },
+ 0: { text: '通过', cls: 'ec0' },
+ 50: { text: '答案被否', cls: 'ec50' },
+ 9: { text: '答案被否', cls: 'ec50' },
+ 12: { text: '包级拒答', cls: 'ec12' },
+};
+
+const KIND_LABEL = {
+ icon_click: '图标点选',
+ text_click: '文字点选',
+ slider: '滑块',
+ unknown: '未知',
};
function ecInfo(ec) {
- if (ec === null || ec === undefined || ec === '') return { text: '—', cls: 'ecother' };
- return EC_MEANING[Number(ec)] || { text: '未知', cls: 'ecother' };
+ if (ec === null || ec === undefined || ec === '') return { text: '—', cls: 'other' };
+ return EC_MEANING[Number(ec)] || { text: '未知', cls: 'other' };
}
function hhmmss(ts) {
@@ -27,9 +37,21 @@ function hhmmss(ts) {
return d.toTimeString().slice(0, 8);
}
+function fmtMs(ms) {
+ if (ms === null || ms === undefined) return '—';
+ return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms';
+}
+
+function el(tag, cls, text) {
+ const e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (text !== undefined) e.textContent = text;
+ return e;
+}
+
let state = {
- targets: [], kinds: [], currentJob: null, es: null,
- cooldownUntil: 0, cards: {}, lastResult: null,
+ targets: [], currentJob: null, es: null,
+ cooldownUntil: 0, cards: {},
};
/* ------------------------------------------------------------------ 启动 */
@@ -37,17 +59,17 @@ async function boot() {
await loadTargets();
await loadHistory();
setInterval(tickCooldown, 1000);
- $('target').onchange = () => renderNote();
+ $('target').onchange = renderNote;
$('start').onclick = startJob;
$('cancel').onclick = cancelJob;
+ setupLightbox();
}
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) => ``)
+ .map((t) => ``)
.join('');
renderNote();
applyCooldown(r.cooldown);
@@ -55,7 +77,7 @@ async function loadTargets() {
function renderNote() {
const t = state.targets.find((x) => x.key === $('target').value);
- $('targetNote').textContent = t ? `【${t.key}】${t.note}` : '';
+ $('targetNote').textContent = t ? `【${t.label || t.key}】${t.note}` : '';
}
function applyCooldown(cd) {
@@ -65,30 +87,27 @@ function applyCooldown(cd) {
function tickCooldown() {
const left = Math.ceil((state.cooldownUntil - Date.now()) / 1000);
- const el = $('cooldown');
+ const elCD = $('cooldown');
if (left > 0) {
- el.textContent = `频控冷却 ${left}s`;
- el.className = 'cooldown';
+ elCD.textContent = `频控冷却 ${left}s`;
+ elCD.className = 'cooldown';
$('start').disabled = true;
} else {
- el.textContent = '可发';
- el.className = 'cooldown ready';
+ elCD.textContent = '可发';
+ elCD.className = 'cooldown ready';
$('start').disabled = false;
}
}
/* ------------------------------------------------------------------ 任务 */
async function startJob() {
- const body = {
- target: $('target').value,
- kind: $('kind').value,
- max_retries: 2,
- max_refreshes: 20,
- };
+ const count = Math.max(1, Math.min(20, parseInt($('count').value, 10) || 1));
+ $('count').value = count; // 越界输入拉回合法范围
+ const body = { target: $('target').value, kind: $('kind').value, count,
+ max_retries: 2, max_refreshes: 20 };
resetView();
const job = await fetch('/api/jobs', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((r) => r.json());
subscribe(job.id);
@@ -100,17 +119,19 @@ function subscribe(jobId) {
$('jobId').textContent = jobId;
setState('pending');
$('cancel').disabled = false;
+ markActiveRow(jobId);
+
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 会自动重连
+ es.close(); // 必须主动关,否则 EventSource 会自动重连
if (state.es === es) state.es = null;
}
};
- es.onerror = () => { /* 断线时 EventSource 自动重连并带 Last-Event-ID 续传 */ };
+ es.onerror = () => { /* 断线时自动重连并带 Last-Event-ID 续传 */ };
state.es = es;
}
@@ -126,23 +147,23 @@ function onEvent(evt) {
switch (type) {
case 'job.started':
setState('running');
- logRow('meta', `任务启动:靶子=${data.target} aid=${data.aid} 题型=${data.kind} 模式=${data.mode}`);
+ logRow('meta', `任务启动:靶子=${data.label || data.target} aid=${data.aid} 题型=${data.kind} 模式=${data.mode}`
+ + (data.count > 1 ? ` 连打=${data.count} 发` : ''));
return;
case 'log':
logRow(data.level, data.message);
+ if (data.message && data.message.includes('频控节流')) setState('throttled');
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}` : ''));
+ 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);
@@ -166,67 +187,94 @@ function setState(s) {
done: ['已完成', 'ok'], error: ['失败', 'err'], cancelled: ['已取消', 'warn'],
};
const [text, cls] = map[s] || [s, ''];
- const el = $('state');
- el.textContent = text;
- el.className = 'badge ' + cls;
+ const b = $('state');
+ b.textContent = text;
+ b.className = 'badge ' + cls;
}
function logRow(level, message) {
const box = $('log');
+ const empty = box.querySelector('.empty');
+ if (empty) empty.remove(); // 首条事件时清掉占位
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 30;
- const row = document.createElement('div');
- row.className = 'row ' + level;
- row.innerHTML = `${hhmmss()}`;
- row.querySelector('.m').textContent = message;
+ const row = el('div', 'row ' + level);
+ row.appendChild(el('span', 't', hhmmss()));
+ row.appendChild(el('span', 'm', 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(`
`);
- if (d.prompt_url) imgs.push(`
`);
- card.innerHTML =
- `
第 ${d.index} 题 · ${d.kind}
- ${d.instruction || ''}
- ${imgs.join('')}
- 求解中…
`;
- wrap.prepend(card); // 最新的题放最上面
+ const wrap = $('challenges');
+ const hint = wrap.querySelector('.empty-hint');
+ if (hint) hint.remove();
+
+ const card = el('div', 'challenge');
+ const head = el('div', 'ch-head');
+ head.appendChild(el('span', 'pill', '#' + d.index));
+ head.appendChild(el('span', 'kind', KIND_LABEL[d.kind] || d.kind));
+ if (d.instruction) head.appendChild(el('span', 'inst', d.instruction));
+ card.appendChild(head);
+
+ const media = el('div', 'ch-media');
+ if (d.bg_url) media.appendChild(figure(d.bg_url, '底图', ''));
+ if (d.annotated_url) media.appendChild(figure(d.annotated_url, '标注图(1/2/3 = 点击顺序)', ''));
+ if (d.prompt_url) media.appendChild(figure(d.prompt_url, '提示图', 'prompt'));
+ card.appendChild(media);
+
+ card.appendChild(el('div', 'ch-meta', '求解中…'));
+ wrap.prepend(card); // 最新的题放最上面
state.cards[d.index] = card;
}
+/** 一张可点放的缩略图。CSS 限高,原始尺寸靠 lightbox 看。 */
+function figure(src, caption, extraCls) {
+ const fig = el('figure', 'thumb' + (extraCls ? ' ' + extraCls : ''));
+ const img = el('img');
+ img.src = src;
+ img.alt = caption;
+ img.onclick = () => openLightbox(src);
+ fig.appendChild(img);
+ fig.appendChild(el('figcaption', '', caption));
+ return fig;
+}
+
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);
+ // 标注图是 solve 时才生成的,补插到「底图之后、提示图之前」。
+ // 判重用 data 标记(不能用 nth-child —— 提示图也排第 2,会误判已存在)。
+ const media = card.querySelector('.ch-media');
+ if (d.annotated_url && media && !media.querySelector('img[data-annotated]')) {
+ const fig = figure(d.annotated_url, '标注图(1/2/3 = 点击顺序)', '');
+ fig.querySelector('img').dataset.annotated = '1';
+ const promptFig = media.querySelector('.thumb.prompt');
+ media.insertBefore(fig, promptFig || null);
}
- if (d.note) logRow('meta', `求解结论: ${d.note}`);
+ const meta = card.querySelector('.ch-meta');
+ if (!meta) return;
+ const offs = (d.off_px || []).map((v) => (v === null ? '—' : v + 'px'));
+ const measurable = offs.length && offs.some((v) => v !== '—');
+ meta.replaceChildren(
+ document.createTextNode(`点位(按点击顺序):${JSON.stringify(d.points)}`),
+ measurable
+ ? el('span', '', ` 离图标中心:${offs.join(' / ')}`)
+ : el('span', 'warn', ' (滑块的答案是缺口位置,不适用“离图标中心”量尺)'),
+ );
+ if (d.note) logRow('meta', `求解结论:${d.note}`);
}
/* ------------------------------------------------------------ 结果渲染 */
function renderVerify(d) {
const info = ecInfo(d.errorCode);
- state.lastResult = d;
- $('result').innerHTML = `
-
-
errorCode
-
${d.errorCode ?? '—'} ${info.text}
-
-
randstr
${d.randstr || '—'}
-
-
`;
+ const grid = el('div', 'kv-grid');
+ grid.appendChild(kv(d.round > 1 ? `第${d.round}发 errorCode` : 'errorCode',
+ String(d.errorCode ?? '—'), info.cls, info.text));
+ grid.appendChild(kv('耗时', fmtMs(d.elapsed_ms), '', ''));
+ grid.appendChild(kv('randstr', d.randstr || '—', '', ''));
+ grid.appendChild(kvWide('ticket', d.ticket || '—'));
+ $('result').replaceChildren(grid);
}
function finishJob(d) {
@@ -234,46 +282,93 @@ function finishJob(d) {
setState(d.success ? 'done' : (info.cls === 'ec12' ? 'error' : 'warn'));
$('cancel').disabled = true;
const st = d.stats || {};
- $('result').insertAdjacentHTML('beforeend', `
-
-
结果
${d.success ? '成功' : (d.errorCode ?? '失败')}
-
抽题次数
${st.challenges ?? '—'}
-
-
平均偏离中心
${st.mean_offset_px ?? '—'}${st.mean_offset_px ? 'px' : ''}
-
`);
+ const grid = document.querySelector('#result .kv-grid') || el('div', 'kv-grid');
+ if (!grid.parentNode) $('result').replaceChildren(grid);
+ grid.appendChild(kv('结果', d.success ? '成功' : (d.errorCode ?? '失败'), info.cls, ''));
+ // 连打时展示每发的 errorCode 一览(0 绿 / 50/9 黄 / 12 红 / 其余灰)
+ if (st.count > 1 && Array.isArray(st.rounds) && st.rounds.length) {
+ const seq = st.rounds.map((r) => {
+ const ic = ecInfo(r.errorCode);
+ return `#${r.round}:${r.errorCode ?? '—'}`;
+ }).join(' ');
+ const box = el('div', 'kv wide');
+ box.appendChild(el('div', 'k', `每发结果(通过 ${st.ok}/${st.rounds.length})`));
+ const val = el('div', 'v');
+ val.innerHTML = seq;
+ box.appendChild(val);
+ grid.appendChild(box);
+ }
+ grid.appendChild(kv('抽题次数', String(st.challenges ?? '—'), '', ''));
+ grid.appendChild(kv('点位总数', String(st.points ?? '—'), '', ''));
+ grid.appendChild(kv('平均偏离中心',
+ st.mean_offset_px ? st.mean_offset_px + 'px' : '—', '', ''));
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';
+function kv(k, v, cls, sub) {
+ const box = el('div', 'kv');
+ box.appendChild(el('div', 'k', k));
+ const val = el('div', 'v' + (cls ? ' ' + cls : ''), v);
+ if (sub) val.appendChild(el('small', '', ' ' + sub));
+ box.appendChild(val);
+ return box;
+}
+
+/** 长文本卡(ticket 等):占整行 + 单行截断,点击复制全文。 */
+function kvWide(k, v) {
+ const box = el('div', 'kv wide');
+ box.title = v === '—' ? '' : '点击复制';
+ box.appendChild(el('div', 'k', k));
+ const val = el('div', 'v trunc' + (v === '—' ? ' other' : ''), v);
+ box.appendChild(val);
+ if (v !== '—') {
+ box.style.cursor = 'copy';
+ box.onclick = async () => {
+ try { await navigator.clipboard.writeText(v); } catch (err) { /* 无剪贴板权限时静默 */ }
+ box.title = '已复制 ✓';
+ setTimeout(() => { box.title = '点击复制'; }, 1200);
+ };
+ }
+ return box;
}
/* ------------------------------------------------------------ 历史任务 */
async function loadHistory() {
const r = await fetch('/api/jobs').then((x) => x.json());
const tb = $('history').querySelector('tbody');
- tb.innerHTML = '';
+ tb.replaceChildren();
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 = `
- ${hhmmss(j.created_at)} |
- ${j.target} |
- ${j.params.kind} |
- ${ec ?? '—'} ${info.text} |
- ${fmtMs(j.result ? j.result.elapsed_ms : null)} |
- ${(j.result && j.result.success) ? '✓' : '—'} |
- ${(j.result && j.result.ticket) ? j.result.ticket.slice(0, 14) + '…' : '—'} | `;
+ const t = state.targets.find((x) => x.key === j.target);
+ const tr = el('tr');
+ tr.dataset.job = j.id;
+ tr.appendChild(td(hhmmss(j.created_at), 'mono muted'));
+ tr.appendChild(td(t ? (t.label || t.key) : j.target, ''));
+ tr.appendChild(td(j.params.kind, 'mono'));
+ tr.appendChild(td(`${ec ?? '—'} ${info.text}`, 'mono ' + info.cls));
+ tr.appendChild(td(fmtMs(j.result ? j.result.elapsed_ms : null), 'mono'));
+ tr.appendChild(td((j.result && j.result.success) ? '✓' : '—', 'mono'));
+ tr.appendChild(td((j.result && j.result.ticket) ? j.result.ticket : '—', 'mono muted'));
tr.onclick = () => replay(j.id);
tb.appendChild(tr);
}
+ if (state.currentJob) markActiveRow(state.currentJob);
+}
+
+function td(text, cls) {
+ const c = el('td', cls, text);
+ return c;
+}
+
+function markActiveRow(jobId) {
+ document.querySelectorAll('#history tbody tr').forEach((tr) => {
+ tr.classList.toggle('active', tr.dataset.job === jobId);
+ });
}
/** 点历史行 → 重新订阅该任务的事件流,把整个过程重放一遍。 */
@@ -284,10 +379,21 @@ function replay(jobId) {
$('cancel').disabled = true;
}
+/* ------------------------------------------------------------- lightbox */
+function setupLightbox() {
+ const lb = $('lightbox');
+ lb.onclick = closeLightbox;
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeLightbox(); });
+}
+
+function openLightbox(src) { $('lightboxImg').src = src; $('lightbox').classList.add('show'); }
+function closeLightbox() { $('lightbox').classList.remove('show'); }
+
+/* ------------------------------------------------------------------ 复位 */
function resetView() {
- $('log').innerHTML = '';
- $('images').innerHTML = '';
- $('result').innerHTML = '等待事件…
';
+ $('log').replaceChildren(el('div', 'empty', '等待事件…'));
+ $('challenges').replaceChildren(el('p', 'empty-hint', '抽到题目后,底图与标注图会出现在这里。'));
+ $('result').replaceChildren(el('p', 'empty-hint', '等待事件…'));
state.cards = {};
}
diff --git a/tcaptcha/web/static/index.html b/tcaptcha/web/static/index.html
index afe7340..f46dadf 100644
--- a/tcaptcha/web/static/index.html
+++ b/tcaptcha/web/static/index.html
@@ -13,18 +13,20 @@
腾讯天御验证码 · 纯协议求解 · 实时观测
-
+
-
+
+
@@ -32,30 +34,44 @@
-
+
+
+
+
+
+
+ 题目 / 点位 点图片可放大
+ 抽到题目后,底图与标注图会出现在这里(标注图上的 1/2/3 即点击顺序)。
+
+
+
+ 历史任务
+
+
+
+
+
+
+ | 时间 | 靶子 | 题型 | errorCode | 耗时 | 结果 | ticket |
+
+
+
+
+
-
- 历史任务
-
-
- | 时间 | 靶子 | 题型 | errorCode | 耗时 | 点位 | ticket |
-
-
-
-
-
+
+
点击任意处 / Esc 关闭
+
![放大查看]()
+
+