/** * 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 { 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): 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 { if (typeof rate.days === "number" && rate.days > 0) return String(rate.days); const transit = rate.transit as Record | 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): Record[] { // 官网价卡列表是 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; const items: QuoteItem[] = []; const seen = new Set(); 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)) { if (slot == null || typeof slot !== "object" || Array.isArray(slot)) { continue; } for (const [ro, leaf] of Object.entries(slot as Record)) { if (leaf == null || typeof leaf !== "object" || Array.isArray(leaf)) { continue; } const rate = leaf as Record; 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["pickup"], ): Record { 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> = { 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[] { if (Array.isArray(available)) { return available.filter( (entry): entry is Record => entry != null && typeof entry === "object" && !Array.isArray(entry), ); } if (available && typeof available === "object") { return Object.values(available as Record).filter( (entry): entry is Record => entry != null && typeof entry === "object" && !Array.isArray(entry), ); } return []; } export function buildDashboardCargo(req: QuoteRequest): Record[] { 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 { 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; 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 { 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 { 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 { 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; } }