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
+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); }