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.

835 lines
22 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 { 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, QuoteItem["serviceLevel"]> = {
standard: "standard",
guaranteed: "guaranteed",
guarantee: "guaranteed",
: "standard",
: "guaranteed",
};
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): QuoteItem["serviceLevel"] | null {
const n = normalizeKey(key);
return SERVICE_LEVEL_ALIASES[n] ?? null;
}
function keyToRateOption(key: string): QuoteItem["rateOption"] | null {
const n = normalizeKey(key);
return RATE_OPTION_ALIASES[n] ?? null;
}
function mapSubKeyToRateOption(
_serviceLevel: QuoteItem["serviceLevel"],
subKey: string,
): QuoteItem["rateOption"] | null {
const ro = keyToRateOption(subKey);
if (ro) {
return ro;
}
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>): QuoteItem["serviceLevel"] | 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("standard") || val.includes("标准")) {
return "standard";
}
}
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: QuoteItem["serviceLevel"],
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;
}
function hasMinimumTiers(items: QuoteItem[]): boolean {
return items.some(
(i) => i.serviceLevel === "standard" && i.rateOption === "lowest",
);
}
/** 解码 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: QuoteItem["serviceLevel"],
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 standard = rates.standard;
if (standard != null && typeof standard === "object" && !Array.isArray(standard)) {
extractTiersFromServiceBlock(
partials,
"standard",
standard 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);
}
const guaranteed = rates.guaranteed;
if (
guaranteed != null &&
typeof guaranteed === "object" &&
!Array.isArray(guaranteed)
) {
extractTiersFromServiceBlock(
partials,
"guaranteed",
guaranteed as Record<string, unknown>,
availableRates,
);
}
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>;
}
function readRateDays(obj: Record<string, unknown>): number {
if (typeof obj.days === "number" && obj.days >= 0) {
return obj.days;
}
return 999;
}
/** MotherShip UI 档位名仅以 rates.{standard|guaranteed} 下出现的子键为准 */
function listUiRateOptionsFromRatesBlock(
rates: Record<string, unknown> | undefined,
serviceLevel: QuoteItem["serviceLevel"],
): 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;
}
function collectExplicitRateOptionsFromAvailable(
availableRates: Record<string, unknown> | null,
serviceLevel: QuoteItem["serviceLevel"],
): Set<QuoteItem["rateOption"]> {
const allowed = new Set<QuoteItem["rateOption"]>();
if (!availableRates) {
return allowed;
}
for (const entry of Object.values(availableRates)) {
if (entry == null || typeof entry !== "object" || Array.isArray(entry)) {
continue;
}
const obj = entry as Record<string, unknown>;
const level = inferServiceLevel(obj) ?? "standard";
if (level !== serviceLevel) {
continue;
}
const serviceType = String(obj.serviceType ?? "").toLowerCase();
if (!serviceType || serviceType === "customrate") {
continue;
}
const ro =
keyToRateOption(serviceType) ??
mapSubKeyToRateOption(serviceLevel, serviceType);
if (ro) {
allowed.add(ro);
}
}
return allowed;
}
/**
* 补全 availableRates 时允许的档位:
* - guaranteed仅 rates.guaranteed 下声明的子键(与 Widget Tab 一致)
* - standardrates.standard 子键 + availableRates 显式 serviceType最快档常仅在此
*/
function listAllowedRateOptionsForSupplement(
rates: Record<string, unknown> | undefined,
availableRates: Record<string, unknown> | null,
serviceLevel: QuoteItem["serviceLevel"],
): Set<QuoteItem["rateOption"]> | null {
const fromRates = listUiRateOptionsFromRatesBlock(rates, serviceLevel);
if (serviceLevel === "guaranteed") {
return fromRates;
}
const fromExplicit = collectExplicitRateOptionsFromAvailable(
availableRates,
"standard",
);
if (!fromRates && fromExplicit.size === 0) {
return null;
}
return new Set([...(fromRates ?? []), ...fromExplicit]);
}
function isRateOptionAllowedForSupplement(
rates: Record<string, unknown> | undefined,
availableRates: Record<string, unknown> | null,
serviceLevel: QuoteItem["serviceLevel"],
rateOption: QuoteItem["rateOption"],
): boolean {
const allowed = listAllowedRateOptionsForSupplement(
rates,
availableRates,
serviceLevel,
);
if (allowed == null) {
return true;
}
return allowed.has(rateOption);
}
function hasPartialTier(
partials: PartialTier[],
serviceLevel: QuoteItem["serviceLevel"],
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 兜底:
* - 显式 serviceTypelowest/fastest/bestValue仅补 rates.* 已声明的 UI 槽位
* - customRate 池推断仅当 rates.{serviceLevel} 块未定义任何 UI 子键时启用
*/
function extractTiersFromAvailableRates(
body: unknown,
ratesFromBody?: Record<string, unknown>,
): PartialTier[] {
const availableRates = readAvailableRates(body);
if (!availableRates) {
return [];
}
const partials: PartialTier[] = [];
const customPools: Record<
QuoteItem["serviceLevel"],
Record<string, unknown>[]
> = { standard: [], guaranteed: [] };
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();
const rateOption =
keyToRateOption(serviceType) ??
mapSubKeyToRateOption(serviceLevel, serviceType);
if (rateOption && serviceType !== "customrate") {
if (
isRateOptionAllowedForSupplement(
ratesFromBody,
availableRates,
serviceLevel,
rateOption,
)
) {
pushTier(partials, serviceLevel, rateOption, obj);
}
continue;
}
customPools[serviceLevel].push(obj);
}
for (const serviceLevel of ["standard", "guaranteed"] as const) {
if (listUiRateOptionsFromRatesBlock(ratesFromBody, serviceLevel) != null) {
continue;
}
const pool = customPools[serviceLevel];
if (pool.length === 0) {
continue;
}
if (!hasPartialTier(partials, serviceLevel, "lowest")) {
const cheapest = pool.reduce((best, cur) =>
(readPrice(cur) ?? Infinity) < (readPrice(best) ?? Infinity)
? cur
: best,
);
pushTier(partials, serviceLevel, "lowest", cheapest);
}
if (!hasPartialTier(partials, serviceLevel, "fastest")) {
const fastest = pool.reduce((best, cur) =>
readRateDays(cur) < readRateDays(best) ? cur : best,
);
pushTier(partials, serviceLevel, "fastest", fastest);
}
if (!hasPartialTier(partials, serviceLevel, "bestValue")) {
const sorted = [...pool].sort(
(a, b) => (readPrice(a) ?? 0) - (readPrice(b) ?? 0),
);
const pick = sorted[1] ?? sorted[0];
if (pick) {
pushTier(partials, serviceLevel, "bestValue", pick);
}
}
}
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/quoterates.* 叶节点 + id 关联 availableRates + 显式 serviceType 补档 */
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;
}