import type { Page } from "playwright"; import { parseMothershipQuoteUrls, validateMothershipQuoteUrls, } from "@/lib/rpa/env"; import { RPA_PAGE_GOTO_TIMEOUT_MS, RPA_SESSION_WIDGET_TIMEOUT_MS, } from "@/lib/constants/rpa"; import { RpaError } from "@/modules/rpa/errors"; import { hasMothershipCredentials, mapUrlToErrorCode, } from "@/modules/rpa/error-mapper"; import { getStorageStateForContext, invalidateStorageState, persistContext, } from "@/lib/rpa/storage-state"; import { waitForQuoteWidgetReady } from "@/workers/rpa/address-side"; import { stabilizeQuoteLandingPage } from "@/workers/rpa/page-prep"; const LOGIN_URL_PATTERNS = ["/login", "/sign-in", "/signin"]; const CLOUDFLARE_SELECTORS = "#challenge-running, .cf-browser-verification, iframe[src*='challenges.cloudflare.com']"; const HUMAN_ENTRY_CLICK_SPECS = [ 'role=link[name=/get.*quote/i]', 'role=button[name=/get.*quote/i]', "text=Get a Quote", "text=Get freight quote", "text=获取货运报价", ]; export type QuotePageOpenOptions = { /** 复用 quote_session storageState:跳过首页预热、缩短 widget 等待 */ reuseSession?: boolean; }; export type QuotePageAdapter = { resolveQuoteEntry( page: Page, options?: QuotePageOpenOptions, ): Promise<{ activeUrl: string }>; preCheck(page: Page, options?: QuotePageOpenOptions): Promise; isLoginWithoutCredentials(page: Page): boolean; }; function isLoginUrl(url: string): boolean { const lower = url.toLowerCase(); return LOGIN_URL_PATTERNS.some((pattern) => lower.includes(pattern)); } 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; } /** domcontentloaded 超时后回退 commit/load,应对慢网或长连接阻塞 */ async function openQuoteEntryUrl(page: Page, url: string): Promise { const timeout = readPageGotoTimeoutMs(); const strategies: Array<"domcontentloaded" | "commit" | "load"> = [ "domcontentloaded", "commit", "load", ]; let lastError: Error | undefined; for (const waitUntil of strategies) { try { await page.goto(url, { waitUntil, timeout }); return; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); } } throw lastError ?? new RpaError("QUOTE_ENTRY_UNAVAILABLE", `无法打开 ${url}`, { retryable: false, }); } /** 模拟真人:首页预热 → 点击报价入口,携带 Referer/Cookie */ async function runHumanEntryWarmup(page: Page): Promise { if (process.env.RPA_HUMAN_ENTRY_FLOW !== "true") { return; } const warmupUrl = process.env.RPA_QUOTE_WARMUP_URL?.trim() || "https://www.mothership.com/"; await openQuoteEntryUrl(page, warmupUrl); await page.waitForTimeout(800); for (const spec of HUMAN_ENTRY_CLICK_SPECS) { const target = page.locator(spec).first(); const visible = await target.isVisible().catch(() => false); if (!visible) { continue; } await target.click({ timeout: 8_000 }).catch(() => undefined); await page .waitForLoadState("domcontentloaded", { timeout: 10_000 }) .catch(() => undefined); console.log(`[rpa] human-entry: clicked ${spec}`); return; } console.warn("[rpa] human-entry: 未找到报价入口按钮,继续直接打开 URL"); } export class MothershipQuotePageAdapter implements QuotePageAdapter { isLoginWithoutCredentials(page: Page): boolean { return isLoginUrl(page.url()) && !hasMothershipCredentials(); } async preCheck( page: Page, options?: QuotePageOpenOptions, ): Promise { const url = page.url(); const urlCode = mapUrlToErrorCode(url); if (urlCode === "RPA_CAPTCHA") { throw new RpaError("RPA_CAPTCHA", "检测到验证码页面", { retryable: false, pauseWorker: true, }); } if (this.isLoginWithoutCredentials(page)) { throw new RpaError( "STRUCT_CHANGE", "login 页且无 Mothership 凭据(匿名入口失效)", { retryable: false }, ); } if (urlCode === "SESSION_EXPIRED") { throw new RpaError("SESSION_EXPIRED", "会话已过期,需重登", { retryable: true, }); } const cfCount = await page.locator(CLOUDFLARE_SELECTORS).count(); if (cfCount > 0) { throw new RpaError("RPA_CAPTCHA", "检测到 Cloudflare 验证", { retryable: false, pauseWorker: true, }); } const bodyText = await page.locator("body").innerText().catch(() => ""); if (/page not found|404|doesn't exist|has been moved/i.test(bodyText)) { throw new RpaError("STRUCT_CHANGE", "报价页 404 或不可达", { retryable: false, }); } await waitForQuoteWidgetReady( page, options?.reuseSession ? RPA_SESSION_WIDGET_TIMEOUT_MS : undefined, ); return true; } async resolveQuoteEntry( page: Page, options?: QuotePageOpenOptions, ): Promise<{ activeUrl: string }> { const urls = parseMothershipQuoteUrls(process.env.MOTHERSHIP_QUOTE_URLS); const urlErrors = validateMothershipQuoteUrls(urls); if (urlErrors.length > 0) { throw new RpaError( "QUOTE_ENTRY_UNAVAILABLE", urlErrors.join(";"), { retryable: false }, ); } const failures: string[] = []; if (!options?.reuseSession) { await runHumanEntryWarmup(page); } for (const url of urls) { try { await openQuoteEntryUrl(page, url); await page .waitForLoadState("domcontentloaded", { timeout: 10_000 }) .catch(() => undefined); await stabilizeQuoteLandingPage(page); if (this.isLoginWithoutCredentials(page)) { invalidateStorageState(); await openQuoteEntryUrl(page, url); await stabilizeQuoteLandingPage(page); if (this.isLoginWithoutCredentials(page)) { throw new RpaError( "STRUCT_CHANGE", `入口 ${url} 要求登录且无凭据`, { retryable: false }, ); } } return { activeUrl: url }; } catch (error) { const message = error instanceof Error ? error.message : String(error ?? "unknown"); failures.push(`${url}: ${message}`); } } throw new RpaError( "QUOTE_ENTRY_UNAVAILABLE", `全部报价入口不可用:${failures.join(" | ")}(请检查网络/VPN、.rpa/mothership-storage.json 是否有效,或设 RPA_HEADLESS=false 重录登录态)`, { retryable: false }, ); } } export const quotePageAdapter = new MothershipQuotePageAdapter();