import type { Page } from "playwright"; import { RPA_PAGE_GOTO_TIMEOUT_MS } from "@/lib/constants/rpa"; const GOTO_STRATEGIES = ["domcontentloaded", "commit", "load"] as const; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export function readPageGotoTimeoutMs(): number { const raw = process.env.RPA_PAGE_GOTO_TIMEOUT_MS?.trim(); if (!raw) { return RPA_PAGE_GOTO_TIMEOUT_MS; } const parsed = Number(raw); return Number.isFinite(parsed) && parsed >= 10_000 ? parsed : RPA_PAGE_GOTO_TIMEOUT_MS; } function readGotoRetryCount(): number { const raw = process.env.RPA_GOTO_RETRY_COUNT?.trim(); const parsed = Number(raw); if (Number.isFinite(parsed) && parsed >= 1 && parsed <= 5) { return Math.floor(parsed); } return 3; } /** 网络层瞬时错误 — 可重试 */ export function isTransientNavigationError(error: unknown): boolean { const msg = error instanceof Error ? error.message : String(error ?? ""); return /ERR_CONNECTION_RESET|ERR_CONNECTION_CLOSED|ERR_NETWORK_CHANGED|ERR_TIMED_OUT|ECONNRESET|ETIMEDOUT|NS_ERROR_NET_RESET|net::ERR_/i.test( msg, ); } /** Priority1 等外站导航失败 — 用户可读中文 */ export function formatExternalSiteNavigationError( siteLabel: string, error: unknown, ): string { const detail = error instanceof Error ? error.message : String(error ?? ""); if (/ERR_CONNECTION_RESET|ECONNRESET/i.test(detail)) { return ( `无法连接 ${siteLabel}(连接被重置)。` + "请确认本机或服务器可访问该官网,或联系管理员检查网络与代理配置后重试。" ); } if (/ERR_TIMED_OUT|ETIMEDOUT|Timeout/i.test(detail)) { return ( `打开 ${siteLabel} 超时。` + "请稍后重试,或联系管理员检查网络与代理配置。" ); } return `无法打开 ${siteLabel},请稍后重试或联系管理员`; } /** * page.goto 多策略 + 瞬时网络错误重试(与 MotherShip quote-page-adapter 一致并增强) */ export async function gotoWithResilience( page: Page, url: string, options?: { timeoutMs?: number }, ): Promise { const timeout = options?.timeoutMs ?? readPageGotoTimeoutMs(); const maxAttempts = readGotoRetryCount(); let lastError: Error | undefined; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { for (const waitUntil of GOTO_STRATEGIES) { try { await page.goto(url, { waitUntil, timeout }); return; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); } } const transient = isTransientNavigationError(lastError); if (attempt < maxAttempts && transient) { const backoffMs = 1_500 * attempt; console.warn( `[page-goto] 第 ${attempt}/${maxAttempts} 次打开失败,${backoffMs}ms 后重试: ${url}`, ); await sleep(backoffMs); continue; } break; } throw lastError ?? new Error(`无法打开 ${url}`); }