'use strict'; function safeMessage(error) { return String(error?.message || error || 'Unknown error').replace(/https?:\/\/[^\s]+/gi, raw => { try { const u = new URL(raw); return u.origin + u.pathname; } catch { return '[source URL]'; } }).slice(0, 1500); } function classifyFailure(error) { const message = safeMessage(error); const status = Number(error?.status || /(?:HTTP|returned)\s+(\d{3})/i.exec(message)?.[1]); const code = error?.code || error?.cause?.code; const failure = (kind, scope, retryable = false) => ({ kind, scope, retryable, message, ...(status ? { httpStatus: status } : {}) }); if ([401, 403, 429].includes(status) || /captcha|cloudflare|challenge|access.denied|account.ban|paywall|login required/i.test(message)) return failure('access', 'source'); if (['ENOSPC', 'EROFS', 'EACCES', 'EPERM', 'EIO'].includes(code)) return failure('storage', 'global'); if (error?.failureKind === 'unavailable' || /No downloadable English|title was not found|HTTP 404/i.test(message)) return failure('unavailable', 'job'); if (error?.failureKind === 'transient' || status >= 500 || /network|fetch failed|timed? ?out|timeout|socket|connection reset/i.test(message) || ['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(code)) return failure('transient', 'source', true); return failure('adapter', 'job'); } function progressKey(job) { return JSON.stringify([job?.completed || 0, job?.total || 0, job?.current?.sourceId || '', job?.current?.downloadedPages || 0, job?.lastChapter || '']); } module.exports = { classifyFailure, safeMessage, progressKey };