// ==UserScript== // @name ehentai-go pre-download helper // @namespace https://localhost/ehentai-go // @version 5.1.0 // @description e-hentai 画廊页一键「Send & Push」: 解析画廊元信息 + 单页 URL 列表 → POST 给本机 ehentai-go 服务, 然后并发把缺失图片二进制推到后端。所有抓图都在浏览器完成, 绕开 Go 端被 e-hentai TLS fingerprint 拦截的问题。支持刷新/跳转前的放弃确认、按 title 续传、固定状态面板、失败自动 retry。 // @match https://e-hentai.org/g/* // @match https://exhentai.org/g/* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @run-at document-idle // @noframes // ==/UserScript== (function () { 'use strict'; const API_BASE = 'http://localhost:8990'; const PANEL_ID = 'ehg-panel'; const BTN_ID = 'ehg-btn'; const CONCURRENCY = 5; const PAGE_URL_TIMEOUT_MS = 10000; const IMAGE_FETCH_TIMEOUT_MS = 60000; GM_addStyle(` #${PANEL_ID} { position: fixed; left: 16px; bottom: 16px; z-index: 99999; width: 440px; max-width: calc(100vw - 32px); background: #1f2937; color: #e5e7eb; border: 1px solid #374151; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.35); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; overflow: hidden; user-select: none; } #${PANEL_ID} .ehg-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: #111827; border-bottom: 1px solid #374151; cursor: move; } #${PANEL_ID} .ehg-title { font-weight: 600; font-size: 13px; letter-spacing: .04em; text-transform: uppercase; color: #9ca3af; } #${PANEL_ID} .ehg-stage { font-size: 12px; padding: 3px 10px; border-radius: 999px; background: #374151; color: #d1d5db; } #${PANEL_ID} .ehg-stage.idle { background: #374151; color: #d1d5db; } #${PANEL_ID} .ehg-stage.run { background: #1d4ed8; color: #fff; } #${PANEL_ID} .ehg-stage.ok { background: #15803d; color: #fff; } #${PANEL_ID} .ehg-stage.warn { background: #b45309; color: #fff; } #${PANEL_ID} .ehg-stage.err { background: #b91c1c; color: #fff; } #${PANEL_ID} .ehg-body { padding: 12px 14px 14px; } #${PANEL_ID} .ehg-status { font-size: 13px; color: #e5e7eb; margin-bottom: 8px; word-break: break-all; } #${PANEL_ID} .ehg-progress { height: 7px; background: #374151; border-radius: 4px; overflow: hidden; margin-bottom: 10px; } #${PANEL_ID} .ehg-progress > span { display: block; height: 100%; width: 0; background: linear-gradient(90deg, #4f46e5, #22c55e); transition: width .25s ease; } #${PANEL_ID} .ehg-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; font-size: 12px; color: #9ca3af; margin-bottom: 12px; } #${PANEL_ID} .ehg-stats b { display: block; color: #f9fafb; font-size: 16px; font-variant-numeric: tabular-nums; } #${BTN_ID} { width: 100%; padding: 11px 14px; color: #fff; border: 0; border-radius: 6px; font: 600 14px/1 inherit; cursor: pointer; background: #4f46e5; transition: background .15s ease; } #${BTN_ID}:hover { background: #4338ca; } #${BTN_ID}:disabled { background: #4b5563; cursor: wait; opacity: .8; } #${BTN_ID}.ok { background: #16a34a; } #${BTN_ID}.err { background: #dc2626; } #${BTN_ID}.warn { background: #d97706; } #${PANEL_ID}.ehg-collapsed .ehg-body { display: none; } #${PANEL_ID} .ehg-toggle { background: transparent; border: 0; color: #9ca3af; cursor: pointer; font-size: 14px; padding: 0 4px; margin-left: 8px; } `); // ---- 工具 ---- function sanitize(name) { let s = name.replace(/[:"*?|<>/\\\n]/g, '-'); s = s.replace(/-+/g, '-'); s = s.trim().replace(/^\.+|\.+$/g, '').trim(); return s || 'untitled'; } function extractSinglePageURLs(html, baseURL) { const startIdx = html.indexOf('
', i); if (end < 0) throw new Error('.ptt 表格未闭合'); const seg = html.slice(i, end); const reTD = /]*>([\s\S]*?)<\/td>/g; let max = 0; let m; while ((m = reTD.exec(seg)) !== null) { const text = m[1].replace(/<[^>]+>/g, '').trim(); const n = parseInt(text, 10); if (!Number.isNaN(n) && n > max) max = n; } if (max === 0) throw new Error('.ptt 表格里没找到数字页码'); return max; } function extractRawTitle(html) { let m = /

