|
|
/**
|
|
|
* MotherShip 登录态 dashboard Direct:HTTP 询价,跳过 DOM 填表。
|
|
|
* API:place/autocomplete → place/details → POST /api/app/v1/quote
|
|
|
* 鉴权:storage localStorage `@mothership_auth.idToken`(Bearer)
|
|
|
*/
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
import fs from "node:fs";
|
|
|
import {
|
|
|
buildCookieHeader,
|
|
|
DEFAULT_LOGGED_IN_STORAGE_STATE_PATH,
|
|
|
type PlaywrightStorageState,
|
|
|
} from "@/lib/axel/session";
|
|
|
import { launchRpaBrowser } from "@/lib/rpa/browser-launch";
|
|
|
import { getEffectiveMothershipLogin } from "@/lib/rpa/mothership-login-context";
|
|
|
import {
|
|
|
ceilMothershipNumeric,
|
|
|
normalizeMothershipReadyDateIso,
|
|
|
snapMothershipReadyDateToWeekday,
|
|
|
} from "@/lib/mothership/logged-in-constraints";
|
|
|
import type { QuoteItem, QuoteRequest } from "@/modules/providers/quote-provider";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import { normalizeQuoteItems } from "@/workers/rpa/quote-capture/quote-schema-validator";
|
|
|
|
|
|
export const MS_DASHBOARD_ORIGIN = "https://dashboard.mothership.com";
|
|
|
export const MS_DASHBOARD_QUOTE_URL = `${MS_DASHBOARD_ORIGIN}/api/app/v1/quote`;
|
|
|
export const MS_DASHBOARD_PLACE_AUTOCOMPLETE_URL = `${MS_DASHBOARD_ORIGIN}/api/app/v1/place/autocomplete`;
|
|
|
export const MS_DASHBOARD_PLACE_DETAILS_URL = `${MS_DASHBOARD_ORIGIN}/api/app/v1/place/details`;
|
|
|
|
|
|
export type MsStorageState = PlaywrightStorageState & {
|
|
|
origins?: Array<{
|
|
|
origin: string;
|
|
|
localStorage: Array<{ name: string; value: string }>;
|
|
|
}>;
|
|
|
};
|
|
|
|
|
|
type MsPlace = {
|
|
|
placeId: string;
|
|
|
city: string;
|
|
|
state: string;
|
|
|
zip: string;
|
|
|
street: string;
|
|
|
timezone: string;
|
|
|
zone: string | null;
|
|
|
coordinates: { latitude: number; longitude: number };
|
|
|
neighborhood: string;
|
|
|
};
|
|
|
|
|
|
type AuthBlob = {
|
|
|
idToken?: string;
|
|
|
accessToken?: string;
|
|
|
};
|
|
|
|
|
|
/** 默认开;设 MS_DASHBOARD_DIRECT_QUOTE=false 强制 DOM */
|
|
|
export function isMsDashboardDirectQuoteEnabled(): boolean {
|
|
|
const raw = process.env.MS_DASHBOARD_DIRECT_QUOTE?.trim().toLowerCase();
|
|
|
if (raw === "false" || raw === "0" || raw === "off") return false;
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
export function resolveLoggedInStoragePath(): string {
|
|
|
return (
|
|
|
process.env.RPA_LOGGED_IN_STORAGE_STATE_PATH?.trim() ||
|
|
|
DEFAULT_LOGGED_IN_STORAGE_STATE_PATH
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export function readMothershipIdToken(state: MsStorageState): string | null {
|
|
|
for (const origin of state.origins ?? []) {
|
|
|
for (const item of origin.localStorage ?? []) {
|
|
|
if (item.name !== "@mothership_auth") continue;
|
|
|
try {
|
|
|
const parsed = JSON.parse(item.value) as AuthBlob;
|
|
|
const token = parsed.idToken || parsed.accessToken;
|
|
|
if (token?.trim()) return token.trim();
|
|
|
} catch {
|
|
|
/* ignore */
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
export function buildAddressQuery(addr: QuoteRequest["pickup"]): string {
|
|
|
const street = addr.street?.trim() || "";
|
|
|
const structured = [street, addr.city, addr.state, addr.zip]
|
|
|
.map((p) => p?.trim())
|
|
|
.filter(Boolean)
|
|
|
.join(", ");
|
|
|
if (street && addr.city?.trim() && addr.state?.trim()) return structured;
|
|
|
return (
|
|
|
addr.mothershipDisplayLabel?.trim() ||
|
|
|
addr.formattedAddress?.trim() ||
|
|
|
structured
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export function decodeMsCarrierLabel(rate: Record<string, unknown>): string {
|
|
|
const encoded = rate.encodedCarrierName;
|
|
|
if (typeof encoded === "string" && encoded.trim()) {
|
|
|
try {
|
|
|
const name = Buffer.from(encoded, "base64").toString("utf8").trim();
|
|
|
if (name) {
|
|
|
const lane = String(rate.serviceLaneType ?? "");
|
|
|
if (/direct/i.test(lane) && !/direct/i.test(name)) {
|
|
|
return `${name} Direct`;
|
|
|
}
|
|
|
if (/interline/i.test(lane) && !/interline/i.test(name)) {
|
|
|
return `${name} Interline`;
|
|
|
}
|
|
|
return name;
|
|
|
}
|
|
|
} catch {
|
|
|
/* fall through */
|
|
|
}
|
|
|
}
|
|
|
const scac = String(rate.carrierScac ?? "").trim();
|
|
|
return scac ? scac.toUpperCase() : "MotherShip";
|
|
|
}
|
|
|
|
|
|
function mapServiceTypeToRateOption(
|
|
|
serviceType: unknown,
|
|
|
): QuoteItem["rateOption"] {
|
|
|
const raw = String(serviceType ?? "")
|
|
|
.trim()
|
|
|
.toLowerCase();
|
|
|
if (raw === "fastest" || raw === "expedited") return "fastest";
|
|
|
if (raw === "lowest" || raw === "cheapest") return "lowest";
|
|
|
return "bestValue";
|
|
|
}
|
|
|
|
|
|
function readRatePrice(rate: Record<string, unknown>): number | null {
|
|
|
for (const key of ["finalPrice", "price", "baseQuotePrice"] as const) {
|
|
|
const v = rate[key];
|
|
|
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
function readRateDays(rate: Record<string, unknown>): string {
|
|
|
if (typeof rate.days === "number" && rate.days > 0) return String(rate.days);
|
|
|
const transit = rate.transit as Record<string, unknown> | undefined;
|
|
|
const min = transit?.serviceDaysMin;
|
|
|
const max = transit?.serviceDaysMax;
|
|
|
if (typeof min === "number" && typeof max === "number" && min > 0) {
|
|
|
return min === max ? String(min) : `${min}-${max}`;
|
|
|
}
|
|
|
return "待确认";
|
|
|
}
|
|
|
|
|
|
function collectQuoteRates(root: Record<string, unknown>): Record<string, unknown>[] {
|
|
|
// 官网价卡列表是 ratesV2;与 availableRates 通常同集,优先 V2 以免漏/多
|
|
|
const fromV2 = iterDashboardRates(root.ratesV2);
|
|
|
if (fromV2.length > 0) return fromV2;
|
|
|
return iterDashboardRates(root.availableRates);
|
|
|
}
|
|
|
|
|
|
/** 将 dashboard quote 响应映射为登录态承运商列表(对齐 DOM 刮价) */
|
|
|
export function mapDashboardQuoteBodyToItems(body: unknown): QuoteItem[] {
|
|
|
if (body == null || typeof body !== "object") return [];
|
|
|
const root = body as Record<string, unknown>;
|
|
|
const items: QuoteItem[] = [];
|
|
|
const seen = new Set<string>();
|
|
|
|
|
|
for (const rate of collectQuoteRates(root)) {
|
|
|
const price = readRatePrice(rate);
|
|
|
if (price == null) continue;
|
|
|
const carrier = decodeMsCarrierLabel(rate);
|
|
|
const serviceLevel = String(rate.serviceLevel ?? "standard") || "standard";
|
|
|
const rateOption = mapServiceTypeToRateOption(rate.serviceType);
|
|
|
const days = readRateDays(rate);
|
|
|
const rateId = String(rate.id ?? "");
|
|
|
const key = `${rateId}|${serviceLevel}|${rateOption}|${carrier}|${price}`;
|
|
|
if (seen.has(key)) continue;
|
|
|
seen.add(key);
|
|
|
items.push({
|
|
|
serviceLevel,
|
|
|
rateOption,
|
|
|
carrier,
|
|
|
transitDays: days,
|
|
|
transitDescription:
|
|
|
days === "待确认" ? "时效待定" : `${days} business days`,
|
|
|
rawFreight: price,
|
|
|
surcharges: 0,
|
|
|
rawTotal: price,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
// 兜底:rates.{sl}.{ro} 槽位
|
|
|
const rates = root.rates;
|
|
|
if (rates && typeof rates === "object" && !Array.isArray(rates)) {
|
|
|
for (const [sl, slot] of Object.entries(rates as Record<string, unknown>)) {
|
|
|
if (slot == null || typeof slot !== "object" || Array.isArray(slot)) {
|
|
|
continue;
|
|
|
}
|
|
|
for (const [ro, leaf] of Object.entries(slot as Record<string, unknown>)) {
|
|
|
if (leaf == null || typeof leaf !== "object" || Array.isArray(leaf)) {
|
|
|
continue;
|
|
|
}
|
|
|
const rate = leaf as Record<string, unknown>;
|
|
|
const price = readRatePrice(rate);
|
|
|
if (price == null) continue;
|
|
|
const carrier = decodeMsCarrierLabel(rate);
|
|
|
const rateOption = mapServiceTypeToRateOption(ro);
|
|
|
const days = readRateDays(rate);
|
|
|
const key = `${sl}|${rateOption}|${carrier}|${price}`;
|
|
|
if (seen.has(key)) continue;
|
|
|
seen.add(key);
|
|
|
items.push({
|
|
|
serviceLevel: sl,
|
|
|
rateOption,
|
|
|
carrier,
|
|
|
transitDays: days,
|
|
|
transitDescription:
|
|
|
days === "待确认" ? "时效待定" : `${days} business days`,
|
|
|
rawFreight: price,
|
|
|
surcharges: 0,
|
|
|
rawTotal: price,
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (items.length === 0) return [];
|
|
|
return normalizeQuoteItems(items);
|
|
|
}
|
|
|
|
|
|
function emptyLocationFields() {
|
|
|
return {
|
|
|
accessorials: [] as string[],
|
|
|
appointment: null as null,
|
|
|
autoAddedAccessorials: [] as string[],
|
|
|
email: "",
|
|
|
fulfillmentByAmazonId: null as null,
|
|
|
inboundShipmentAppointmentId: null as null,
|
|
|
name: "",
|
|
|
notes: "",
|
|
|
phoneNumber: "",
|
|
|
purchaseOrderNumber: null as null,
|
|
|
referenceNumber: "",
|
|
|
serviceEndTime: "",
|
|
|
serviceStartTime: "",
|
|
|
subStreet: "",
|
|
|
contactFirstName: "",
|
|
|
contactLastName: "",
|
|
|
phoneNumberExtension: "",
|
|
|
addressBookId: null as null,
|
|
|
dockNumber: null as null,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export function buildDashboardLocation(
|
|
|
place: MsPlace,
|
|
|
accessorials: string[],
|
|
|
details?: NonNullable<QuoteRequest["mothershipDetails"]>["pickup"],
|
|
|
): Record<string, unknown> {
|
|
|
return {
|
|
|
city: place.city,
|
|
|
coordinates: place.coordinates,
|
|
|
neighborhood: place.neighborhood,
|
|
|
placeId: place.placeId,
|
|
|
state: place.state,
|
|
|
street: place.street,
|
|
|
timezone: place.timezone,
|
|
|
zip: place.zip,
|
|
|
zone: place.zone,
|
|
|
...emptyLocationFields(),
|
|
|
accessorials: accessorials.filter(Boolean),
|
|
|
name: details?.company_name?.trim() || "",
|
|
|
subStreet: details?.suite?.trim() || "",
|
|
|
contactFirstName: details?.contact_first?.trim() || "",
|
|
|
contactLastName: details?.contact_last?.trim() || "",
|
|
|
email: details?.contact_email?.trim() || "",
|
|
|
phoneNumber: details?.contact_phone?.trim() || "",
|
|
|
referenceNumber: details?.reference?.trim() || "",
|
|
|
notes: details?.notes?.trim() || "",
|
|
|
serviceStartTime: details?.opens_at?.trim() || "",
|
|
|
serviceEndTime: details?.closes_at?.trim() || "",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 官网 Create shipment cargo.type(API 枚举,非下拉展示名)。
|
|
|
* piece 下拉显示 Piece,请求体必须是 Pieces,否则 quote 400 只剩经纪价。
|
|
|
*/
|
|
|
export const MS_DASHBOARD_CARGO_TYPE_BY_ID: Readonly<Record<string, string>> = {
|
|
|
pallet: "Pallet",
|
|
|
pallets: "Pallet",
|
|
|
box: "Box",
|
|
|
boxes: "Box",
|
|
|
crate: "Crate",
|
|
|
crates: "Crate",
|
|
|
piece: "Pieces",
|
|
|
pieces: "Pieces",
|
|
|
bale: "Bale",
|
|
|
bales: "Bale",
|
|
|
bucket: "Bucket",
|
|
|
buckets: "Bucket",
|
|
|
carton: "Carton",
|
|
|
cartons: "Carton",
|
|
|
case: "Case",
|
|
|
cases: "Case",
|
|
|
coil: "Coil",
|
|
|
coils: "Coil",
|
|
|
cylinder: "Cylinder",
|
|
|
cylinders: "Cylinder",
|
|
|
drum: "Drum",
|
|
|
drums: "Drum",
|
|
|
pail: "Pail",
|
|
|
pails: "Pail",
|
|
|
reel: "Reel",
|
|
|
reels: "Reel",
|
|
|
roll: "Roll",
|
|
|
rolls: "Roll",
|
|
|
skid: "Skid",
|
|
|
skids: "Skid",
|
|
|
tote: "Tote",
|
|
|
totes: "Tote",
|
|
|
tube: "Tube",
|
|
|
tubes: "Tube",
|
|
|
general_freight: "Pallet",
|
|
|
};
|
|
|
|
|
|
export function mapMsDashboardCargoType(cargoType: string | undefined): string {
|
|
|
const raw = String(cargoType || "pallet").trim().toLowerCase();
|
|
|
return MS_DASHBOARD_CARGO_TYPE_BY_ID[raw] || "Pallet";
|
|
|
}
|
|
|
|
|
|
function iterDashboardRates(available: unknown): Record<string, unknown>[] {
|
|
|
if (Array.isArray(available)) {
|
|
|
return available.filter(
|
|
|
(entry): entry is Record<string, unknown> =>
|
|
|
entry != null && typeof entry === "object" && !Array.isArray(entry),
|
|
|
);
|
|
|
}
|
|
|
if (available && typeof available === "object") {
|
|
|
return Object.values(available as Record<string, unknown>).filter(
|
|
|
(entry): entry is Record<string, unknown> =>
|
|
|
entry != null && typeof entry === "object" && !Array.isArray(entry),
|
|
|
);
|
|
|
}
|
|
|
return [];
|
|
|
}
|
|
|
|
|
|
export function buildDashboardCargo(req: QuoteRequest): Record<string, unknown>[] {
|
|
|
const lines =
|
|
|
req.cargoLines && req.cargoLines.length > 0
|
|
|
? req.cargoLines
|
|
|
: [
|
|
|
{
|
|
|
cargoType: req.cargoType || "pallet",
|
|
|
quantity: req.palletCount,
|
|
|
weightLb: req.weightLb,
|
|
|
lengthIn: req.dimsIn.l,
|
|
|
widthIn: req.dimsIn.w,
|
|
|
heightIn: req.dimsIn.h,
|
|
|
},
|
|
|
];
|
|
|
|
|
|
return lines.map((line) => {
|
|
|
const id = randomUUID();
|
|
|
return {
|
|
|
id,
|
|
|
type: mapMsDashboardCargoType(line.cargoType),
|
|
|
description: "",
|
|
|
nmfcCode: "",
|
|
|
quantity: Math.max(1, Math.round(line.quantity)),
|
|
|
weight: ceilMothershipNumeric(line.weightLb),
|
|
|
length: ceilMothershipNumeric(line.lengthIn),
|
|
|
width: ceilMothershipNumeric(line.widthIn),
|
|
|
height: ceilMothershipNumeric(line.heightIn),
|
|
|
hazmat: false,
|
|
|
alcohol: false,
|
|
|
tobacco: false,
|
|
|
commodities: [
|
|
|
{
|
|
|
id: randomUUID(),
|
|
|
description: "",
|
|
|
nmfcCode: "",
|
|
|
isHazardous: false,
|
|
|
},
|
|
|
],
|
|
|
};
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/** 解析官网 Ready time(12h,如 8:00 AM / 4:00 PM) */
|
|
|
export function parseMsReadyTimeToHm(readyTime?: string): { h: number; m: number } {
|
|
|
const raw = String(readyTime ?? "").trim();
|
|
|
const hit = raw.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
|
|
|
if (!hit) return { h: 8, m: 0 };
|
|
|
let h = Number(hit[1]);
|
|
|
const minute = Number(hit[2]);
|
|
|
const ap = hit[3]!.toUpperCase();
|
|
|
if (ap === "AM") {
|
|
|
if (h === 12) h = 0;
|
|
|
} else if (h !== 12) {
|
|
|
h += 12;
|
|
|
}
|
|
|
if (!Number.isFinite(h) || !Number.isFinite(minute)) return { h: 8, m: 0 };
|
|
|
return { h, m: minute };
|
|
|
}
|
|
|
|
|
|
/** 把提货地墙上时间转 UTC ISO,对齐官网 pickupDate */
|
|
|
export function zonedWallTimeToUtcIso(
|
|
|
ymd: string,
|
|
|
hour: number,
|
|
|
minute: number,
|
|
|
timeZone: string,
|
|
|
): string {
|
|
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd.trim());
|
|
|
if (!m) return new Date().toISOString();
|
|
|
const y = Number(m[1]);
|
|
|
const mo = Number(m[2]);
|
|
|
const d = Number(m[3]);
|
|
|
const tz = timeZone.trim() || "UTC";
|
|
|
let utcMs = Date.UTC(y, mo - 1, d, hour, minute, 0);
|
|
|
for (let i = 0; i < 4; i += 1) {
|
|
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
|
timeZone: tz,
|
|
|
year: "numeric",
|
|
|
month: "2-digit",
|
|
|
day: "2-digit",
|
|
|
hour: "2-digit",
|
|
|
minute: "2-digit",
|
|
|
hourCycle: "h23",
|
|
|
}).formatToParts(new Date(utcMs));
|
|
|
const num = (type: string) =>
|
|
|
Number(parts.find((p) => p.type === type)?.value ?? NaN);
|
|
|
const localAsUtc = Date.UTC(
|
|
|
num("year"),
|
|
|
num("month") - 1,
|
|
|
num("day"),
|
|
|
num("hour"),
|
|
|
num("minute"),
|
|
|
0,
|
|
|
);
|
|
|
const targetAsUtc = Date.UTC(y, mo - 1, d, hour, minute, 0);
|
|
|
const diff = targetAsUtc - localAsUtc;
|
|
|
if (diff === 0) break;
|
|
|
utcMs += diff;
|
|
|
}
|
|
|
return new Date(utcMs).toISOString();
|
|
|
}
|
|
|
|
|
|
export function buildPickupDateIso(
|
|
|
req: QuoteRequest,
|
|
|
pickupTimeZone?: string,
|
|
|
): string {
|
|
|
const raw = req.readyDate?.trim();
|
|
|
const snapped = raw
|
|
|
? snapMothershipReadyDateToWeekday(
|
|
|
normalizeMothershipReadyDateIso(raw) || raw,
|
|
|
)
|
|
|
: "";
|
|
|
const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(snapped);
|
|
|
const { h, m } = parseMsReadyTimeToHm(req.readyTime);
|
|
|
if (ymd && pickupTimeZone?.trim()) {
|
|
|
return zonedWallTimeToUtcIso(snapped, h, m, pickupTimeZone.trim());
|
|
|
}
|
|
|
if (ymd) {
|
|
|
return new Date(
|
|
|
Date.UTC(Number(ymd[1]), Number(ymd[2]) - 1, Number(ymd[3]), 16, 0, 0),
|
|
|
).toISOString();
|
|
|
}
|
|
|
const d = new Date();
|
|
|
d.setUTCDate(d.getUTCDate() + 2);
|
|
|
while (d.getUTCDay() === 0 || d.getUTCDay() === 6) {
|
|
|
d.setUTCDate(d.getUTCDate() + 1);
|
|
|
}
|
|
|
d.setUTCHours(16, 0, 0, 0);
|
|
|
return d.toISOString();
|
|
|
}
|
|
|
|
|
|
async function dashboardFetch(
|
|
|
cookie: string,
|
|
|
idToken: string,
|
|
|
url: string,
|
|
|
init?: RequestInit,
|
|
|
): Promise<{ status: number; body: unknown; text: string }> {
|
|
|
const res = await fetch(url, {
|
|
|
...init,
|
|
|
headers: {
|
|
|
Accept: "application/json",
|
|
|
"Content-Type": "application/json",
|
|
|
Origin: MS_DASHBOARD_ORIGIN,
|
|
|
Referer: `${MS_DASHBOARD_ORIGIN}/ship`,
|
|
|
Cookie: cookie,
|
|
|
Authorization: `Bearer ${idToken}`,
|
|
|
...(init?.headers ?? {}),
|
|
|
},
|
|
|
});
|
|
|
const text = await res.text();
|
|
|
let body: unknown = text;
|
|
|
try {
|
|
|
body = JSON.parse(text);
|
|
|
} catch {
|
|
|
/* raw */
|
|
|
}
|
|
|
return { status: res.status, body, text };
|
|
|
}
|
|
|
|
|
|
export async function resolveDashboardPlace(
|
|
|
cookie: string,
|
|
|
idToken: string,
|
|
|
query: string,
|
|
|
locationType: "pickupLocation" | "deliveryLocation",
|
|
|
): Promise<MsPlace> {
|
|
|
const sessionToken = randomUUID();
|
|
|
const search = await dashboardFetch(
|
|
|
cookie,
|
|
|
idToken,
|
|
|
`${MS_DASHBOARD_PLACE_AUTOCOMPLETE_URL}?queryString=${encodeURIComponent(query)}&shipmentLocationType=${locationType}&sessionToken=${encodeURIComponent(sessionToken)}`,
|
|
|
);
|
|
|
if (search.status === 401 || search.status === 403) {
|
|
|
throw new RpaError("SESSION_EXPIRED", "MotherShip 登录会话已失效", {
|
|
|
retryable: true,
|
|
|
});
|
|
|
}
|
|
|
if (search.status >= 300) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_SUGGESTION_NOT_FOUND",
|
|
|
`地址联想失败 HTTP ${search.status}`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
const root = search.body as {
|
|
|
predictions?: Array<{
|
|
|
providerPlaceId?: string;
|
|
|
provider?: string;
|
|
|
mainText?: string;
|
|
|
description?: string;
|
|
|
}>;
|
|
|
sessionToken?: string;
|
|
|
};
|
|
|
const pred = root.predictions?.[0];
|
|
|
if (!pred?.providerPlaceId) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_SUGGESTION_NOT_FOUND",
|
|
|
`未找到地址候选:${query}`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
const provider = pred.provider || "google";
|
|
|
const token = root.sessionToken || sessionToken;
|
|
|
const details = await dashboardFetch(
|
|
|
cookie,
|
|
|
idToken,
|
|
|
`${MS_DASHBOARD_PLACE_DETAILS_URL}?provider=${encodeURIComponent(provider)}&providerId=${encodeURIComponent(pred.providerPlaceId)}&sessionToken=${encodeURIComponent(token)}`,
|
|
|
);
|
|
|
if (details.status === 401 || details.status === 403) {
|
|
|
throw new RpaError("SESSION_EXPIRED", "MotherShip 登录会话已失效", {
|
|
|
retryable: true,
|
|
|
});
|
|
|
}
|
|
|
if (details.status >= 300) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
`地址详情失败 HTTP ${details.status}`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
const raw = details.body as Record<string, unknown>;
|
|
|
const placeId = String(raw.placeId ?? raw.id ?? "");
|
|
|
const lat = Number(raw.latitude);
|
|
|
const lng = Number(raw.longitude);
|
|
|
if (!placeId || !Number.isFinite(lat) || !Number.isFinite(lng)) {
|
|
|
throw new RpaError("ADDRESS_NOT_CONFIRMED", "地址详情缺少坐标/placeId", {
|
|
|
retryable: true,
|
|
|
});
|
|
|
}
|
|
|
return {
|
|
|
placeId,
|
|
|
city: String(raw.city ?? ""),
|
|
|
state: String(raw.state ?? ""),
|
|
|
zip: String(raw.postalCode ?? raw.zip ?? ""),
|
|
|
street: String(
|
|
|
raw.streetPrimary ??
|
|
|
raw.street ??
|
|
|
pred.mainText ??
|
|
|
String(pred.description ?? "").split(",")[0] ??
|
|
|
"",
|
|
|
),
|
|
|
timezone: String(raw.timezone ?? ""),
|
|
|
zone: (raw.zoneId as string | null | undefined) ?? null,
|
|
|
coordinates: { latitude: lat, longitude: lng },
|
|
|
neighborhood: String(raw.neighborhood ?? ""),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export async function bootstrapLoggedInStorageState(): Promise<MsStorageState> {
|
|
|
const creds = getEffectiveMothershipLogin();
|
|
|
if (!creds) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
"dashboard Direct 需要 MotherShip 账密",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
const path = resolveLoggedInStoragePath();
|
|
|
console.log(`[ms-direct] bootstrap login → ${path}`);
|
|
|
const headed = process.env.RPA_HEADED === "true";
|
|
|
const browser = await launchRpaBrowser({ headless: !headed });
|
|
|
try {
|
|
|
const context = await browser.newContext({
|
|
|
viewport: { width: 1440, height: 900 },
|
|
|
});
|
|
|
const page = await context.newPage();
|
|
|
try {
|
|
|
await page.goto(`${MS_DASHBOARD_ORIGIN}/login`, {
|
|
|
waitUntil: "domcontentloaded",
|
|
|
timeout: 60_000,
|
|
|
});
|
|
|
await page.getByTestId("auth-email-input").fill(creds.email);
|
|
|
await page.getByTestId("auth-password-input").fill(creds.password);
|
|
|
await page.getByTestId("auth-log-in-button").click();
|
|
|
await page.waitForURL((u) => !/login|sign-in/i.test(u.href), {
|
|
|
timeout: 45_000,
|
|
|
});
|
|
|
if (/login|sign-in/i.test(page.url())) {
|
|
|
throw new RpaError(
|
|
|
"PROVIDER_LOGIN_FAILED",
|
|
|
"MotherShip 登录失败,请检查账密",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
await context.storageState({ path });
|
|
|
} finally {
|
|
|
await page.close().catch(() => undefined);
|
|
|
await context.close().catch(() => undefined);
|
|
|
}
|
|
|
} finally {
|
|
|
await browser.close().catch(() => undefined);
|
|
|
}
|
|
|
return JSON.parse(fs.readFileSync(path, "utf8")) as MsStorageState;
|
|
|
}
|
|
|
|
|
|
export async function loadLoggedInStorageForDirect(options?: {
|
|
|
allowBootstrap?: boolean;
|
|
|
}): Promise<{ state: MsStorageState; idToken: string; cookie: string }> {
|
|
|
const path = resolveLoggedInStoragePath();
|
|
|
const allowBootstrap = options?.allowBootstrap !== false;
|
|
|
let state: MsStorageState | null = null;
|
|
|
if (fs.existsSync(path)) {
|
|
|
try {
|
|
|
state = JSON.parse(fs.readFileSync(path, "utf8")) as MsStorageState;
|
|
|
} catch {
|
|
|
state = null;
|
|
|
}
|
|
|
}
|
|
|
let idToken = state ? readMothershipIdToken(state) : null;
|
|
|
if (!idToken) {
|
|
|
if (!allowBootstrap) {
|
|
|
throw new RpaError(
|
|
|
"SESSION_EXPIRED",
|
|
|
"缺少登录态 idToken(未允许 bootstrap)",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
state = await bootstrapLoggedInStorageState();
|
|
|
idToken = readMothershipIdToken(state);
|
|
|
}
|
|
|
if (!idToken || !state) {
|
|
|
throw new RpaError("SESSION_EXPIRED", "无法取得 MotherShip idToken", {
|
|
|
retryable: true,
|
|
|
});
|
|
|
}
|
|
|
return {
|
|
|
state,
|
|
|
idToken,
|
|
|
cookie: buildCookieHeader(state),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function mergeAccessorials(
|
|
|
explicit: string[] | undefined,
|
|
|
auto: string[],
|
|
|
): string[] {
|
|
|
return [...new Set([...(explicit ?? []), ...auto].map((x) => x.trim()).filter(Boolean))];
|
|
|
}
|
|
|
|
|
|
async function postDashboardQuoteOnce(
|
|
|
cookie: string,
|
|
|
idToken: string,
|
|
|
req: QuoteRequest,
|
|
|
): Promise<QuoteItem[]> {
|
|
|
const pickupQ = buildAddressQuery(req.pickup);
|
|
|
const deliveryQ = buildAddressQuery(req.delivery);
|
|
|
const [pickup, delivery] = await Promise.all([
|
|
|
resolveDashboardPlace(cookie, idToken, pickupQ, "pickupLocation"),
|
|
|
resolveDashboardPlace(cookie, idToken, deliveryQ, "deliveryLocation"),
|
|
|
]);
|
|
|
|
|
|
const pickupAcc = mergeAccessorials(req.pickupAccessorials, []);
|
|
|
// 只传用户勾选的附加服务;禁止按地址文案自动加 residential/liftgate(会少出承运商)
|
|
|
const deliveryAcc = mergeAccessorials(req.deliveryAccessorials, []);
|
|
|
|
|
|
const pickupLoc = buildDashboardLocation(
|
|
|
pickup,
|
|
|
pickupAcc,
|
|
|
req.mothershipDetails?.pickup,
|
|
|
);
|
|
|
const deliveryLoc = buildDashboardLocation(
|
|
|
delivery,
|
|
|
deliveryAcc,
|
|
|
req.mothershipDetails?.delivery,
|
|
|
);
|
|
|
if (req.mothershipDetails?.fba_number?.trim()) {
|
|
|
pickupLoc.fulfillmentByAmazonId = req.mothershipDetails.fba_number.trim();
|
|
|
}
|
|
|
if (req.mothershipDetails?.fba_po_number?.trim()) {
|
|
|
pickupLoc.purchaseOrderNumber = req.mothershipDetails.fba_po_number.trim();
|
|
|
}
|
|
|
const shipment = {
|
|
|
pickupDate: buildPickupDateIso(req, pickup.timezone),
|
|
|
pickupLocation: pickupLoc,
|
|
|
deliveryLocation: deliveryLoc,
|
|
|
cargo: buildDashboardCargo(req),
|
|
|
};
|
|
|
|
|
|
const quoteRes = await dashboardFetch(cookie, idToken, MS_DASHBOARD_QUOTE_URL, {
|
|
|
method: "POST",
|
|
|
body: JSON.stringify({ shipment, applyCredits: true }),
|
|
|
});
|
|
|
|
|
|
if (quoteRes.status === 401 || quoteRes.status === 403) {
|
|
|
throw new RpaError("SESSION_EXPIRED", "MotherShip 登录会话已失效", {
|
|
|
retryable: true,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
if (quoteRes.status >= 300) {
|
|
|
const brief = quoteRes.text.slice(0, 300);
|
|
|
if (
|
|
|
/unable to find any rates|no carriers available|no capacity/i.test(brief)
|
|
|
) {
|
|
|
throw new RpaError(
|
|
|
"CARRIER_NO_CAPACITY",
|
|
|
"该线路暂无可用报价,请调整地址或货物后重试",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
`dashboard quote HTTP ${quoteRes.status}: ${brief}`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const items = mapDashboardQuoteBodyToItems(quoteRes.body);
|
|
|
if (items.length < 1) {
|
|
|
throw new RpaError(
|
|
|
"CARRIER_NO_CAPACITY",
|
|
|
"该线路暂无可用报价,请调整地址或货物后重试",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
return items;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 登录态 Direct 询价。
|
|
|
* @param allowBootstrap 为 false 时禁止拉起浏览器登录(API inline 用)
|
|
|
*/
|
|
|
export async function fetchMothershipLoggedInDirectQuote(
|
|
|
req: QuoteRequest,
|
|
|
options?: { allowBootstrap?: boolean },
|
|
|
): Promise<QuoteItem[]> {
|
|
|
if (!isMsDashboardDirectQuoteEnabled()) {
|
|
|
throw new RpaError("RPA_DATA_INVALID", "MS dashboard Direct 已关闭", {
|
|
|
retryable: false,
|
|
|
});
|
|
|
}
|
|
|
if (!getEffectiveMothershipLogin()) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
"dashboard Direct 需要 MotherShip 账密",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const allowBootstrap = options?.allowBootstrap !== false;
|
|
|
let auth = await loadLoggedInStorageForDirect({ allowBootstrap });
|
|
|
|
|
|
try {
|
|
|
const items = await postDashboardQuoteOnce(auth.cookie, auth.idToken, req);
|
|
|
console.log(
|
|
|
`[ms-direct] OK tiers=${items.length} carriers=${items
|
|
|
.slice(0, 6)
|
|
|
.map((i) => i.carrier)
|
|
|
.join(",")}`,
|
|
|
);
|
|
|
return items;
|
|
|
} catch (error) {
|
|
|
if (
|
|
|
allowBootstrap &&
|
|
|
error instanceof RpaError &&
|
|
|
error.code === "SESSION_EXPIRED"
|
|
|
) {
|
|
|
console.warn("[ms-direct] SESSION_EXPIRED → re-login once");
|
|
|
auth = {
|
|
|
state: await bootstrapLoggedInStorageState(),
|
|
|
idToken: "",
|
|
|
cookie: "",
|
|
|
};
|
|
|
const token = readMothershipIdToken(auth.state);
|
|
|
if (!token) {
|
|
|
throw error;
|
|
|
}
|
|
|
auth.idToken = token;
|
|
|
auth.cookie = buildCookieHeader(auth.state);
|
|
|
const items = await postDashboardQuoteOnce(auth.cookie, auth.idToken, req);
|
|
|
console.log(
|
|
|
`[ms-direct] OK after re-login tiers=${items.length}`,
|
|
|
);
|
|
|
return items;
|
|
|
}
|
|
|
throw error;
|
|
|
}
|
|
|
}
|