|
|
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<string, Record<string, number | string | null>> = {};
|
|
|
for (const [sl, tiers] of Object.entries(rates as Record<string, unknown>)) {
|
|
|
if (tiers == null || typeof tiers !== "object" || Array.isArray(tiers)) {
|
|
|
continue;
|
|
|
}
|
|
|
out[sl] = {};
|
|
|
for (const [ro, tier] of Object.entries(tiers as Record<string, unknown>)) {
|
|
|
if (tier == null || typeof tier !== "object" || Array.isArray(tier)) {
|
|
|
continue;
|
|
|
}
|
|
|
const t = tier as Record<string, unknown>;
|
|
|
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<QuoteItem[]> {
|
|
|
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);
|
|
|
}
|