import type { Page } from "playwright"; import type { QuoteItem, QuoteRequest } from "@/modules/providers/quote-provider"; import { AXEL_QUOTE_URL } from "@/lib/axel/constants"; import { buildAxelShipmentPayload, type AxelPlaceLocation, } from "@/lib/axel/shipment"; import { RpaError } from "@/modules/rpa/errors"; import { decodeQuotePayloads } from "@/workers/rpa/quote-capture/payload-decode"; import { normalizeQuote } from "@/workers/rpa/quote-capture/quote-normalize"; import { validateQuoteSchema } from "@/workers/rpa/quote-capture/quote-schema-validator"; function resolveLocationDescription( fallback: string, address: QuoteRequest["pickup"], ): string { const userStreet = address.street?.trim(); if (!userStreet) { return fallback; } const num = userStreet.match(/^\d+/)?.[0]; if (num && !fallback.includes(num)) { const city = address.city?.trim(); const state = address.state?.trim(); if (city && state) { return `${userStreet}, ${city}, ${state}, USA`; } return userStreet; } return address.formattedAddress?.trim() || fallback; } function summarizeAxelRates(body: unknown): unknown { const rates = (body as { data?: { rates?: unknown } })?.data?.rates; if (!rates || typeof rates !== "object") { return null; } const out: Record> = {}; for (const [sl, tiers] of Object.entries(rates as Record)) { if (tiers == null || typeof tiers !== "object" || Array.isArray(tiers)) { continue; } out[sl] = {}; for (const [ro, tier] of Object.entries(tiers as Record)) { if (tier == null || typeof tier !== "object" || Array.isArray(tier)) { continue; } const t = tier as Record; out[sl][ro] = typeof t.finalPrice === "number" ? t.finalPrice : typeof t.price === "number" ? t.price : null; const days = t.days; if (typeof days === "number") { out[sl][`${ro}_days`] = days; } } } return out; } /** 使用 prefetch 的 place 详情直接 POST axel/quote(真实 MotherShip 定价) */ export async function scrapeQuotesViaDirectAxel( page: Page, places: { pickup: AxelPlaceLocation; delivery: AxelPlaceLocation; }, req: QuoteRequest, ): Promise { const shipment = buildAxelShipmentPayload({ pickup: places.pickup, delivery: places.delivery, pickupDescription: resolveLocationDescription( req.pickup.formattedAddress ?? req.pickup.mothershipDisplayLabel ?? "", req.pickup, ), deliveryDescription: resolveLocationDescription( req.delivery.formattedAddress ?? req.delivery.mothershipDisplayLabel ?? "", req.delivery, ), cargo: { palletCount: req.palletCount, weightLb: req.weightLb, lengthIn: req.dimsIn.l, widthIn: req.dimsIn.w, heightIn: req.dimsIn.h, }, }); const body = await page.evaluate( async ({ url, payload }) => { const res = await fetch(url, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ shipment: payload }), }); const text = await res.text(); if (!res.ok) { const brief = text.slice(0, 200); if (res.status === 400 && /unable to find an accurate quote/i.test(text)) { throw new Error(`axel/quote UNAVAILABLE 400: ${brief}`); } throw new Error(`axel/quote HTTP ${res.status}: ${brief}`); } return JSON.parse(text) as unknown; }, { url: AXEL_QUOTE_URL, payload: shipment }, ); if (!normalizeQuote(body)) { throw new RpaError( "RPA_DATA_INVALID", "direct axel/quote 响应不满足报价契约", { retryable: true }, ); } const rateSummary = summarizeAxelRates(body); if (rateSummary) { console.log(`[rpa] direct axel/quote rates: ${JSON.stringify(rateSummary)}`); } console.log("[rpa] direct axel/quote: 捕获真实报价响应"); const decoded = decodeQuotePayloads({ contractPayloads: [{ url: AXEL_QUOTE_URL, body }], jsonPayloads: [{ url: AXEL_QUOTE_URL, body }], networkTrace: [], resultUrl: page.url(), }); if (!decoded?.items.length) { throw new RpaError( "RPA_DATA_INVALID", "direct axel/quote 无法解析报价档位", { retryable: true }, ); } return validateQuoteSchema(decoded.items); }