You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

756 lines
20 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import type { QuoteItem } from "@/modules/providers/quote-provider";
import {
normalizeMotherShipRateOption,
normalizeMotherShipServiceLevel,
} from "@/lib/constants/mothership-ui-tiers";
import { isAxelQuoteResponseUrl } from "@/workers/rpa/quote-capture/axel-contract";
import { satisfiesQuoteContract } from "@/workers/rpa/quote-capture/quote-contract-guard";
import { normalizeQuote } from "@/workers/rpa/quote-capture/quote-normalize";
import type { QuoteCaptureSignals, QuoteDecodeResult } from "@/workers/rpa/quote-capture/types";
type PartialTier = {
serviceLevel: QuoteItem["serviceLevel"];
rateOption: QuoteItem["rateOption"];
rawFreight: number;
surcharges: number;
transitDays: string;
transitDescription: string;
carrier: string;
};
const MAX_WALK_DEPTH = 24;
const SERVICE_LEVEL_ALIASES: Record<string, string> = {
standard: "standard",
guaranteed: "guaranteed",
guarantee: "guaranteed",
dedicated: "dedicated",
dedicatedtruck: "dedicated",
truckload: "dedicated",
fulltruckload: "dedicated",
ftl: "dedicated",
: "standard",
: "guaranteed",
: "dedicated",
: "dedicated",
};
const RATE_OPTION_ALIASES: Record<string, QuoteItem["rateOption"]> = {
lowest: "lowest",
cheapest: "lowest",
economy: "lowest",
fastest: "fastest",
expedited: "fastest",
express: "fastest",
bestvalue: "bestValue",
: "lowest",
: "fastest",
: "bestValue",
};
const PRICE_FIELD_KEYS = [
"price",
"freight",
"amount",
"cost",
"total",
"rawfreight",
"rawtotal",
"rate",
"value",
"basequoteprice",
"finalprice",
] as const;
const TRANSIT_FIELD_KEYS = [
"transitdays",
"transit_days",
"estimatedtransitdays",
"estimatedtransit",
"deliverydays",
"transit",
"eta",
"days",
] as const;
function normalizeKey(key: string): string {
return key.replace(/[^a-z0-9]/gi, "").toLowerCase();
}
function keyToServiceLevel(key: string): string | null {
const n = normalizeKey(key);
const fromAlias = SERVICE_LEVEL_ALIASES[n];
if (fromAlias) {
return fromAlias;
}
return normalizeMotherShipServiceLevel(key);
}
function keyToRateOption(key: string): QuoteItem["rateOption"] | null {
const n = normalizeKey(key);
return RATE_OPTION_ALIASES[n] ?? null;
}
function mapSubKeyToRateOption(
_serviceLevel: string,
subKey: string,
): QuoteItem["rateOption"] | null {
const ro = keyToRateOption(subKey) ?? normalizeMotherShipRateOption(subKey);
if (ro) {
return ro as QuoteItem["rateOption"];
}
if (normalizeKey(subKey) === "bestvalue") {
return "bestValue";
}
return null;
}
function readStringField(obj: Record<string, unknown>, keys: readonly string[]): string | null {
for (const key of keys) {
const val = obj[key];
if (typeof val === "string" && val.trim()) {
return val.trim();
}
if (typeof val === "number" && Number.isFinite(val)) {
return String(val);
}
}
return null;
}
function readNumericFromKeys(
obj: Record<string, unknown>,
keys: readonly string[],
): number | null {
for (const want of keys) {
for (const key of Object.keys(obj)) {
if (normalizeKey(key) !== normalizeKey(want)) {
continue;
}
const val = obj[key];
if (typeof val === "number" && val > 0) {
return val;
}
if (typeof val === "string") {
const m = val.replace(/,/g, "").match(/(\d+(?:\.\d+)?)/);
if (m) {
const n = Number(m[1]);
if (n > 0) {
return n;
}
}
}
}
}
return null;
}
function readPrice(obj: Record<string, unknown>): number | null {
// 为何MotherShip UI 展示 finalPriceprice 常为承运商标价与 UI 不一致
return (
readNumericFromKeys(obj, ["finalprice"]) ??
readNumericFromKeys(obj, ["price"])
);
}
function readTransit(obj: Record<string, unknown>): string {
const daysVal = obj.days ?? obj.transitDays;
if (typeof daysVal === "number") {
if (daysVal > 0) {
return `Est. ${daysVal} business days`;
}
if (daysVal === 0) {
return "0 business days";
}
}
const desc = readStringField(obj, [
"serviceCommitmentDescription",
"transitDescription",
"description",
"label",
]);
if (desc) {
return desc;
}
for (const key of Object.keys(obj)) {
const nk = normalizeKey(key);
if (!TRANSIT_FIELD_KEYS.some((t) => nk.includes(t.replace(/_/g, "")))) {
continue;
}
const val = obj[key];
if (typeof val === "string" && val.trim()) {
return val.trim();
}
if (typeof val === "number" && val > 0) {
return `${val} business days`;
}
}
return "—";
}
function inferServiceLevel(obj: Record<string, unknown>): string | null {
const fields = ["serviceLevel", "service_level", "serviceType", "service_type", "type", "name", "tier"];
for (const key of fields) {
const val = obj[key];
if (typeof val !== "string") {
continue;
}
const lower = val.toLowerCase();
if (lower.includes("guarantee") || val.includes("保证")) {
return "guaranteed";
}
if (
lower.includes("dedicated") ||
lower.includes("truckload") ||
lower.includes("ftl") ||
val.includes("专属")
) {
return "dedicated";
}
if (lower.includes("standard") || val.includes("标准")) {
return "standard";
}
const fromKey = keyToServiceLevel(val);
if (fromKey) {
return fromKey;
}
}
return null;
}
function inferRateOption(obj: Record<string, unknown>): QuoteItem["rateOption"] | null {
const fields = ["rateOption", "rate_option", "option", "speed", "name", "label", "serviceType", "service_type"];
for (const key of fields) {
const val = obj[key];
if (typeof val !== "string") {
continue;
}
const lower = val.toLowerCase();
if (lower.includes("fast") || lower.includes("exped") || val.includes("最快")) {
return "fastest";
}
if (lower.includes("low") || lower.includes("cheap") || val.includes("最低")) {
return "lowest";
}
if (lower.includes("bestvalue") || lower.includes("best value") || val.includes("最优")) {
return "bestValue";
}
}
return null;
}
function readCarrierName(obj: Record<string, unknown>): string {
const carrierInfo = obj.carrierInfo;
if (
carrierInfo != null &&
typeof carrierInfo === "object" &&
!Array.isArray(carrierInfo)
) {
const name = readStringField(carrierInfo as Record<string, unknown>, [
"name",
"carrierName",
]);
if (name) {
return name;
}
}
return (
readStringField(obj, ["carrierName", "carrier", "providerName"]) ??
"Mothership"
);
}
function pushTier(
acc: PartialTier[],
serviceLevel: string,
rateOption: QuoteItem["rateOption"],
obj: Record<string, unknown>,
): void {
const rawFreight = readPrice(obj);
if (rawFreight === null || rawFreight <= 0) {
return;
}
const surcharges =
typeof obj.surcharges === "number" && obj.surcharges >= 0
? obj.surcharges
: typeof obj.fees === "number" && obj.fees >= 0
? obj.fees
: 0;
const transit = readTransit(obj);
acc.push({
serviceLevel,
rateOption,
rawFreight,
surcharges,
transitDays: transit,
transitDescription: transit,
carrier: readCarrierName(obj),
});
}
function walkNestedServiceRates(
obj: Record<string, unknown>,
acc: PartialTier[],
depth: number,
): void {
if (depth > MAX_WALK_DEPTH) {
return;
}
for (const [key, value] of Object.entries(obj)) {
const sl = keyToServiceLevel(key);
if (!sl || typeof value !== "object" || value === null || Array.isArray(value)) {
continue;
}
const nested = value as Record<string, unknown>;
const directRo = keyToRateOption(key);
if (directRo) {
pushTier(acc, sl, directRo, nested);
continue;
}
for (const [subKey, subVal] of Object.entries(nested)) {
const ro = mapSubKeyToRateOption(sl, subKey);
if (!ro || typeof subVal !== "object" || subVal === null || Array.isArray(subVal)) {
continue;
}
pushTier(acc, sl, ro, subVal as Record<string, unknown>);
}
}
}
function walkJson(node: unknown, acc: PartialTier[], depth = 0): void {
if (depth > MAX_WALK_DEPTH || node == null) {
return;
}
if (Array.isArray(node)) {
for (const item of node) {
walkJson(item, acc, depth + 1);
}
return;
}
if (typeof node !== "object") {
return;
}
const obj = node as Record<string, unknown>;
walkNestedServiceRates(obj, acc, depth);
const sl = inferServiceLevel(obj);
const ro = inferRateOption(obj);
if (sl) {
pushTier(acc, sl, ro ?? "lowest", obj);
}
for (const value of Object.values(obj)) {
walkJson(value, acc, depth + 1);
}
}
function dedupePartials(partials: PartialTier[]): PartialTier[] {
const map = new Map<string, PartialTier>();
for (const tier of partials) {
const key = `${tier.serviceLevel}/${tier.rateOption}`;
if (!map.has(key)) {
map.set(key, tier);
}
}
return [...map.values()];
}
function partialsToQuoteItems(partials: PartialTier[]): QuoteItem[] {
return dedupePartials(partials).map((tier) => ({
serviceLevel: tier.serviceLevel,
rateOption: tier.rateOption,
carrier: tier.carrier,
transitDays: tier.transitDays,
transitDescription: tier.transitDescription,
rawFreight: tier.rawFreight,
surcharges: tier.surcharges,
rawTotal: tier.rawFreight + tier.surcharges,
}));
}
/** UI 档位:叶节点 finalPrice/price或经 id 关联 availableRates 解析完整对象 */
function resolveUiTierLeaf(tier: unknown): Record<string, unknown> | null {
if (tier == null || typeof tier !== "object" || Array.isArray(tier)) {
return null;
}
const obj = tier as Record<string, unknown>;
if (readNumericFromKeys(obj, ["finalprice", "price"]) != null) {
return obj;
}
return null;
}
function resolveTierFromRatesSlot(
slotVal: unknown,
availableRates: Record<string, unknown> | null,
): Record<string, unknown> | null {
const leaf = resolveUiTierLeaf(slotVal);
if (leaf) {
return leaf;
}
if (availableRates == null) {
return null;
}
let rateId: string | null = null;
if (typeof slotVal === "string" && slotVal.trim()) {
rateId = slotVal.trim();
} else if (slotVal != null && typeof slotVal === "object" && !Array.isArray(slotVal)) {
const id = (slotVal as Record<string, unknown>).id;
if (typeof id === "string" && id.trim()) {
rateId = id.trim();
}
}
if (!rateId) {
return null;
}
const ref = availableRates[rateId];
if (ref == null || typeof ref !== "object" || Array.isArray(ref)) {
return null;
}
return resolveUiTierLeaf(ref) ? (ref as Record<string, unknown>) : null;
}
/** MotherShip 返回什么就解析什么:至少 1 条有效运价即可 */
function hasMinimumTiers(items: QuoteItem[]): boolean {
return items.some((i) => i.rawFreight > 0);
}
/** 解码 sign-up-quote?quote-details=URL 编码 JSON可能多层 */
export function decodeQuoteDetailsParam(raw: string): unknown | null {
let current = raw.trim();
for (let attempt = 0; attempt < 4; attempt += 1) {
if (current.startsWith("{") || current.startsWith("[")) {
try {
return JSON.parse(current) as unknown;
} catch {
return null;
}
}
let progressed = false;
try {
const urlDecoded = decodeURIComponent(current);
if (urlDecoded !== current) {
current = urlDecoded;
progressed = true;
}
} catch {
/* 非 URL 编码 */
}
if (!progressed) {
try {
const fromB64 = Buffer.from(current, "base64").toString("utf8");
if (fromB64 && fromB64 !== current) {
current = fromB64;
progressed = true;
}
} catch {
/* 非 base64 */
}
}
if (!progressed) {
break;
}
}
if (current.startsWith("{") || current.startsWith("[")) {
try {
return JSON.parse(current) as unknown;
} catch {
return null;
}
}
return null;
}
function extractTiersFromServiceBlock(
partials: PartialTier[],
serviceLevel: string,
block: Record<string, unknown>,
availableRates: Record<string, unknown> | null,
): void {
for (const [subKey, subVal] of Object.entries(block)) {
const ro = mapSubKeyToRateOption(serviceLevel, subKey);
if (!ro) {
continue;
}
const tier = resolveTierFromRatesSlot(subVal, availableRates);
if (tier) {
pushTier(partials, serviceLevel, ro, tier);
}
}
}
function extractTiersFromRates(
rates: Record<string, unknown>,
availableRates: Record<string, unknown> | null,
): PartialTier[] {
const partials: PartialTier[] = [];
const skipTopLevel = new Set(["fastest"]);
for (const [key, block] of Object.entries(rates)) {
if (skipTopLevel.has(key)) {
continue;
}
const serviceLevel = keyToServiceLevel(key);
if (
!serviceLevel ||
block == null ||
typeof block !== "object" ||
Array.isArray(block)
) {
continue;
}
extractTiersFromServiceBlock(
partials,
serviceLevel,
block as Record<string, unknown>,
availableRates,
);
}
const fastestTop = resolveTierFromRatesSlot(rates.fastest, availableRates);
if (
fastestTop &&
!partials.some(
(p) => p.serviceLevel === "standard" && p.rateOption === "fastest",
)
) {
pushTier(partials, "standard", "fastest", fastestTop);
}
return partials;
}
function readAvailableRates(body: unknown): Record<string, unknown> | null {
if (body == null || typeof body !== "object" || Array.isArray(body)) {
return null;
}
const data = (body as Record<string, unknown>).data;
if (data == null || typeof data !== "object" || Array.isArray(data)) {
return null;
}
const availableRates = (data as Record<string, unknown>).availableRates;
if (
availableRates == null ||
typeof availableRates !== "object" ||
Array.isArray(availableRates)
) {
return null;
}
return availableRates as Record<string, unknown>;
}
/** MotherShip UI 档位名仅以 rates.{standard|guaranteed} 下出现的子键为准 */
function listUiRateOptionsFromRatesBlock(
rates: Record<string, unknown> | undefined,
serviceLevel: string,
): Set<QuoteItem["rateOption"]> | null {
const block = rates?.[serviceLevel];
if (block == null || typeof block !== "object" || Array.isArray(block)) {
return null;
}
const allowed = new Set<QuoteItem["rateOption"]>();
for (const subKey of Object.keys(block as Record<string, unknown>)) {
const ro = mapSubKeyToRateOption(serviceLevel, subKey);
if (ro) {
allowed.add(ro);
}
}
return allowed.size > 0 ? allowed : null;
}
/** 仅 rates.{serviceLevel} 下声明的子键允许从 availableRates 补全(与官网 Tab 一致) */
function listAllowedRateOptionsForSupplement(
rates: Record<string, unknown> | undefined,
serviceLevel: string,
): Set<QuoteItem["rateOption"]> | null {
if (!rates) {
return null;
}
return listUiRateOptionsFromRatesBlock(rates, serviceLevel);
}
function isRateOptionAllowedForSupplement(
rates: Record<string, unknown> | undefined,
serviceLevel: string,
rateOption: QuoteItem["rateOption"],
): boolean {
const allowed = listAllowedRateOptionsForSupplement(rates, serviceLevel);
if (allowed == null || allowed.size === 0) {
return false;
}
return allowed.has(rateOption);
}
function hasPartialTier(
partials: PartialTier[],
serviceLevel: string,
rateOption: QuoteItem["rateOption"],
): boolean {
return partials.some(
(p) => p.serviceLevel === serviceLevel && p.rateOption === rateOption,
);
}
function mergePartials(target: PartialTier[], source: PartialTier[]): void {
for (const tier of source) {
if (!hasPartialTier(target, tier.serviceLevel, tier.rateOption)) {
target.push(tier);
}
}
}
/**
* data.availableRates 补全:仅填充 rates.* 已声明槽位(显式 serviceType 或 id 引用),禁止 customRate 推断
*/
function extractTiersFromAvailableRates(
body: unknown,
ratesFromBody?: Record<string, unknown>,
): PartialTier[] {
const availableRates = readAvailableRates(body);
if (!availableRates) {
return [];
}
const partials: PartialTier[] = [];
for (const entry of Object.values(availableRates)) {
if (entry == null || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
const obj = entry as Record<string, unknown>;
if (!resolveUiTierLeaf(obj)) {
continue;
}
const serviceLevel = inferServiceLevel(obj) ?? "standard";
const serviceType = String(obj.serviceType ?? "").toLowerCase();
if (!serviceType || serviceType === "customrate") {
continue;
}
const rateOption =
keyToRateOption(serviceType) ??
mapSubKeyToRateOption(serviceLevel, serviceType);
if (!rateOption) {
continue;
}
if (
isRateOptionAllowedForSupplement(ratesFromBody, serviceLevel, rateOption)
) {
pushTier(partials, serviceLevel, rateOption, obj);
}
}
return partials;
}
function mergeAvailableRatesSupplement(
partials: PartialTier[],
body: unknown,
rates: Record<string, unknown> | undefined,
): void {
mergePartials(partials, extractTiersFromAvailableRates(body, rates));
}
function extractFromContractBodies(
bodies: unknown[],
): QuoteItem[] | null {
const partials: PartialTier[] = [];
for (const body of bodies) {
const canonical = normalizeQuote(body);
if (!canonical) {
continue;
}
const availableRates = readAvailableRates(body);
partials.push(...extractTiersFromRates(canonical.rates, availableRates));
mergeAvailableRatesSupplement(partials, body, canonical.rates);
}
const items = partialsToQuoteItems(partials);
return hasMinimumTiers(items) ? items : null;
}
/** 直连 axel/quote仅 rates.* 声明槽位 + id/空槽 availableRates 补全,不推断 customRate */
export function decodeAxelQuoteBodyLenient(body: unknown): QuoteItem[] | null {
const partials: PartialTier[] = [];
const canonical = normalizeQuote(body);
const rates = canonical?.rates;
const availableRates = readAvailableRates(body);
if (rates) {
partials.push(...extractTiersFromRates(rates, availableRates));
}
mergeAvailableRatesSupplement(partials, body, rates);
const items = partialsToQuoteItems(partials);
return hasMinimumTiers(items) ? items : null;
}
function orderContractPayloads(
payloads: QuoteCaptureSignals["contractPayloads"],
): QuoteCaptureSignals["contractPayloads"] {
return [...payloads].sort((a, b) => {
const rank = (url: string) => (isAxelQuoteResponseUrl(url) ? 0 : 1);
return rank(a.url) - rank(b.url);
});
}
function tryLenientAxelDecode(
signals: QuoteCaptureSignals,
): QuoteDecodeResult | null {
const onSignUpQuote = signals.resultUrl.includes("sign-up-quote");
const candidates = [
...orderContractPayloads(signals.contractPayloads),
...signals.jsonPayloads,
];
for (const payload of candidates) {
if (!isAxelQuoteResponseUrl(payload.url) && !onSignUpQuote) {
continue;
}
const items = decodeAxelQuoteBodyLenient(payload.body);
if (items && hasMinimumTiers(items)) {
console.log(
`[rpa] quote-decode: lenient axel/quote url=${payload.url} tiers=${items.length}`,
);
return { items, source: "lenient" };
}
}
return null;
}
/** 契约 JSON 响应 → 四档报价sign-up-quote 页 axel/quote 结构不全时走 lenient */
export function decodeQuotePayloads(
signals: QuoteCaptureSignals,
): QuoteDecodeResult | null {
if (signals.contractPayloads.length === 0) {
return tryLenientAxelDecode(signals);
}
const ordered = orderContractPayloads(signals.contractPayloads);
const strictOk = ordered.every((p) => satisfiesQuoteContract(p.body));
if (strictOk) {
const bodies = ordered.map((p) => p.body);
const items = extractFromContractBodies(bodies);
if (items && hasMinimumTiers(items)) {
return { items, source: "contract" };
}
}
return tryLenientAxelDecode(signals);
}
/** @deprecated Network-First 架构禁止 DOM 内嵌 JSON 报价提取 */
export function decodeEmbeddedJsonBodies(
_bodies: unknown[],
): QuoteDecodeResult | null {
return null;
}