([^<]+)<\/h1>/.exec(html); if (m) return m[1].trim(); m = /

([^<]+)<\/h1>/.exec(html); if (m) return m[1].trim(); return ''; } function extractImageURL(html) { // fullimg 通常是原图链接;id="img" 有时只是当前页面预览图。 const patterns = [ /') .replace(/"/g, '"') .replace(/'/g, "'"); } function resolveURL(base, value) { try { const url = new URL(value, base); return (url.protocol === 'http:' || url.protocol === 'https:') ? url.href : value; } catch { return value; } } function gmFetch(url, opts = {}) { return new Promise((resolve, reject) => { const requestOpts = { method: opts.method || 'GET', url, // 默认按浏览器请求发送 cookie;需要匿名请求时显式传 anonymous: true。 anonymous: opts.anonymous === true, headers: opts.headers || {}, data: opts.body, responseType: opts.responseType || 'text', }; if (opts.timeout !== undefined) requestOpts.timeout = opts.timeout; GM_xmlhttpRequest({ ...requestOpts, onload: (r) => { if (r.status >= 200 && r.status < 300) { resolve({ status: r.status, text: r.responseText, blob: r.response, headers: r.responseHeaders || '', }); } else { const err = new Error(`HTTP ${r.status} for ${url}`); err.status = r.status; reject(err); } }, onerror: (e) => reject(new Error(`network error: ${e.error || 'unknown'} for ${url}`)), ontimeout: () => reject(new Error(`timeout for ${url}`)), }); }); } function joinPageURL(base, page) { const u = new URL(base); u.searchParams.set('p', String(page)); return u.href; } function guessExt(mime, url) { if (mime === 'image/jpeg' || mime === 'image/jpg') return '.jpg'; if (mime === 'image/png') return '.png'; if (mime === 'image/webp') return '.webp'; if (mime === 'image/gif') return '.gif'; if (mime === 'image/bmp') return '.bmp'; if (mime === 'image/avif') return '.avif'; const noQuery = url.split('?')[0]; const i = noQuery.lastIndexOf('.'); if (i > noQuery.length - 7) return noQuery.slice(i).toLowerCase(); return '.bin'; } function formatBytes(n) { if (!Number.isFinite(n) || n <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; let i = 0; while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } function formatETA(seconds) { if (!Number.isFinite(seconds) || seconds < 0) return '--'; seconds = Math.round(seconds); if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m${seconds % 60}s`; return `${Math.floor(seconds / 3600)}h${Math.floor((seconds % 3600) / 60)}m`; } // ---- 面板 / 状态 ---- // 单一状态对象,所有 UI 更新都通过 setState 走,避免散落 DOM 写。 const state = { title: '', stage: 'idle', // idle | run | ok | warn | err statusText: '就绪', total: 0, done: 0, failed: 0, active: 0, startedAt: 0, bytesDone: 0, currentKey: '', }; function renderPanel() { const stageEl = document.getElementById('ehg-stage'); if (stageEl) { stageEl.className = 'ehg-stage ' + state.stage; stageEl.textContent = state.stage; } const setText = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v; }; setText('ehg-status', state.statusText); setText('ehg-stat-total', String(state.total)); setText('ehg-stat-done', String(state.done)); setText('ehg-stat-fail', String(state.failed)); setText('ehg-stat-active', String(state.active)); const pct = state.total > 0 ? Math.min(100, ((state.done + state.failed) / state.total) * 100) : 0; const bar = document.getElementById('ehg-progress-bar'); if (bar) bar.style.width = pct.toFixed(1) + '%'; } function setStatus(text) { state.statusText = text; renderPanel(); } function setStage(stage, text) { state.stage = stage; if (text) state.statusText = text; renderPanel(); } function mountPanel() { if (document.getElementById(PANEL_ID)) return; const panel = document.createElement('div'); panel.id = PANEL_ID; panel.innerHTML = `
ehentai-go idle
就绪
Total0
Done0
Active0
Fail0
`; document.body.appendChild(panel); const toggle = panel.querySelector('.ehg-toggle'); toggle.addEventListener('click', () => panel.classList.toggle('ehg-collapsed')); let dragDX = 0, dragDY = 0, dragging = false; const head = panel.querySelector('.ehg-head'); head.addEventListener('mousedown', (e) => { // 点击 toggle 不进入拖拽 if (e.target.closest('.ehg-toggle')) return; dragging = true; const rect = panel.getBoundingClientRect(); panel.style.left = rect.left + 'px'; panel.style.top = rect.top + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; dragDX = e.clientX - rect.left; dragDY = e.clientY - rect.top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; const x = Math.max(0, Math.min(window.innerWidth - 100, e.clientX - dragDX)); const y = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - dragDY)); panel.style.left = x + 'px'; panel.style.top = y + 'px'; }); document.addEventListener('mouseup', () => { dragging = false; }); renderPanel(); } // ---- 离开页面保护:下载中刷新/关 tab/点链,提示用户 ---- let busyGuard = null; function installLeaveGuard() { // beforeunload:刷新/关 tab 时浏览器会显示原生确认框。 window.addEventListener('beforeunload', (e) => { if (!busyGuard) return; e.preventDefault(); e.returnValue = busyGuard; return busyGuard; }); // 点页内链接跳转时,拦下来 confirm。 document.addEventListener('click', (e) => { if (!busyGuard) return; const a = e.target.closest && e.target.closest('a[href]'); if (!a) return; const href = a.getAttribute('href'); if (!href || href.startsWith('#') || href.startsWith('javascript:')) return; if (a.target === '_blank') return; // 油猴菜单 / 自身按钮不拦 if (a.id === BTN_ID || a.closest(`#${PANEL_ID}`)) return; if (!window.confirm(busyGuard)) { e.preventDefault(); e.stopPropagation(); } }, true); } function setBusy(message) { busyGuard = message || null; } // ---- 阶段 A:解析首页 + 翻页收集所有单URL---- async function collectSinglePageURLs(galleryURL, setStatus) { const firstURL = joinPageURL(galleryURL, 0); setStatus('拉取首页...'); const firstHTML = (await gmFetch(firstURL)).text; const pagesTotal = extractPagesLength(firstHTML); const rawTitle = extractRawTitle(firstHTML); if (!rawTitle) throw new Error('未找到画廊标题 (h1#gj / h1#gn)'); const title = sanitize(rawTitle); setStatus(`解析单页 0/?…`); const singlePageURLs = new Set(extractSinglePageURLs(firstHTML, firstURL)); for (let p = 1; p < pagesTotal; p++) { try { const pageListURL = joinPageURL(galleryURL, p); const body = (await gmFetch(pageListURL)).text; for (const u of extractSinglePageURLs(body, pageListURL)) { singlePageURLs.add(u); } } catch (e) { console.warn(`?p=${p} 失败:`, e.message); } setStatus(`解析单页 ${singlePageURLs.size}/?…`); } if (singlePageURLs.size === 0) throw new Error('未收集到任何单页 URL'); return { title, rawTitle, orderedURLs: Array.from(singlePageURLs) }; } // ---- 阶段 B:续传判断 + 保存元信---- // 核心:先拿画廊标题问后端「这JSON 在不/ 还有几张没下 // 已存在就跳过翻页解析,直接进入 push async function prepareGallery(galleryURL, setStatus) { // 只用首页就能拿到 title,所以不翻页也能判断 setStatus('解析首页拿到标题...'); const firstHTML = (await gmFetch(joinPageURL(galleryURL, 0))).text; const rawTitle = extractRawTitle(firstHTML); if (!rawTitle) throw new Error('未找到画廊标题 (h1#gj / h1#gn)'); const title = sanitize(rawTitle); const checkData = JSON.parse((await gmFetch(`${API_BASE}/api/galleries/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }), })).text); if (checkData.exists) { // 续传:不再解析单页 URL 列表,直接读后端的 missing 列表。 // 后端 check 接口会一并返回已有的 pages 映射(0001->单页URL …), // 我们把它转成 orderedURLs,这样 pageURLByKey 会被填上, // worker 后续在 pushMissingImages 里才能用单页URL 重新解析图片直链去重试。 // 否则 pageURLByKey 是空的,worker 拿到 key 查不到 URL 就直接跳过, // 导致 missing 的图永远补不上。 const pageMap = checkData.pages || {}; const orderedURLs = Object.keys(pageMap) .sort((a, b) => parseInt(a, 10) - parseInt(b, 10)) .map((k) => pageMap[k]); setStatus(`发现已存在JSON (共 ${orderedURLs.length} 张),跳过翻页解析,直接续传...`); return { title, rawTitle, orderedURLs, resumed: true }; } // 全新:解析全部单页 URL,再保存 const collected = await collectSinglePageURLs(galleryURL, setStatus); setStatus('保存元信息...'); const pages = {}; collected.orderedURLs.forEach((u, i) => { pages[String(i + 1).padStart(4, '0')] = u; }); await gmFetch(`${API_BASE}/api/galleries/save`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gallery_name: collected.title, raw_title: collected.rawTitle, source_url: galleryURL, total_pages: collected.orderedURLs.length, fetched_at: new Date().toISOString(), pages, }), }); return { title: collected.title, rawTitle, orderedURLs: collected.orderedURLs, resumed: false }; } // 单张图片重试 3 次 async function pushOneWithRetry(title, key, pageURL, workerId) { const MAX_ATTEMPTS = 3; let lastErr = null; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { const r = await pushOne(title, key, pageURL, attempt, MAX_ATTEMPTS); return { ok: true, bytes: r.bytes }; } catch (e) { lastErr = e; console.warn( `[${key}] attempt ${attempt}/${MAX_ATTEMPTS} failed; pageURL (上一级)=${pageURL}; ${e.message}` ); if (attempt < MAX_ATTEMPTS) { const wait = 1000 << (attempt - 1); setStatus(`retry ${attempt}/${MAX_ATTEMPTS - 1} ${key} (w${workerId})…`); await new Promise((r) => setTimeout(r, wait)); } } } return { ok: false, err: lastErr }; } async function pushOne(title, key, pageURL, attempt = 1, maxAttempts = 1) { // 页面和原图请求要带浏览器 cookie;匿名请求拿到的页面/直链可能与浏览器不同。 const pageResp = await gmFetch(pageURL, { timeout: PAGE_URL_TIMEOUT_MS, anonymous: false, headers: { Referer: location.href, 'X-Alt-Referer': location.href, }, }); const rawImgURL = extractImageURL(pageResp.text); if (!rawImgURL) throw new Error('单页未匹配到图片直链正则'); const imgURL = resolveURL(pageURL, rawImgURL); if (attempt > 1) { console.warn( `[${key}] retry ${attempt}/${maxAttempts}; pageURL (上一级)=${pageURL}; directURL=${imgURL}` ); } const imgResp = await gmFetch(imgURL, { responseType: 'blob', timeout: IMAGE_FETCH_TIMEOUT_MS, anonymous: false, headers: { Referer: pageURL, 'X-Alt-Referer': pageURL, 'Cache-Control': 'no-cache', }, }); const blob = imgResp.blob; if (!blob || blob.size < 1024) { throw new Error(`图片太小 (${blob ? blob.size : 0}B),疑似限流占位`); } const ext = guessExt(blob.type, imgURL); const fd = new FormData(); fd.append('file', blob, key + ext); const resp = JSON.parse((await gmFetch( `${API_BASE}/api/galleries/${encodeURIComponent(title)}/images/${key}`, { method: 'POST', body: fd } )).text); return { bytes: resp.bytes || blob.size }; } // ---- 阶段 C:并发 push 缺失图片,失败自动循环直到全完 ---- // 流程:每轮重新问后端缺图清单 → 并发 5 跑(单张内 3 次重试) → 若还有 missing, // sleep RETRY_DELAY 秒 → 重新查缺图 → 再跑。无限循环,直到 missing=0 或达到 // 全局最大轮数(防止某些图永远下不下来时把脚本卡死)。 async function pushMissingImages(title, pageURLByKey, totalPages, setStatus, stopRequested) { const RETRY_DELAY_MS = 5000; // 失败后等多久再重试整轮 const MAX_ROUNDS = 999; // 安全上限:重试到 999 还没好就自动停,大概 999 * 5s ≈ 83 分钟 const totalSessionPages = Math.max(totalPages, 0); let totalDone = 0; let totalFailedKeys = new Set(); let round = 0; while (true) { if (stopRequested && stopRequested()) { return { done: totalDone, failed: totalFailedKeys.size, stopped: true }; } round++; if (round > MAX_ROUNDS) { // 兜底:跑了 MAX_ROUNDS 轮还missing,放弃,告诉用户 return { done: totalDone, failed: totalFailedKeys.size, failedKeys: Array.from(totalFailedKeys), skipped: false, gaveUp: true, rounds: round - 1, }; } setStatus(`retry ${round}: 查询后端缺图清单…`); const missingResp = (await gmFetch( `${API_BASE}/api/galleries/${encodeURIComponent(title)}/missing-images` )).text; const missingObj = JSON.parse(missingResp); const missing = missingObj.missing || []; const serverTotal = missingObj.total | 0; // total 用后端返回的画廊真实总页只在首轮确定一 // 后续轮次哪missing 减少也不这样分母始终是「整本画廊不会算重 if (state.total === 0) { state.total = serverTotal || totalSessionPages || missing.length; } // 续传场景修复:用户刷新页面 / 中断后重新跑时,state.done 必须从 // 「后端已经存了 N 张」起算,不能从 0 起算。后端「已下载数量」 // = total - missing.length。在第一轮初始化一次,后续轮别动它。 if (round === 1 && totalDone === 0) { const alreadyDone = Math.max(0, state.total - missing.length); if (alreadyDone > 0) { totalDone = alreadyDone; state.done = totalDone; setStatus(`retry ${round}: 后端已有 ${alreadyDone} 张,继续补 ${missing.length} 张…`); } } if (missing.length === 0) { return { done: totalDone, failed: 0, failedKeys: [], skipped: totalDone === 0 && totalSessionPages === 0, }; } const queue = missing.slice(); const total = missing.length; let done = 0; let failed = 0; const failedKeys = []; async function worker(workerId) { while (queue.length > 0) { const key = queue.shift(); if (!key) return; const localPageURL = pageURLByKey[key]; state.active++; state.currentKey = key; renderPanel(); const t0 = performance.now(); if (!localPageURL) { failed++; failedKeys.push(key); state.failed = totalFailedKeys.size + failed; state.active--; setStatus(`retry ${round} ${key} 无 URL,跳过 (${done + failed}/${total})`); continue; } setStatus(`retry ${round} ${key} (w${workerId}) ${done + failed + 1}/${total})`); const r = await pushOneWithRetry(title, key, localPageURL, workerId); const dt = (performance.now() - t0) / 1000; if (r.ok) { done++; totalDone++; state.done = totalDone; state.bytesDone += r.bytes || 0; setStatus(`retry ${round} ${done}/${total} (${failed} fail, w${workerId}, ${formatBytes(r.bytes)}, ${dt.toFixed(1)}s)`); } else { failed++; failedKeys.push(key); state.failed = totalFailedKeys.size + failed; setStatus(`retry ${round} ${key} 失败: ${r.err && r.err.message} (${done + failed}/${total})`); } state.active--; renderPanel(); } } await Promise.all( Array.from({ length: CONCURRENCY }, (_, i) => worker(i + 1)) ); failedKeys.forEach((k) => totalFailedKeys.add(k)); state.failed = totalFailedKeys.size; // 每轮结束后显式 GET 后端做完整性确认 —— 单纯靠 "worker 报告全成功" 判断 done // 不够稳:可能后端响应 200 但实际没存(磁盘满 / race / 响应阶段被前端断开),此时 // 后端 missing 仍 > 0,如果不验证就 break 会误报 done。后端才是真相来源。 setStatus(`retry ${round} 结束,问后端确认完整性…`); const verifyResp = (await gmFetch( `${API_BASE}/api/galleries/${encodeURIComponent(title)}/missing-images` )).text; const verifyObj = JSON.parse(verifyResp); const stillMissing = (verifyObj.missing || []).length; if (stillMissing === 0) { // 后端确认该画廊图片齐全了 → 真的 done return { done: totalDone, failed: 0, failedKeys: [], skipped: false, }; } // 还有 missing。等 5 秒再下一轮(下一轮开头会重新 GET 最新 missing 列表)。 if (failed === 0) { setStatus(`retry ${round} worker 全成功,但后端仍缺 ${stillMissing} 张,${RETRY_DELAY_MS / 1000}s 后重试…`); } else { setStatus(`retry ${round} 失败 ${failed} 张,后端仍缺 ${stillMissing} 张,${RETRY_DELAY_MS / 1000}s 后重试…`); } await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); } return { done: totalDone, failed: totalFailedKeys.size, failedKeys: Array.from(totalFailedKeys), skipped: false, }; } // ---- 入口 ---- // runState 跟踪当前任务的生命周期: idle | running | stopping。 // 跑中按按钮会请求 stop,下一轮 sleep 时退出循环。 let runState = 'idle'; async function onOneClick(btn) { if (runState === 'running') { // 已经在跑,这次点击当作停止请求。 runState = 'stopping'; setStage('warn', '收到停止请求,本轮结束后退出'); btn.textContent = 'Stopping'; btn.disabled = true; return; } runState = 'running'; const galleryURL = location.href; btn.disabled = true; btn.classList.remove('ok', 'err', 'warn'); state.total = 0; state.done = 0; state.failed = 0; state.active = 0; state.bytesDone = 0; state.startedAt = Date.now(); state.currentKey = ''; setStage('run', '准备'); btn.textContent = 'Stop'; setBusy('ehentai-go 正在下载图片,放弃当前进度?'); try { // 阶段 A + B:解析/续传 + 保存元信息 const { title, orderedURLs, resumed } = await prepareGallery(galleryURL, setStatus); state.title = title; // 续传模式下 pageURLByKey 留空,单张失败时再回查单页 URL。 // 但更稳妥的做法是续传时也解析一次首页拿到所有单页 URL 供失败重试用。 // 这里采取折中:续传时也跑一次轻量解析(只取首页 + ?p=1 等尽量少的页), // 但 missing 为 0 时直接结束,不会走完整解析。 let pageURLByKey = {}; if (orderedURLs) { orderedURLs.forEach((u, i) => { pageURLByKey[String(i + 1).padStart(4, '0')] = u; }); } // 阶段 C:并发 push 图片(失败自动循环,无需点重试) const result = await pushMissingImages( title, pageURLByKey, orderedURLs ? orderedURLs.length : 0, setStatus, () => runState === 'stopping' ); setBusy(null); runState = 'idle'; const elapsed = (Date.now() - state.startedAt) / 1000; const summary = `用时 ${formatETA(elapsed)} 共 ${formatBytes(state.bytesDone)}`; if (runState === 'stopping' || (result.stopped)) { setStage('warn', `[STOP] ${title} ${result.done} ok / ${result.failed} 未完成 (${summary})`); setButtonState(btn, `Start`, null); } else if (result.skipped) { setStage('ok', `[OK] ${title} 已完整 (${summary})`); setButtonState(btn, `Start`, 'ok'); } else if (result.failed === 0) { setStage('ok', `[OK] ${title} +${result.done} (${summary})`); setButtonState(btn, `Start`, 'ok'); } else if (result.gaveUp) { setStage('err', `[GIVE UP] ${title} ${result.failed} 张始终失败(>${result.rounds} 轮) (${summary})`); setButtonState(btn, `Start`, 'err'); } else { // 理论上跑不到这里(自动循环直到 0 missing 或 gaveUp),留个兜底文案。 setStage('warn', `${title} ${result.done} ok / ${result.failed} 未完成 (${summary})`); setButtonState(btn, `Start`, 'warn'); } } catch (e) { console.error(e); setBusy(null); runState = 'idle'; setStage('err', `[ERR] ${e.message}`); setButtonState(btn, `Start`, 'err'); } } function setButtonState(btn, text, cls) { btn.textContent = text; btn.classList.remove('ok', 'err', 'warn'); if (cls) btn.classList.add(cls); btn.disabled = false; btn.onclick = null; if (btn.__handler) btn.addEventListener('click', () => btn.__handler(btn)); } mountPanel(); installLeaveGuard(); const btn = document.getElementById(BTN_ID); btn.__handler = onOneClick; btn.addEventListener('click', () => onOneClick(btn)); })();