/* 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) => ``) .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 = `${hhmmss()}`; 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(`底图`); if (d.prompt_url) imgs.push(`提示图`); card.innerHTML = `
第 ${d.index} 题 · ${d.kind} ${d.instruction || ''}
${imgs.join('')}
求解中…
`; 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 = `
errorCode
${d.errorCode ?? '—'} ${info.text}
耗时
${fmtMs(d.elapsed_ms)}
randstr
${d.randstr || '—'}
ticket
${d.ticket || '—'}
`; } 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', `
结果
${d.success ? '成功' : (d.errorCode ?? '失败')}
抽题次数
${st.challenges ?? '—'}
点位总数
${st.points ?? '—'}
平均偏离中心
${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'; } /* ------------------------------------------------------------ 历史任务 */ 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 = ` ${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) + '…' : '—'}`; 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 = '

等待事件…

'; state.cards = {}; } boot();