import type { BrowserContext, Page } from "playwright"; import { REQUIRED_QUOTE_TIERS } from "@/lib/constants/quote"; import { isRpaMockMode } from "@/lib/rpa/selectors"; import type { QuoteItem, QuoteProvider, QuoteRequest, QuoteResult, } from "@/modules/providers/quote-provider"; import { RpaError } from "@/modules/rpa/errors"; import { getConfidenceScore } from "@/modules/quote/confidence"; import { fetchAxelQuoteItems } from "@/lib/axel/quote-from-request"; import { isParkedSessionEnabled, shouldKeepBrowserOpen, isWidgetQuoteFallbackEnabled, } from "@/lib/rpa/env"; import { getRpaAddressMode, isRpaAddressMockMode } from "@/lib/rpa/address-mode"; import { storageStateExists } from "@/lib/rpa/storage-state"; import { RPAContext } from "@/workers/rpa/kernel/context"; import { runMothershipQuoteKernel } from "@/workers/rpa/kernel/orchestrator"; import { fillAddressStep } from "@/workers/rpa/kernel/steps/address-step"; import { fillCargoStep } from "@/workers/rpa/kernel/steps/cargo-step"; import { validateResult, } from "@/workers/rpa/kernel/steps/quote-step"; import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter"; import { openQuotePage, persistContext, withRpaSessionLock, } from "@/workers/rpa/session-manager"; import { takeParkedQuoteSession, prepareParkedQuotePage, } from "@/workers/rpa/parked-quote-session"; import { captureRpaDebugScreenshot } from "@/workers/rpa/page-prep"; function mockQuoteItems(): QuoteItem[] { const base = [ { freight: 320, surcharges: 45, days: "3-5" }, { freight: 380, surcharges: 45, days: "2-3" }, { freight: 420, surcharges: 50, days: "3-5" }, { freight: 480, surcharges: 50, days: "2-3" }, ]; return REQUIRED_QUOTE_TIERS.map((tier, index) => { const sample = base[index] ?? base[0]; const rawTotal = sample.freight + sample.surcharges; return { serviceLevel: tier.service_level as QuoteItem["serviceLevel"], rateOption: tier.rate_option as QuoteItem["rateOption"], carrier: "Mock Carrier", transitDays: sample.days, transitDescription: `${sample.days} business days`, rawFreight: sample.freight, surcharges: sample.surcharges, rawTotal, }; }); } export { validateResult } from "@/workers/rpa/kernel/steps/quote-step"; /** @deprecated 使用 fillAddressStep(ctx, req) */ export async function fillAddress(page: Page, req: QuoteRequest): Promise { await fillAddressStep(RPAContext.fromSession(page), req); } /** @deprecated 使用 fillCargoStep(ctx, req) */ export async function fillCargo(page: Page, req: QuoteRequest): Promise { await fillCargoStep(RPAContext.fromSession(page), req); } /** @deprecated 使用 runMothershipQuoteKernel / captureQuotesDirectFirst */ export async function scrapeQuotes(_page: Page): Promise { throw new RpaError( "RPA_DATA_INVALID", "scrapeQuotes 已废弃:报价须走 Direct 优先策略(runMothershipQuoteKernel)", { retryable: false }, ); } export class MothershipRPAProvider implements QuoteProvider { async getQuote(req: QuoteRequest): Promise { if (isRpaMockMode()) { const items = mockQuoteItems(); validateResult(items); return { items, sourceType: "rpa", confidenceScore: getConfidenceScore("rpa"), }; } try { const items = await fetchAxelQuoteItems(req); validateResult(items); console.log( `[rpa] getQuote: node-direct 成功 tiers=${items.length}(不启动浏览器)`, ); return { items, sourceType: "rpa", confidenceScore: getConfidenceScore("rpa"), }; } catch (error) { const brief = error instanceof Error ? error.message : String(error); console.log( `[rpa] getQuote: node-direct 失败 — ${brief.slice(0, 200)}`, ); if (!isWidgetQuoteFallbackEnabled()) { if (error instanceof RpaError) { throw error; } throw new RpaError("RPA_DATA_INVALID", brief, { retryable: true }); } } const session: { page: Page | null; context: BrowserContext | null; rpaCtx: RPAContext | null; } = { page: null, context: null, rpaCtx: null, }; const executeQuote = async ( relogin: boolean, tryParked: boolean, ): Promise => { return withRpaSessionLock(async () => { console.log( `[rpa] executeQuote start relogin=${relogin} addressMode=${getRpaAddressMode()}`, ); if (!relogin && tryParked && req.quoteSessionId) { const parked = await takeParkedQuoteSession(req.quoteSessionId, { waitMs: 5_000, }); if (parked) { const reuseStarted = Date.now(); session.page = parked.page; session.context = parked.context; session.rpaCtx = RPAContext.fromSession( parked.page, parked.context, ); console.log( "[rpa] step: reuseParkedSession(候选+询价单次会话续跑)", ); await prepareParkedQuotePage(session.page); const items = await runMothershipQuoteKernel( session.rpaCtx, req, ); await persistContext(session.context); console.log( `[rpa] reuseParkedSession 完成 ${Date.now() - reuseStarted}ms`, ); return items; } console.log( "[rpa] 驻留会话不可用,回退 storageState 开页", ); } const opened = await openQuotePage( quotePageAdapter, relogin, undefined, { freshCargoContext: true }, ); session.page = opened.page; session.context = opened.context; session.rpaCtx = RPAContext.fromSession( opened.page, opened.context, ); console.log("[rpa] step: preCheck"); await quotePageAdapter.preCheck(session.page, { reuseSession: false, }); const items = await runMothershipQuoteKernel(session.rpaCtx, req); console.log("[rpa] 询价完成,跳过 persistContext(freshCargoContext)"); return items; }); }; try { let items: QuoteItem[]; try { items = await executeQuote(false, isParkedSessionEnabled()); } catch (error) { if ( error instanceof RpaError && error.code === "SESSION_EXPIRED" ) { await session.page?.close().catch(() => undefined); await session.context?.close().catch(() => undefined); session.page = null; session.context = null; session.rpaCtx = null; items = await executeQuote(true, false); } else { throw error; } } return { items, sourceType: "rpa", confidenceScore: getConfidenceScore("rpa"), }; } catch (error) { if (session.page) { await captureRpaDebugScreenshot(session.page, "failure"); } if (error instanceof RpaError) { throw error; } throw new RpaError( "PAGE_LOAD_TIMEOUT", error instanceof Error ? error.message : "RPA 执行异常", ); } finally { if (!shouldKeepBrowserOpen()) { await session.page?.close().catch(() => undefined); await session.context?.close().catch(() => undefined); } else if (isRpaAddressMockMode()) { // mock 探通:避免保活 Browser 僵死拖垮后续 job await session.page?.close().catch(() => undefined); await session.context?.close().catch(() => undefined); } } } async healthCheck(): Promise { if (isRpaMockMode()) { return true; } if (storageStateExists()) { return true; } let page: Page | null = null; let context: BrowserContext | null = null; try { const opened = await openQuotePage(); page = opened.page; context = opened.context; return quotePageAdapter.preCheck(page); } catch { return false; } finally { if (page) await page.close().catch(() => undefined); if (context) await context.close().catch(() => undefined); } } } export const mothershipProvider = new MothershipRPAProvider(); /** @deprecated 使用 quotePageAdapter.preCheck */ export async function preCheck(page: Page): Promise { return quotePageAdapter.preCheck(page); }