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.

820 lines
25 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.

/**
* 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;
}
}