Files
hello_js_reverse_skill/tcaptcha/web/static/app.js
T
yml2213 99565f65f0 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
2026-10-14 19:10:54 +08:00

295 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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();