/** * MotherShip 鐧诲綍鎬?dashboard銆孋reate a new shipment銆嶁啋 rate-card 鏌ヤ环 * 鏈夎处瀵嗘椂绂佹璧板尶鍚?Axel Direct锛堜环闈㈡槸 TForce/Daylight 妗d綅锛岄潪 ABF/XPO Direct锛? */ import type { Page } from "playwright"; import fs from "node:fs"; import path from "node:path"; import { launchRpaBrowser } from "@/lib/rpa/browser-launch"; import { resolveStorageStatePath } from "@/lib/axel/session"; import { getEffectiveMothershipLogin, hasEffectiveMothershipLogin, } from "@/lib/rpa/mothership-login-context"; import type { QuoteItem, QuoteRequest } from "@/modules/providers/quote-provider"; import { PROVIDER_LOGIN_FAILED_USER_MESSAGE } from "@/modules/rpa/provider-login-message"; import { RpaError } from "@/modules/rpa/errors"; import { findMothershipCargoWeightBlockMessage, ceilMothershipNumeric, isMothershipWeightEachAllowed, MOTHERSHIP_AVG_WEIGHT_BLOCK_MESSAGE, normalizeMothershipReadyDateIso, snapMothershipReadyDateToWeekday, } from "@/lib/mothership/logged-in-constraints"; import { extractMothershipAccessorialBlockMessages, formatMothershipPortalQuoteMessage, isPortalHardBusinessBlock, isPortalNeedsDetailsMessage, parseMothershipPortalQuoteMessageFromAlerts, parseMothershipPortalQuoteMessageFromBody, } from "@/lib/mothership/portal-quote-messages"; import { isMsRefineHoldExpired, MS_NEEDS_DETAILS_MESSAGE, MS_REFINE_TOTAL_MS, } from "@/lib/constants/ms-refine-hold"; import { readMsRefineHold } from "@/lib/mothership/refine-hold-store"; import { normalizeQuoteItems } from "@/workers/rpa/quote-capture/quote-schema-validator"; import { createContext, getSharedRpaBrowser, withRpaSessionLock, } from "@/workers/rpa/session-manager"; import { canPersistParkedQuoteSession, parkQuoteSession, releaseParkedQuoteSession, sweepExpiredParkedSessions, takeParkedQuoteSession, } from "@/workers/rpa/parked-quote-session"; /** 登录后一级查价稳定入口(录制 mothership-logged-in-20260715-140935.js) */ export const MOTHERSHIP_CREATE_SHIPMENT_URL = "https://dashboard.mothership.com/ship"; /** Continue 后等到价卡:含 Inbox 关闭 / Save&update / 慢车道 */ const RATE_WAIT_MS = 120_000; /** Continue 后先等一级自然出价,避免加载期误判缺二级必填 */ const RATE_GRACE_AFTER_CONTINUE_MS = 25_000; const FIELD_WAIT_MS = 25_000; /** 濉〃寰仠锛氬敖閲忕煭锛岄潬 waitFor 鑰岄潪鐩茬瓑 */ const PAUSE_XS = 80; const PAUSE_SM = 150; const PAUSE_MD = 280; const SUGGEST_WAIT_MS = 1_200; export function parseLoggedInRateCardText(text: string): QuoteItem | null { const compact = text.replace(/\s+/g, " ").trim(); if (!compact) return null; const priceMatch = compact.match(/\$\s*([0-9,]+\.\d{2})/); if (!priceMatch) return null; const total = Number(priceMatch[1]!.replace(/,/g, "")); if (!(Number.isFinite(total) && total > 0)) return null; // 为何:官网文案多样;解析失败时不得写 "—"(validateQuoteSchema 会报「时效为空」) const daysMatch = compact.match(/(\d+)\s*-\s*(\d+)\s*business\s*days?/i) || compact.match(/(\d+)\s*business\s*days?/i) || compact.match(/Est\.?\s*(\d+)/i) || compact.match(/Estimated\s+(\d+)/i) || compact.match(/(\d+)\s*个?工作日/) || compact.match(/(\d+)\s*-\s*(\d+)\s*days?/i) || compact.match(/(\d+)\s*days?(?!\s*ago)/i); const days = daysMatch ? daysMatch[2] ? `${daysMatch[1]}-${daysMatch[2]}` : daysMatch[1]! : "待确认"; let carrier = "MotherShip"; const direct = compact.match( /(?:Selected|Checkmark|已选)?\s*([A-Za-z0-9][A-Za-z0-9 &.+/-]{0,30}?)\s+Direct\b/i, ); if (direct?.[1]) { carrier = `${direct[1].trim()} Direct`; } else { const interline = compact.match( /([A-Za-z0-9][A-Za-z0-9 &.+/-]{0,40}?)\s+Interline\b/i, ); if (interline?.[1]) { carrier = `${interline[1].trim()} Interline`; } else { const selected = compact.match( /(?:Selected|已选)\s+([A-Za-z0-9][A-Za-z0-9 &.+/-]{1,40})/i, ); if (selected?.[1]) carrier = selected[1].trim(); } } const guaranteed = /guaranteed|保证送达/i.test(compact); return { serviceLevel: guaranteed ? "guaranteed" : "standard", rateOption: "bestValue", carrier, transitDays: String(days), transitDescription: days === "待确认" ? "时效待定" : `${days} business days`, rawFreight: total, surcharges: 0, rawTotal: total, }; } /** 结账探针:禁止点击的支付类按钮文案 */ export const MS_CHECKOUT_PAYMENT_BUTTON_RE = /^(Pay(\b| now| with)|Place\s+order|Complete\s+.*payment|Add\s+(a\s+)?payment|Submit\s+payment|Confirm\s+payment)/i; export type MsCheckoutDetailsDefaults = { pickupCompany: string; deliveryCompany: string; pickupSuite: string; deliverySuite: string; pickupFirst: string; pickupLast: string; deliveryFirst: string; deliveryLast: string; pickupEmail: string; deliveryEmail: string; pickupPhone: string; deliveryPhone: string; pickupReference: string; deliveryReference: string; pickupNotes: string; deliveryNotes: string; pickupOpens: string; pickupCloses: string; deliveryOpens: string; deliveryCloses: string; pieceCountType: string; pieceCountQty: string; cargoDescription: string; }; export type MothershipCheckoutProbeResult = { stage: | "agree" | "review" | "checkout_stop_before_payment" | "details_filled"; selectedCarrier: string; selectedTotal: number; screenshotPath?: string; /** fill-only:回读仍为空的字段 key */ missingFields?: string[]; }; /** 支付按钮黑名单(硬停守卫,禁止点击) */ export function isMsCheckoutPaymentButtonLabel(label: string): boolean { const t = label.replace(/\s+/g, " ").trim(); if (!t) return false; if (MS_CHECKOUT_PAYMENT_BUTTON_RE.test(t)) return true; return /\b(credit\s*card|debit\s*card|add\s+(a\s+)?card|billing\s+address)\b/i.test( t, ); } /** 支付页正文特征(到达即硬停,不点支付) */ export function isMsCheckoutPaymentPageText(body: string): boolean { return /Add payment method|Enter card details|Payment method|Complete payment|Pay with|Billing address|Card number/i.test( body, ); } /** 选价:carrierHint 子串优先,否则最低价 */ export function pickLoggedInRateCardIndex( items: Array<{ carrier: string; rawTotal: number }>, carrierHint?: string, ): number { if (items.length === 0) return -1; const hint = (carrierHint ?? "").trim().toLowerCase(); if (hint) { const idx = items.findIndex((x) => x.carrier.toLowerCase().includes(hint), ); if (idx >= 0) return idx; } let best = 0; for (let i = 1; i < items.length; i += 1) { if (items[i]!.rawTotal < items[best]!.rawTotal) best = i; } return best; } export function buildMsCheckoutDetailsDefaults( req: QuoteRequest, ): MsCheckoutDetailsDefaults { const d = req.mothershipDetails; const pieceQty = String( d?.cargo?.[0]?.piece_count_qty ?? req.cargoLines?.[0]?.quantity ?? req.palletCount ?? 1, ); return { pickupCompany: d?.pickup?.company_name || "Demo Pickup Co", deliveryCompany: d?.delivery?.company_name || "Demo Delivery Co", pickupSuite: d?.pickup?.suite || "Ste 100", deliverySuite: d?.delivery?.suite || "Ste 200", pickupFirst: d?.pickup?.contact_first || "Ops", pickupLast: d?.pickup?.contact_last || "Contact", deliveryFirst: d?.delivery?.contact_first || "Ops", deliveryLast: d?.delivery?.contact_last || "Receiver", pickupEmail: d?.pickup?.contact_email || "pickup-ops@example.com", deliveryEmail: d?.delivery?.contact_email || "delivery-ops@example.com", pickupPhone: d?.pickup?.contact_phone || "5555550101", deliveryPhone: d?.delivery?.contact_phone || "5555550102", pickupReference: d?.pickup?.reference || "PO-PICKUP-001", deliveryReference: d?.delivery?.reference || "PO-DELIVERY-001", pickupNotes: d?.pickup?.notes || "Dock door B, call on arrival", deliveryNotes: d?.delivery?.notes || "Receiver desk on floor 2", pickupOpens: d?.pickup?.opens_at || "8:00 AM", pickupCloses: d?.pickup?.closes_at || "5:00 PM", deliveryOpens: d?.delivery?.opens_at || "8:00 AM", deliveryCloses: d?.delivery?.closes_at || "5:00 PM", pieceCountType: d?.cargo?.[0]?.piece_count_type || "Pieces", pieceCountQty: pieceQty, cargoDescription: d?.cargo?.[0]?.description || "General freight pallets", }; } export function buildLoggedInAddressSearchQuery( addr: QuoteRequest["pickup"], ): string { const stripUnitNoise = (s: string) => s .replace( /\b(?:floor|fl|suite|ste|unit|apt|rm|room)\s*#?\s*[\w-]+\b/gi, "", ) .replace(/\s{2,}/g, " ") .replace(/\s+,/g, ",") .trim(); const street = stripUnitNoise(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; } const fallback = addr.mothershipDisplayLabel?.trim() || addr.formattedAddress?.trim() || structured; return stripUnitNoise(fallback); } function addressQuery(addr: QuoteRequest["pickup"]): string { return buildLoggedInAddressSearchQuery(addr); } /** dashboard 鍏ㄥ眬銆孲earch any shipment銆嶉伄缃╋紙璇Е Search / 蹇嵎閿細寮瑰嚭锛?*/ export async function isGlobalShipmentSearchModalOpen( page: Page, ): Promise { const markers = [ page.getByRole("heading", { name: /Search any shipment/i }), page.getByPlaceholder(/Search any shipment/i), page.getByText(/Search by shipment#.*business name/i), ]; for (const loc of markers) { if ((await loc.count()) > 0 && (await loc.first().isVisible())) { return true; } } return false; } export async function dismissGlobalShipmentSearchModal( page: Page, ): Promise { if (!(await isGlobalShipmentSearchModalOpen(page))) { return false; } console.log("[rpa] logged-in: 鍏抽棴 Search any shipment 鍏ㄥ眬寮圭獥"); await page.keyboard.press("Escape"); await page.waitForTimeout(PAUSE_SM); if (!(await isGlobalShipmentSearchModalOpen(page))) { return true; } const closeCandidates = [ page.getByRole("button", { name: /^close$/i }), page.getByRole("button", { name: /close dialog/i }), page.locator('button[aria-label*="close" i]'), page.getByRole("button").filter({ hasText: /^×$|^X$/ }), ]; for (const btn of closeCandidates) { if ((await btn.count()) > 0) { try { await btn.first().click({ timeout: 2_000 }); await page.waitForTimeout(PAUSE_SM); if (!(await isGlobalShipmentSearchModalOpen(page))) { return true; } } catch { /* try next */ } } } await page.keyboard.press("Escape"); await page.waitForTimeout(PAUSE_SM); return !(await isGlobalShipmentSearchModalOpen(page)); } async function isLoginFormVisible(page: Page): Promise { const email = page.getByTestId("auth-email-input"); if ((await email.count()) === 0) return false; return email.first().isVisible().catch(() => false); } /** goto /ship 后等 SPA 落定:表单 or 登录页(避免重定向前误跳过登录) */ async function waitForShipOrLogin( page: Page, timeoutMs = 20_000, ): Promise<"form" | "login" | "unknown"> { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await isCreateShipmentFormVisible(page)) return "form"; const url = page.url().toLowerCase(); if ( url.includes("/login") || url.includes("/sign-in") || (await isLoginFormVisible(page)) ) { return "login"; } await page.waitForTimeout(200); } if (await isCreateShipmentFormVisible(page)) return "form"; const url = page.url().toLowerCase(); if ( url.includes("/login") || url.includes("/sign-in") || (await isLoginFormVisible(page)) ) { return "login"; } return "unknown"; } async function loginDashboardIfNeeded(page: Page): Promise { const url = page.url().toLowerCase(); const onLoginUrl = url.includes("/login") || url.includes("/sign-in"); // 为何:会话过期时 URL 可能仍停在 /ship,仅靠 path 会跳过登录 if (!onLoginUrl && !(await isLoginFormVisible(page))) { return; } const creds = getEffectiveMothershipLogin(); if (!creds) { throw new RpaError( "STRUCT_CHANGE", "dashboard 登录页但无 MotherShip 账密(请管理端配置客户账密,或 .env 填 MOTHERSHIP_EMAIL/PASSWORD,或上传 mothership-logged-in-storage.json)", { retryable: false }, ); } console.log( "[rpa] logged-in: dashboard login email=" + creds.email.slice(0, 2) + "***", ); const email = page.getByTestId("auth-email-input"); const password = page.getByTestId("auth-password-input"); const submit = page.getByTestId("auth-log-in-button"); try { if ((await email.count()) > 0) { await email.waitFor({ state: "visible", timeout: 10_000 }); await email.fill(creds.email); await password.fill(creds.password); await submit.click(); } else { await page.fill( process.env.RPA_SELECTOR_LOGIN_EMAIL ?? 'input[type="email"], input[name="email"]', creds.email, ); await page.fill( process.env.RPA_SELECTOR_LOGIN_PASSWORD ?? 'input[type="password"], input[name="password"]', creds.password, ); await page.click( process.env.RPA_SELECTOR_LOGIN_SUBMIT ?? 'button[type="submit"], button:has-text("Sign in"), button:has-text("Log in")', ); } await page.waitForURL((u) => !/login|sign-in/i.test(u.href), { timeout: 30_000, }); } catch (err) { const brief = err instanceof Error ? err.message : String(err); console.warn( "[rpa] logged-in: dashboard login failed, still on login page. detail=" + brief.slice(0, 200), ); throw new RpaError("PROVIDER_LOGIN_FAILED", PROVIDER_LOGIN_FAILED_USER_MESSAGE, { retryable: false, }); } if ( /\/login|sign-in/i.test(page.url()) || (await isLoginFormVisible(page)) ) { throw new RpaError("PROVIDER_LOGIN_FAILED", PROVIDER_LOGIN_FAILED_USER_MESSAGE, { retryable: false, }); } } async function isCreateShipmentFormVisible(page: Page): Promise { const pickup = page.getByTestId("quote-create-pickup-input-search"); if ((await pickup.count()) === 0) return false; return pickup.first().isVisible().catch(() => false); } /** 等 SPA 渲染出提货搜索框;失败返回 false(不抛) */ async function waitForCreateShipmentForm( page: Page, timeoutMs = FIELD_WAIT_MS, ): Promise { try { await page .getByTestId("quote-create-pickup-input-search") .waitFor({ state: "visible", timeout: timeoutMs }); return true; } catch { return false; } } async function gotoCreateShipmentUrl(page: Page): Promise { console.log( "[rpa] logged-in: goto create shipment url=" + MOTHERSHIP_CREATE_SHIPMENT_URL, ); await page.goto(MOTHERSHIP_CREATE_SHIPMENT_URL, { waitUntil: "domcontentloaded", timeout: 60_000, }); await dismissGlobalShipmentSearchModal(page); } async function openCreateShipment(page: Page): Promise { await dismissGlobalShipmentSearchModal(page); // /ship/single 详情/报价页也可能残留旧控件;禁止早退,必须回到创建页 if ( /\/ship\/single/i.test(page.url()) || (await page.getByTestId("rate-card").count().catch(() => 0)) > 0 || (await page .getByRole("button", { name: /Save\s*&\s*update quote/i }) .count() .catch(() => 0)) > 0 ) { console.log( "[rpa] logged-in: leave quote/details page → force /ship url=" + page.url(), ); await gotoCreateShipmentUrl(page); } else if (await isCreateShipmentFormVisible(page)) { return; } else { // 为何:直达 /ship;须等落定到表单或登录页,禁止重定向前跳过登录 await gotoCreateShipmentUrl(page); } let state = await waitForShipOrLogin(page); console.log("[rpa] logged-in: after /ship state=" + state + " url=" + page.url()); if (state === "login") { await loginDashboardIfNeeded(page); await gotoCreateShipmentUrl(page); state = await waitForShipOrLogin(page); console.log( "[rpa] logged-in: after login state=" + state + " url=" + page.url(), ); } else { await loginDashboardIfNeeded(page); } await dismissGlobalShipmentSearchModal(page); if (state === "form" || (await waitForCreateShipmentForm(page, FIELD_WAIT_MS))) { return; } // 登录后可能落首页,再强制 /ship console.log("[rpa] logged-in: form missing after login, retry /ship"); await gotoCreateShipmentUrl(page); state = await waitForShipOrLogin(page); if (state === "login") { await loginDashboardIfNeeded(page); await gotoCreateShipmentUrl(page); state = await waitForShipOrLogin(page); } await dismissGlobalShipmentSearchModal(page); if (state === "form" || (await waitForCreateShipmentForm(page, FIELD_WAIT_MS))) { return; } // 兜底:旧路径 Ship → Create a new shipment(短超时) const shipLink = page.getByRole("link", { name: /^Ship$/i }); if ((await shipLink.count()) > 0) { console.log("[rpa] logged-in: fallback click Ship menu"); await shipLink.first().click(); await page.waitForTimeout(PAUSE_MD); await dismissGlobalShipmentSearchModal(page); const create = page.getByText( /Create a new shipment|Create shipment|创建新货件|新建货件/i, ); const menuOk = await create .first() .waitFor({ state: "visible", timeout: 8_000 }) .then(() => true) .catch(() => false); if (menuOk) { await create.first().click(); await dismissGlobalShipmentSearchModal(page); if (await waitForCreateShipmentForm(page, FIELD_WAIT_MS)) { return; } } } const body = (await page.locator("body").innerText().catch(() => "")).slice( 0, 400, ); const stillLogin = /\/login|sign-in/i.test(page.url()) || /Sign in to Mothership|auth-email-input|Forgot password/i.test(body); if (stillLogin) { throw new RpaError( "PROVIDER_LOGIN_FAILED", PROVIDER_LOGIN_FAILED_USER_MESSAGE, { retryable: false }, ); } throw new RpaError( "STRUCT_CHANGE", "无法打开登录态 Create shipment 表单(无 quote-create-pickup-input-search)。url=" + page.url() + " body=" + body.replace(/\s+/g, " ").trim(), { retryable: true }, ); } /** * 校验地址输入框已确认(非占位/非错误状态) * 官网确认后:对应侧「Please enter the address…」占位消失,且 input 含地址 token。 */ async function isAddressConfirmed( page: Page, side: "pickup" | "delivery", tokens: string[], ): Promise { const testId = side === "pickup" ? "quote-create-pickup-input-search" : "quote-create-delivery-input-search"; const body = await page.locator("body").innerText().catch(() => ""); const placeholderRe = side === "pickup" ? /Please enter the address of your pick-?up location/i : /Please enter the address of your delivery location/i; if (placeholderRe.test(body)) { return false; } const inputVal = ( await page .getByTestId(testId) .inputValue() .catch(() => "") ).trim(); if (inputVal.length <= 3) { return false; } // 必须命中至少一个地址 token,避免仅搜索未点选却残留输入 const lower = inputVal.toLowerCase(); return tokens.some((t) => t.length >= 3 && lower.includes(t.toLowerCase())); } async function tryPickOnce( page: Page, side: "pickup" | "delivery", query: string, tokens: string[], t0: number, ): Promise { const testId = side === "pickup" ? "quote-create-pickup-input-search" : "quote-create-delivery-input-search"; const input = page.getByTestId(testId); // Inbox/日历遮挡时 click 5s 会直接抛 → 先可见等待 + force await closeInboxDrawer(page); await input.scrollIntoViewIfNeeded().catch(() => undefined); await input .waitFor({ state: "visible", timeout: FIELD_WAIT_MS }) .catch(() => undefined); const clicked = await input .click({ timeout: 5_000 }) .then(() => true) .catch(async () => { await closeInboxDrawer(page); await page.keyboard.press("Escape").catch(() => undefined); return input .click({ timeout: 3_000, force: true }) .then(() => true) .catch(() => false); }); if (!clicked) return false; await input.fill(""); await input.fill(query); const searchBtn = page.getByRole("button", { name: new RegExp( tokens[0] ? `Search\\s+.*${tokens[0]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}` : "^Search\\s", "i", ), }); const listItem = page.getByTestId("search-list-item"); const appeared = await new Promise<"search" | "list" | "timeout">( (resolve) => { let done = false; const finish = (v: "search" | "list" | "timeout") => { if (done) return; done = true; resolve(v); }; const timer = setTimeout(() => finish("timeout"), SUGGEST_WAIT_MS); searchBtn .first() .waitFor({ state: "visible", timeout: SUGGEST_WAIT_MS }) .then(() => { clearTimeout(timer); finish("search"); }) .catch(() => undefined); listItem .first() .waitFor({ state: "visible", timeout: SUGGEST_WAIT_MS }) .then(() => { clearTimeout(timer); finish("list"); }) .catch(() => undefined); }, ); const clickOpt = { timeout: 3_000, force: true as const }; if ((await listItem.count()) > 0 && (await listItem.first().isVisible())) { await listItem.first().click(clickOpt); await page.waitForTimeout(PAUSE_XS); console.log("[rpa] logged-in: suggest side=" + side + " hint=" + appeared + " via=listItem ms=" + String(Date.now() - t0)); return true; } let clickedSearch = false; if ((await searchBtn.count()) > 0 && (await searchBtn.first().isVisible())) { await searchBtn.first().click(clickOpt); await page.waitForTimeout(PAUSE_XS); clickedSearch = true; } if ((await listItem.count()) > 0 && (await listItem.first().isVisible())) { await listItem.first().click(clickOpt); await page.waitForTimeout(PAUSE_XS); console.log("[rpa] logged-in: suggest side=" + side + " hint=" + appeared + " via=search+list ms=" + String(Date.now() - t0)); return true; } // 仅点了 Search、未点选候选:不算确认(避免输入残留导致假阳性) if (clickedSearch) { console.log( "[rpa] logged-in: suggest side=" + side + " via=search-only(无 listItem),不算确认 ms=" + String(Date.now() - t0), ); return false; } const buttons = page.getByRole("button"); const n = await buttons.count(); for (let i = 0; i < Math.min(n, 40); i += 1) { const btn = buttons.nth(i); const name = ((await btn.innerText().catch(() => "")) || "").replace(/\s+/g, " "); if (/^Search any shipment$/i.test(name)) continue; if (/^Search by shipment/i.test(name)) continue; if (/^Search\s/i.test(name) && !tokens.some((t) => name.includes(t))) continue; if (tokens.some((t) => name.toLowerCase().includes(t.toLowerCase()))) { await btn.click(clickOpt); console.log("[rpa] logged-in: suggest side=" + side + " via=tokenBtn ms=" + String(Date.now() - t0)); return true; } } return false; } async function pickAddressSuggestion( page: Page, side: "pickup" | "delivery", query: string, ): Promise { const t0 = Date.now(); // 先关 Inbox 抽屉,否则 input/Search 点击会空等默认 30s await closeInboxDrawer(page); const tokens = query .split(/[,\s]+/) .map((t) => t.trim()) .filter((t) => t.length >= 3) .slice(0, 3); // 最多 2 次:首次 + 失败后清空重试 for (let attempt = 0; attempt < 2; attempt += 1) { if (attempt > 0) { console.log("[rpa] logged-in: 地址未确认,重试 side=" + side + " attempt=" + String(attempt + 1)); await closeInboxDrawer(page); } await tryPickOnce(page, side, query, tokens, t0); await page.waitForTimeout(PAUSE_SM); // 校验地址已真正 commit if (await isAddressConfirmed(page, side, tokens)) { console.log( "[rpa] logged-in: address confirmed side=" + side + " ms=" + String(Date.now() - t0), ); return; } console.log("[rpa] logged-in: address NOT confirmed side=" + side + " attempt=" + String(attempt + 1)); } throw new RpaError( "ADDRESS_SUGGESTION_NOT_FOUND", "登录态地址未确认(官网表单仍空)(" + side + "):" + query.slice(0, 80), { retryable: true }, ); } /** 附加服务 id → 官网可见英文(下拉文案兜底) */ const ACCESSORIAL_LABEL_RE: Record = { cfs: /\bCFS\b/i, liftgate: /liftgate/i, limitedAccess: /limited\s*access/i, inside: /inside/i, residential: /residential/i, tradeshow: /trade\s*show|tradeshow/i, appointment: /appointment/i, fbaAppointment: /fba|amazon.*appointment|appointment.*amazon/i, }; /** 部分官网 testId 与前端 id 不完全一致时的别名 */ const ACCESSORIAL_TESTID_ALIASES: Record = { appointment: ["appointment", "deliveryAppointment", "DeliveryAppointment"], fbaAppointment: ["fbaAppointment", "fba", "amazonAppointment"], limitedAccess: ["limitedAccess", "limited-access", "limited_access"], }; /** 新 UI 平铺 checkbox:按侧区分精确可访问名(2026-07 起无下拉 trigger) */ const ACCESSORIAL_CHECKBOX_NAME: Record< "pickup" | "delivery", Record > = { pickup: { cfs: /^CFS$/i, liftgate: /^Liftgate$/i, limitedAccess: /^Limited Access$/i, inside: /^Inside Pickup$/i, residential: /^Residential$/i, tradeshow: /^Tradeshow$/i, }, delivery: { cfs: /^CFS$/i, liftgate: /^Liftgate$/i, limitedAccess: /^Limited Access$/i, inside: /^Inside Delivery$/i, residential: /^Residential$/i, tradeshow: /^Tradeshow$/i, appointment: /^Appointment Required$/i, fbaAppointment: /^Amazon Appointment$/i, }, }; async function listVisibleAccessorialOptionIds(page: Page): Promise { return page .locator("[data-testid^='address-book-accessorials-option-']") .evaluateAll((els) => els .map((el) => el.getAttribute("data-testid") ?? "") .map((id) => id.replace(/^address-book-accessorials-option-/, "")) .filter(Boolean), ) .catch(() => [] as string[]); } const ACCESSORIAL_TRIGGER_WAIT_MS = 8_000; /** 附加服务前:等该侧地址已确认(无 placeholder + 输入框有值) */ async function waitAddressReadyForAccessorials( page: Page, side: "pickup" | "delivery", ): Promise { const sideZh = side === "pickup" ? "提货" : "派送"; const testId = side === "pickup" ? "quote-create-pickup-input-search" : "quote-create-delivery-input-search"; const placeholderRe = side === "pickup" ? /Please enter the address of your pick-?up location/i : /Please enter the address of your delivery location/i; const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const input = page.getByTestId(testId); await input.scrollIntoViewIfNeeded().catch(() => undefined); const body = await page.locator("body").innerText().catch(() => ""); const val = (await input.inputValue().catch(() => "")).trim(); if (!placeholderRe.test(body) && val.length > 3) { return; } await page.waitForTimeout(PAUSE_SM); } throw new RpaError( "ADDRESS_SUGGESTION_NOT_FOUND", `${sideZh}地址未确认,附加服务入口未出现。请重新选择地址后重试。`, { retryable: true }, ); } /** * 新 UI:Services 区已平铺选项。 * 派送侧须看到第 2 块 Services / 第 2 个同类 checkbox,避免误用提货侧内联状态。 */ async function hasInlineAccessorialCheckboxes( page: Page, side: "pickup" | "delivery" = "pickup", ): Promise { const services = page.getByText(/Services\s*\(accessorials\)/i); const servicesCount = await services.count().catch(() => 0); if (servicesCount === 0) { return false; } if (side === "delivery" && servicesCount < 2) { return false; } const sampleCb = page.getByRole("checkbox", { name: /^(CFS|Liftgate|Residential|Limited Access|Appointment Required)$/i, }); const cbCount = await sampleCb.count().catch(() => 0); if (side === "delivery" && cbCount < 2) { return false; } if (cbCount > 0) { return true; } // 自定义控件可能无 checkbox role:文案可见即视为内联 const sampleLabel = page.getByText( /^(CFS|Liftgate|Inside Pickup|Inside Delivery|Appointment Required)$/i, ); const labelCount = await sampleLabel.count().catch(() => 0); if (side === "delivery" && labelCount < 2) { return false; } return labelCount > 0; } async function clickInlineAccessorialCheckbox( page: Page, side: "pickup" | "delivery", id: string, ): Promise { const nameRe = ACCESSORIAL_CHECKBOX_NAME[side][id]; if (!nameRe) { return false; } const boxes = page.getByRole("checkbox", { name: nameRe }); let boxCount = await boxes.count().catch(() => 0); // Details 页 a11y 名常带 Recommended/描述,放宽匹配 const looseBoxes = boxCount === 0 && /liftgate/i.test(id) ? page.getByRole("checkbox", { name: /Liftgate/i }) : boxCount === 0 && /residential/i.test(id) ? page.getByRole("checkbox", { name: /Residential/i }) : null; const resolvedBoxes = looseBoxes ?? boxes; boxCount = await resolvedBoxes.count().catch(() => 0); if (boxCount > 0) { const target = side === "pickup" ? resolvedBoxes.first() : resolvedBoxes.nth(Math.max(0, boxCount - 1)); await target.scrollIntoViewIfNeeded().catch(() => undefined); const checked = await target.isChecked().catch(() => false); if (checked) { console.log(`[rpa] logged-in: 附加服务已勾选 side=${side} id=${id}`); return true; } await target.click({ timeout: 5_000, force: true }).catch(async () => { await target.click({ timeout: 3_000 }); }); return true; } // 兜底:点可见文案(自定义 chip/label,无 checkbox role) const labels = page.getByText(nameRe); const labelCount = await labels.count().catch(() => 0); if (labelCount === 0) { return false; } const label = side === "pickup" ? labels.first() : labels.nth(Math.max(0, labelCount - 1)); await label.scrollIntoViewIfNeeded().catch(() => undefined); await label.click({ timeout: 5_000 }); return true; } async function openAccessorialDropdown( page: Page, side: "pickup" | "delivery", ): Promise { const sideZh = side === "pickup" ? "提货" : "派送"; await waitAddressReadyForAccessorials(page, side); // 最多 3 轮:等派送侧 Services 渲染 / 点 trigger / Chevron for (let round = 0; round < 3; round += 1) { if (await hasInlineAccessorialCheckboxes(page, side)) { console.log( `[rpa] logged-in: 附加服务已内联展示 side=${side} round=${round}(跳过 trigger)`, ); return; } const triggers = page.getByTestId("address-book-accessorials-trigger"); const triggerCount = await triggers.count().catch(() => 0); if (triggerCount > 0) { const triggerIdx = side === "pickup" ? 0 : Math.max(0, triggerCount - 1); const trigger = triggers.nth(triggerIdx); const triggerReady = await trigger .waitFor({ state: "visible", timeout: ACCESSORIAL_TRIGGER_WAIT_MS }) .then(() => true) .catch(() => false); if (triggerReady) { await trigger.scrollIntoViewIfNeeded().catch(() => undefined); await trigger.click({ timeout: 5_000 }); await page.waitForTimeout(PAUSE_SM); return; } } const chevrons = page.getByRole("button", { name: /Select\s+Chevron\s*down/i, }); const chevronCount = await chevrons.count().catch(() => 0); if (chevronCount > 0) { const chevron = side === "pickup" ? chevrons.first() : chevrons.last(); await chevron.scrollIntoViewIfNeeded().catch(() => undefined); const clicked = await chevron .click({ timeout: 5_000 }) .then(() => true) .catch(() => false); if (clicked) { await page.waitForTimeout(PAUSE_SM); return; } } // 再等一轮让派送侧 Services 出现 console.log( `[rpa] logged-in: 附加服务入口未就绪 side=${side} round=${round},等待重试`, ); await page.waitForTimeout(PAUSE_MD); } throw new RpaError( "STRUCT_CHANGE", `${sideZh}附加服务入口未出现(无内联选项 / address-book-accessorials-trigger / Select Chevron)。可能原因:地址未确认、页面结构变更或登录态异常。`, { retryable: true }, ); } async function clickAccessorialOption( page: Page, side: "pickup" | "delivery", id: string, ): Promise { // 优先新 UI 内联 checkbox if (await clickInlineAccessorialCheckbox(page, side, id)) { return; } const aliases = ACCESSORIAL_TESTID_ALIASES[id] ?? [id]; for (const alias of aliases) { const byTestId = page.getByTestId(`address-book-accessorials-option-${alias}`); const count = await byTestId.count().catch(() => 0); if (count > 0) { const target = byTestId.first(); await target.scrollIntoViewIfNeeded().catch(() => undefined); const visible = await target.isVisible().catch(() => false); if (visible) { await target.click({ timeout: 5_000 }); return; } // 在 DOM 但不可见:下拉动画未完成时 force 点击 await target.click({ force: true, timeout: 5_000 }); return; } } const labelRe = ACCESSORIAL_LABEL_RE[id]; if (labelRe) { const byText = page .locator("[data-testid^='address-book-accessorials-option-']") .filter({ hasText: labelRe }); if ((await byText.count().catch(() => 0)) > 0) { await byText.first().click({ timeout: 5_000 }); return; } const roleOpt = page.getByRole("option", { name: labelRe }); if ((await roleOpt.count().catch(() => 0)) > 0) { await roleOpt.first().click({ timeout: 5_000 }); return; } const roleMenuitem = page.getByRole("menuitemcheckbox", { name: labelRe }); if ((await roleMenuitem.count().catch(() => 0)) > 0) { await roleMenuitem.first().click({ timeout: 5_000 }); return; } } const available = await listVisibleAccessorialOptionIds(page); const sideZh = side === "pickup" ? "提货" : "派送"; throw new RpaError( "CARRIER_NO_CAPACITY", `${sideZh}附加服务「${id}」在官网不可用(可见选项:${available.slice(0, 12).join(",") || "无"})。请取消该附加服务后重试,或确认地址类型是否支持。`, { retryable: false }, ); } async function waitForSideAccessorialUi( page: Page, side: "pickup" | "delivery", timeoutMs: number, ): Promise { const deadline = Date.now() + timeoutMs; const minTriggers = side === "delivery" ? 2 : 1; while (Date.now() < deadline) { if (await hasInlineAccessorialCheckboxes(page, side)) { return true; } const triggers = await page .getByTestId("address-book-accessorials-trigger") .count() .catch(() => 0); if (triggers >= minTriggers) { return true; } const chevrons = await page .getByRole("button", { name: /Select\s+Chevron\s*down/i }) .count() .catch(() => 0); if (chevrons >= minTriggers) { return true; } await page.waitForTimeout(PAUSE_SM); } return false; } /** 用户未勾选时,取消官网已勾的 Inside/Liftgate/Residential 等残留 */ async function clearUnwantedAccessorialChecks( page: Page, side: "pickup" | "delivery", ): Promise { // 下拉式 UI:不先打开则 checkbox 不在 DOM,残留 Residential 清不掉 if (!(await hasInlineAccessorialCheckboxes(page, side))) { try { await openAccessorialDropdown(page, side); } catch { console.log( `[rpa] logged-in: 清残留附加服务跳过(入口不可用)side=${side}`, ); return; } } const nameRe = side === "pickup" ? /^(Inside Pickup|Liftgate|Residential|Limited Access|CFS|Tradeshow)$/i : /^(Inside Delivery|Liftgate|Residential|Limited Access|CFS|Tradeshow|Appointment Required)$/i; const boxes = page.getByRole("checkbox", { name: nameRe }); const n = await boxes.count().catch(() => 0); if (n === 0) return; // 派送侧 checkbox 常在后半段 const start = side === "delivery" && n > 6 ? Math.floor(n / 2) : 0; for (let i = start; i < n; i += 1) { const box = boxes.nth(i); const checked = await box.isChecked().catch(() => false); if (!checked) continue; const label = ((await box.getAttribute("aria-label").catch(() => "")) || (await box.innerText().catch(() => "")) || "").slice(0, 40); const ok = await box .click({ timeout: 2_000, force: true }) .then(() => true) .catch(() => false); if (ok) { console.log( `[rpa] logged-in: 取消残留附加服务 side=${side} label=${label}`, ); } await page.waitForTimeout(PAUSE_XS); } } async function applyAccessorials( page: Page, side: "pickup" | "delivery", ids: string[] | undefined, ): Promise { const wanted = [ ...new Set((ids ?? []).map((id) => id.trim()).filter(Boolean)), ]; if (wanted.length === 0) { // 用户未选:清掉官网残留勾选(上单 Sticky / Recommended),尤其 Inside await clearUnwantedAccessorialChecks(page, side); console.log(`[rpa] logged-in: 跳过附加服务 side=${side}(未勾选,已尝试清残留)`); return; } try { await openAccessorialDropdown(page, side); } catch (err) { const portal = await readPortalAccessorialBlockMessage(page); if (portal) { throw new RpaError("CARRIER_NO_CAPACITY", portal, { retryable: false }); } // 派送侧偶发不渲染 Services:再等一轮 / 不阻断整单出价 if (side === "delivery") { console.warn( `[rpa] logged-in: 派送附加服务入口暂不可用,等待重试 ids=${wanted.join(",")}`, ); const ready = await waitForSideAccessorialUi(page, "delivery", 12_000); if (ready) { try { await openAccessorialDropdown(page, side); } catch (err2) { const portal2 = await readPortalAccessorialBlockMessage(page); if (portal2) { throw new RpaError("CARRIER_NO_CAPACITY", portal2, { retryable: false, }); } throw err2; } } else { console.warn( `[rpa] logged-in: 跳过派送附加服务(入口缺失)ids=${wanted.join(",")},继续询价`, ); return; } } else { throw err; } } const inline = await hasInlineAccessorialCheckboxes(page, side); if (!inline) { // 旧 UI:等首个 option 出现;没有则重开一次 const anyOption = page.locator( "[data-testid^='address-book-accessorials-option-']", ); const appeared = await anyOption .first() .waitFor({ state: "visible", timeout: 8_000 }) .then(() => true) .catch(() => false); if (!appeared) { console.log(`[rpa] logged-in: 附加服务下拉未展开,重试 side=${side}`); await page.keyboard.press("Escape").catch(() => undefined); await page.waitForTimeout(PAUSE_SM); await openAccessorialDropdown(page, side); await anyOption .first() .waitFor({ state: "visible", timeout: 10_000 }) .catch(() => { throw new RpaError( "STRUCT_CHANGE", `${side === "pickup" ? "提货" : "派送"}附加服务下拉未能打开,请稍后重试`, { retryable: true }, ); }); } } for (const id of wanted) { await clickAccessorialOption(page, side, id); await page.waitForTimeout(PAUSE_XS); console.log(`[rpa] logged-in: 勾选附加服务 side=${side} id=${id}`); } if (!inline) { const closeBtn = page.getByRole("button", { name: /\d+\s+selected.*Chevron/i, }); if ((await closeBtn.count()) > 0 && (await closeBtn.first().isVisible())) { await closeBtn.first().click(); } else { await page.keyboard.press("Escape"); } } await page.waitForTimeout(PAUSE_SM); console.log( `[rpa] logged-in: 完成附加服务 side=${side} ids=${wanted.join(",")}`, ); } const EN_MONTH_SHORT_TO_INDEX: Record = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, }; /** YYYY-MM-DD → 日历格子名「Thu Jul 16」(录制 gridcell,无序数词) */ export function formatLoggedInReadyGridCell(isoDate: string): string { const d = new Date(`${isoDate}T12:00:00`); if (Number.isNaN(d.getTime())) { return isoDate; } const weekday = d.toLocaleDateString("en-US", { weekday: "short" }); const month = d.toLocaleDateString("en-US", { month: "short" }); return `${weekday} ${month} ${d.getDate()}`; } /** 官网日格常见「Wed Aug 05 2026」 */ export function formatLoggedInReadyGridCellWithYear(isoDate: string): RegExp { const d = new Date(`${isoDate}T12:00:00`); const weekday = d.toLocaleDateString("en-US", { weekday: "short" }); const month = d.toLocaleDateString("en-US", { month: "short" }); const day = d.getDate(); const year = d.getFullYear(); return new RegExp( `${weekday}\\s+${month}\\s+0?${day}\\s+${year}`, "i", ); } type CalendarMonthRef = { year: number; monthIndex: number }; function calendarMonthKey(ref: CalendarMonthRef): string { return `${ref.year}-${ref.monthIndex}`; } /** 从标题或日格推断当前可见年月 */ async function readVisibleCalendarMonth( page: Page, ): Promise { const longMonthRe = /(January|February|March|April|May|June|July|August|September|October|November|December)\s+(20\d{2})/i; const captionLocs = [ page.getByRole("button", { name: longMonthRe }), page.getByText(longMonthRe), ]; for (const loc of captionLocs) { const n = await loc.count().catch(() => 0); for (let i = 0; i < Math.min(n, 4); i += 1) { const el = loc.nth(i); const visible = await el.isVisible().catch(() => false); if (!visible) continue; const text = ( (await el.getAttribute("aria-label").catch(() => null)) || (await el.innerText().catch(() => "")) || "" ).replace(/\s+/g, " "); const m = text.match(longMonthRe); if (!m) continue; const parsed = new Date(`${m[1]} 1, ${m[2]}`); if (Number.isNaN(parsed.getTime())) continue; return { year: parsed.getFullYear(), monthIndex: parsed.getMonth() }; } } const names = await page .locator('[role="gridcell"], [role="grid"] button') .evaluateAll((els) => els.slice(0, 48).map((el) => (el.getAttribute("aria-label") || el.textContent || "") .replace(/\s+/g, " ") .trim(), ), ) .catch(() => [] as string[]); for (const name of names) { const m = name.match( /\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+(20\d{2})\b/i, ); if (!m) continue; const monthIndex = EN_MONTH_SHORT_TO_INDEX[m[1]!.toLowerCase()]; if (monthIndex == null) continue; return { year: Number(m[2]), monthIndex }; } return null; } async function clickCalendarMonthChevron( page: Page, direction: "next" | "prev", ): Promise { const exactRe = direction === "next" ? /^(Chevron right|Go to next month|Next month)$/i : /^(Chevron left|Go to previous month|Previous month)$/i; const softRe = direction === "next" ? /Chevron right|next month/i : /Chevron left|previous month/i; const ariaSel = direction === "next" ? 'button[aria-label*="next month" i], button[aria-label="Next" i]' : 'button[aria-label*="previous month" i], button[aria-label="Previous" i]'; const pools = [ page.getByRole("button", { name: exactRe }), page.getByRole("button", { name: softRe }), page.locator(ariaSel), ]; for (const loc of pools) { const n = await loc.count().catch(() => 0); if (n <= 0) continue; // 下一月取最右,上一月取最左(避免点到「选年月」旁重复 Chevron) const el = direction === "next" ? loc.last() : loc.first(); const visible = await el.isVisible().catch(() => false); if (!visible) continue; const disabled = (await el.getAttribute("aria-disabled").catch(() => null)) === "true" || (await el.isDisabled().catch(() => false)); if (disabled) continue; await el.click({ force: true }); await page.waitForTimeout(PAUSE_SM); return true; } return false; } /** 选日前翻到目标月(标题/日格出现 August 2026) */ async function ensureCalendarShowsTargetMonth( page: Page, isoDate: string, ): Promise { const d = new Date(`${isoDate}T12:00:00`); const target: CalendarMonthRef = { year: d.getFullYear(), monthIndex: d.getMonth(), }; const targetKey = calendarMonthKey(target); const captionZh = `${target.year}年${target.monthIndex + 1}月`; for (let attempt = 0; attempt < 24; attempt += 1) { const current = await readVisibleCalendarMonth(page); if (current && calendarMonthKey(current) === targetKey) { const monthLong = d.toLocaleDateString("en-US", { month: "long", year: "numeric", }); await page .getByRole("button", { name: new RegExp(monthLong, "i") }) .first() .waitFor({ state: "visible", timeout: 2_000 }) .catch(() => undefined); console.log( `[rpa] logged-in: calendar month ok target=${captionZh} via=${JSON.stringify(current)}`, ); return; } const goNext = !current || current.year < target.year || (current.year === target.year && current.monthIndex < target.monthIndex); const beforeKey = current ? calendarMonthKey(current) : null; const clicked = await clickCalendarMonthChevron( page, goNext ? "next" : "prev", ); if (!clicked) { throw new RpaError( "RPA_DATA_INVALID", `未能切换到可提货月 ${captionZh}:未找到月份切换按钮,请稍后重试`, { retryable: true }, ); } const deadline = Date.now() + 3_000; while (Date.now() < deadline) { const after = await readVisibleCalendarMonth(page); if (after && calendarMonthKey(after) !== beforeKey) break; await page.waitForTimeout(PAUSE_XS); } } throw new RpaError( "RPA_DATA_INVALID", `未能切换到可提货月 ${captionZh},请稍后重试或更换日期`, { retryable: true }, ); } /** 打开日历后点目标日:官网日格低对比度,wait visible 会超时,需 force */ async function clickReadyDateInCalendar( page: Page, isoDate: string, ): Promise { const d = new Date(`${isoDate}T12:00:00`); if (Number.isNaN(d.getTime())) { throw new RpaError("RPA_DATA_INVALID", `可提货日无效:${isoDate}`, { retryable: false, }); } const day = d.getDate(); const cellName = formatLoggedInReadyGridCell(isoDate); const cellWithYearRe = formatLoggedInReadyGridCellWithYear(isoDate); const monthLong = d.toLocaleDateString("en-US", { month: "long" }); const monthShort = d.toLocaleDateString("en-US", { month: "short" }); const year = d.getFullYear(); await ensureCalendarShowsTargetMonth(page, isoDate); const named = await page .locator('[role="gridcell"], [role="grid"] button') .evaluateAll((els) => els.slice(0, 40).map((el) => ({ role: el.getAttribute("role"), name: (el.getAttribute("aria-label") || el.textContent || "") .replace(/\s+/g, " ") .trim() .slice(0, 80), disabled: el.getAttribute("aria-disabled") === "true" || (el as HTMLButtonElement).disabled === true, })), ) .catch( () => [] as Array<{ role: string | null; name: string; disabled: boolean }>, ); if (named.length) { console.log( `[rpa] logged-in: 日历格采样 ${JSON.stringify(named.filter((n) => n.name).slice(0, 12))}`, ); } const candidates = [ page.getByRole("gridcell", { name: cellWithYearRe }), page.getByRole("button", { name: cellWithYearRe }), page.getByRole("gridcell", { name: cellName }), page.getByRole("gridcell", { name: new RegExp( `${cellName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i", ), }), page.getByRole("gridcell", { name: new RegExp( `\\b${monthShort}\\s+0?${day}\\b(?:\\s+${year})?`, "i", ), }), page.getByRole("button", { name: new RegExp( `${monthLong}\\s+${day}(st|nd|rd|th)?,?\\s*${year}`, "i", ), }), page.getByRole("gridcell", { name: new RegExp(`\\b${day}(st|nd|rd|th)?\\b`), }), page.getByRole("button", { name: new RegExp(`^${day}$`) }), ]; for (const loc of candidates) { const n = await loc.count(); for (let i = 0; i < Math.min(n, 8); i += 1) { const el = loc.nth(i); const attached = await el .waitFor({ state: "attached", timeout: 1_500 }) .then(() => true) .catch(() => false); if (!attached) continue; const disabled = (await el.getAttribute("aria-disabled").catch(() => null)) === "true" || (await el.isDisabled().catch(() => false)); if (disabled) continue; const label = ( (await el.getAttribute("aria-label").catch(() => null)) || (await el.innerText().catch(() => "")) || "" ).replace(/\s+/g, " "); // 官网禁选周末:跳过 Sat/Sun 格子 if (/\b(Sat|Sun)\b/i.test(label)) continue; // 避免误点其它月残留格:优先含目标日文案 if ( !label.includes(String(day)) && !new RegExp(`\\b${day}\\b`).test(label) && label !== String(day) ) { continue; } // 若标签带月份,须匹配目标月 if ( /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i.test(label) && !new RegExp(`\\b${monthShort}\\b`, "i").test(label) ) { continue; } await el.click({ force: true }); await page.waitForTimeout(PAUSE_SM); console.log( "[rpa] logged-in: selected ready-date " + isoDate + " via=" + JSON.stringify(label || cellName), ); return; } } console.warn( `[rpa] logged-in: 日历未点到 ${isoDate} cell=${cellName} sample=${named .map((x) => x.name) .filter(Boolean) .slice(0, 8) .join("|")}`, ); throw new RpaError( "RPA_DATA_INVALID", `未能选择可提货日 ${isoDate},请稍后重试或更换日期`, { retryable: true }, ); } /** * Ready date/time dropdown helpers (recording 140935). * Time trigger a11y name is the CURRENT truncated value (e.g. ":00 AM"), * not the target — do not filter by target AM/PM (hits notification items). */ /** 可提货日触发钮:官网 a11y 名偶发不再带 Chevron,禁止只认一种写法 */ const READY_DATE_BTN_RE = /(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s*(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}(st|nd|rd|th)?,?\s*20\d{2}/i; async function openReadyDatePicker(page: Page): Promise { await closeInboxDrawer(page); await dismissFormOverlays(page); const candidates = [ page.getByTestId("ready-pickup-date-select"), page.getByTestId("ready-pickup-date-button"), page.getByRole("button", { name: /(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s*(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}(st|nd|rd|th)?,?\s*20\d{2}.*Chevron/i, }), page.getByRole("button", { name: READY_DATE_BTN_RE }), page .locator("button") .filter({ hasText: READY_DATE_BTN_RE }), ]; for (const loc of candidates) { const n = await loc.count().catch(() => 0); if (n === 0) continue; const btn = loc.first(); const visible = await btn .waitFor({ state: "visible", timeout: 8_000 }) .then(() => true) .catch(() => false); if (!visible) continue; await btn.scrollIntoViewIfNeeded().catch(() => undefined); const clicked = await btn .click({ timeout: 5_000 }) .then(() => true) .catch(async () => { // Inbox/遮罩挡住时 force,避免空等默认 30s await closeInboxDrawer(page); return btn .click({ timeout: 3_000, force: true }) .then(() => true) .catch(() => false); }); if (clicked) { console.log("[rpa] logged-in: opened ready-date picker"); return; } } throw new RpaError( "STRUCT_CHANGE", "登录态未找到可提货日(Ready for pick-up)日期按钮。可能原因:Inbox 遮挡、官网日期控件改版。", { retryable: true }, ); } async function openReadyTimeDropdown(page: Page): Promise { await closeInboxDrawer(page); await dismissFormOverlays(page); const afterLabel = page.getByText("after", { exact: true }); if ((await afterLabel.count()) > 0) { await afterLabel .first() .click({ timeout: 2_000 }) .catch(() => undefined); await page.waitForTimeout(150); } const buttons = page.getByRole("button", { name: /:\d{2}\s*(AM|PM)/i, }); const n = await buttons.count(); for (let i = 0; i < n; i += 1) { const btn = buttons.nth(i); const testId = (await btn.getAttribute("data-testid").catch(() => null)) ?? ""; if (testId.startsWith("notification-") || testId.includes("notification")) { continue; } const label = ( (await btn.getAttribute("aria-label").catch(() => null)) || (await btn.innerText().catch(() => "")) || "" ).replace(/\s+/g, " "); // 鎷掓帀閫氱煡鏂囨锛涘彧瑕佸儚銆?:00 AM銆?銆?00 AM銆嶇殑鐭椂鍒婚挳 if (/reschedule|shipment|notification/i.test(label)) continue; if (!/:\d{2}\s*(AM|PM)/i.test(label) && !/^\s*\d{1,2}:\d{2}\s*(AM|PM)\s*$/i.test(label)) { continue; } if (!(await btn.isVisible().catch(() => false))) continue; await btn .click({ timeout: 5_000 }) .catch(async () => { await closeInboxDrawer(page); await btn.click({ timeout: 3_000, force: true }); }); console.log( "[rpa] logged-in: open ready-time trigger=" + JSON.stringify(label.slice(0, 40)), ); return; } // 鍏滃簳锛歛fter 鍚庣殑绗竴涓煭鏃跺埢鎸夐挳 const nearby = page .locator("button") .filter({ hasText: /^\s*\d{1,2}:\d{2}\s*(AM|PM)\s*$/i }); const nearbyCount = await nearby.count(); for (let i = 0; i < nearbyCount; i += 1) { const btn = nearby.nth(i); const testId = (await btn.getAttribute("data-testid").catch(() => null)) ?? ""; if (testId.startsWith("notification-")) continue; if (!(await btn.isVisible().catch(() => false))) continue; await btn .click({ timeout: 5_000 }) .catch(async () => { await closeInboxDrawer(page); await btn.click({ timeout: 3_000, force: true }); }); console.log("[rpa] logged-in: step"); return; } throw new RpaError( "RPA_DATA_INVALID", "登录态未找到 Ready for pick-up 时刻触发钮", { retryable: true }, ); } async function applyReadyDateTime( page: Page, readyDate: string | undefined, readyTime: string | undefined, ): Promise { if (readyDate?.trim()) { let iso = normalizeMothershipReadyDateIso(readyDate.trim()) ?? readyDate.trim(); if (iso !== readyDate.trim()) { console.log( `[rpa] logged-in: ready_date weekend ${readyDate.trim()} → weekday ${iso}`, ); } try { await openReadyDatePicker(page); await page.waitForTimeout(PAUSE_MD); await clickReadyDateInCalendar(page, iso); // 日历残留会挡住货型下拉 / 派送框 await page.keyboard.press("Escape").catch(() => undefined); await closeInboxDrawer(page); } catch (err) { if (err instanceof RpaError) throw err; const brief = err instanceof Error ? err.message : String(err); throw new RpaError( "STRUCT_CHANGE", "登录态选择可提货日失败:" + brief.slice(0, 180), { retryable: true }, ); } } else { console.log("[rpa] logged-in: skip ready_date (none in request)"); } if (readyTime?.trim()) { const timeLabel = readyTime .trim() .replace(/\uFF1A/g, ":") .replace(/\s+/g, " ") .replace(/([AP])\s*M/i, (_: string, ap: string) => ap.toUpperCase() + "M"); await openReadyTimeDropdown(page); await page.waitForTimeout(PAUSE_SM); const optionExact = page.getByRole("option", { name: timeLabel, exact: true, }); if ((await optionExact.count()) > 0) { await optionExact.first().click({ timeout: 5_000 }); } else { const escaped = timeLabel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const optionLoose = page.getByRole("option", { name: new RegExp("^\\s*" + escaped + "\\s*$", "i"), }); await optionLoose.first().waitFor({ state: "visible", timeout: 8_000 }); await optionLoose.first().click({ timeout: 5_000 }); } await page.waitForTimeout(PAUSE_XS); await page.keyboard.press("Escape").catch(() => undefined); console.log( "[rpa] logged-in: selected ready-time option=" + JSON.stringify(timeLabel), ); } else { console.log("[rpa] logged-in: skip ready_time (none in request)"); } } /** 登录态 cargo type id → 官网下拉英文名(录制选 Pallet/Box) */ const MS_CARGO_TYPE_EN: Record = { pallet: "Pallet", box: "Box", crate: "Crate", piece: "Piece", bale: "Bale", bucket: "Bucket", carton: "Carton", case: "Case", coil: "Coil", cylinder: "Cylinder", drum: "Drum", pail: "Pail", reel: "Reel", roll: "Roll", skid: "Skid", tote: "Tote", tube: "Tube", }; /** LTL 稳出价格型;其余(piece/box/carton/drum…)预切换 Pallet */ const LTL_STABLE_CARGO_TYPES = new Set(["pallet", "skid", "crate"]); function normalizeCargoTypeForMsQuote(cargoTypeId: string): string { const t = String(cargoTypeId || "pallet").trim().toLowerCase(); if (LTL_STABLE_CARGO_TYPES.has(t)) return t; console.log( `[rpa] logged-in: 货型预切换 ${t || "(empty)"} → pallet(提高 LTL 出价率)`, ); return "pallet"; } async function selectCargoTypeAt( page: Page, index: number, cargoTypeId: string, ): Promise { const resolved = normalizeCargoTypeForMsQuote(cargoTypeId); const label = MS_CARGO_TYPE_EN[resolved] ?? "Pallet"; await closeInboxDrawer(page); await dismissFormOverlays(page); await page.keyboard.press("Escape").catch(() => undefined); const resolveTypeInput = () => { const byTestId = page.getByTestId("cargo-type-dropdown-input").nth(index); const byRole = page.getByRole("combobox", { name: /Cargo type/i }).nth(index); return { byTestId, byRole }; }; const deadline = Date.now() + FIELD_WAIT_MS; let typeInput = resolveTypeInput().byTestId; while (Date.now() < deadline) { const locs = resolveTypeInput(); typeInput = locs.byTestId; if (!(await typeInput.isVisible().catch(() => false))) { typeInput = locs.byRole; } await typeInput.scrollIntoViewIfNeeded().catch(() => undefined); if (await typeInput.isVisible().catch(() => false)) break; await page.keyboard.press("Escape").catch(() => undefined); await closeInboxDrawer(page); await dismissFormOverlays(page); await page .getByText(/Cargo type|Handling unit|3\.\s*Cargo|货物/i) .first() .scrollIntoViewIfNeeded() .catch(() => undefined); await page.waitForTimeout(PAUSE_SM); } // 已是目标货型则跳过下拉,避免遮挡导致空等 const current = ( (await typeInput.inputValue().catch(() => "")) || (await typeInput.innerText().catch(() => "")) || "" ) .replace(/\s+/g, " ") .trim(); if (new RegExp(`^${label}$`, "i").test(current)) { console.log( "[rpa] logged-in: cargo type already=" + label + " row=" + index, ); return; } const visible = await typeInput .waitFor({ state: "visible", timeout: 8_000 }) .then(() => true) .catch(() => false); if (!visible) { // Details 页货型区偶发不可见;若正文已显示目标货型则放行 const body = await page.locator("body").innerText().catch(() => ""); if (new RegExp(`Cargo type[\\s\\S]{0,40}${label}`, "i").test(body)) { console.log( "[rpa] logged-in: cargo type UI hidden but page shows " + label, ); return; } throw new RpaError( "STRUCT_CHANGE", "登录态未找到货型下拉(cargo-type / Cargo type)", { retryable: true }, ); } await typeInput .click({ timeout: 5_000 }) .catch(async () => { await closeInboxDrawer(page); await typeInput.click({ timeout: 3_000, force: true }); }); await page.waitForTimeout(PAUSE_SM); const option = page.getByRole("option", { name: new RegExp(`^${label}$`, "i"), }); if ((await option.count()) > 0) { await option.first().click(); } else { const divOpt = page.locator("div").filter({ hasText: new RegExp(`^${label}$`) }); const n = await divOpt.count(); await divOpt.nth(Math.min(Math.max(n - 1, 0), 4)).click(); } await page.waitForTimeout(PAUSE_XS); console.log("[rpa] logged-in: cargo type row=" + index + " type=" + label); } /** Multi-row cargo: fill row0 then cargo-add-button for more rows. */ async function fillCargo(page: Page, req: QuoteRequest): Promise { const lines = req.cargoLines && req.cargoLines.length > 0 ? req.cargoLines : [ { cargoType: "pallet", quantity: req.palletCount || 1, weightLb: req.weightLb, lengthIn: req.dimsIn.l, widthIn: req.dimsIn.w, heightIn: req.dimsIn.h, }, ]; console.log( `[rpa] logged-in: 濉揣鐗╄鏁?${lines.length} types=${lines.map((l) => l.cargoType).join(",")}`, ); const weightBlock = findMothershipCargoWeightBlockMessage( lines.map((l) => ({ weightLb: l.weightLb, quantity: l.quantity })), ); if (weightBlock) { throw new RpaError("RPA_DATA_INVALID", weightBlock, { retryable: false }); } for (let i = 0; i < lines.length; i += 1) { if (i > 0) { const addBtn = page.getByTestId("cargo-add-button"); await addBtn.waitFor({ state: "visible", timeout: FIELD_WAIT_MS }); await addBtn.click(); await page.waitForTimeout(PAUSE_MD); console.log("[rpa] logged-in: step"); } const line = lines[i]!; if (!isMothershipWeightEachAllowed(line.weightLb)) { throw new RpaError( "RPA_DATA_INVALID", `${MOTHERSHIP_AVG_WEIGHT_BLOCK_MESSAGE}(当前 ${line.weightLb} lb)`, { retryable: false }, ); } await selectCargoTypeAt(page, i, line.cargoType); await page .getByTestId("cargo-quantity-input") .nth(i) .fill(String(Math.max(1, Math.round(line.quantity)))); const wt = ceilMothershipNumeric(line.weightLb); const l = ceilMothershipNumeric(line.lengthIn); const w = ceilMothershipNumeric(line.widthIn); const h = ceilMothershipNumeric(line.heightIn); await page.getByTestId("cargo-weight-input").nth(i).fill(String(wt)); await page.getByTestId("cargo-length-input").nth(i).fill(String(l)); await page.getByTestId("cargo-width-input").nth(i).fill(String(w)); await page.getByTestId("cargo-height-input").nth(i).fill(String(h)); console.log( `[rpa] logged-in: 已填货物 row=${i} qty=${line.quantity} wt=${wt} dims=${l}x${w}x${h}`, ); } } /** 可编辑才 fill;disabled 时空等默认 30s 会拖死整单 */ async function safeFillInput( target: import("playwright").Locator, text: string, opts?: { force?: boolean; label?: string }, ): Promise { const value = text.trim(); if (!value) return false; await target.scrollIntoViewIfNeeded().catch(() => undefined); const enabled = await target.isEnabled().catch(() => false); if (!enabled) { console.log( `[rpa] logged-in: skip disabled input ${opts?.label ?? ""}`.trim(), ); if (!opts?.force) return false; // force:用 DOM 写值并派发事件(避免 Playwright fill 等 enabled 30s) const ok = await target .evaluate((el, v) => { const input = el as HTMLInputElement; if (input.isContentEditable) { input.textContent = v; } else { const proto = Object.getPrototypeOf(input); const desc = Object.getOwnPropertyDescriptor(proto, "value"); if (desc?.set) desc.set.call(input, v); else input.value = v; } input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); return true; }, value) .catch(() => false); return Boolean(ok); } await target.click({ timeout: 3_000, force: true }).catch(() => undefined); const filled = await target .fill(value, { timeout: 5_000 }) .then(() => true) .catch(() => false); if (filled) return true; await target .pressSequentially(value, { delay: 5, timeout: 8_000 }) .then(() => true) .catch(() => false); return ((await target.inputValue().catch(() => "")) || "").trim().length > 0; } async function fillLabeledTextNth( page: Page, label: RegExp, index: number, value: string | undefined, opts?: { force?: boolean }, ): Promise { const text = (value ?? "").trim(); if (!text) return; const boxes = page.getByRole("textbox", { name: label }); const loc = (await boxes.count()) > index ? boxes : page.getByLabel(label); if ((await loc.count()) <= index) return; const target = loc.nth(index); const current = (await target.inputValue().catch(() => "")).trim(); if (current && !opts?.force) return; await safeFillInput(target, text, { force: opts?.force, label: `labeled idx=${index}`, }); } async function readLabeledTextNth( page: Page, label: RegExp, index: number, ): Promise { const boxes = page.getByRole("textbox", { name: label }); const loc = (await boxes.count()) > index ? boxes : page.getByLabel(label); if ((await loc.count()) <= index) return ""; return (await loc.nth(index).inputValue().catch(() => "")).trim(); } async function readLabeledOptionNth( page: Page, label: RegExp, index: number, ): Promise { const combos = page.getByRole("combobox", { name: label }); const buttons = page.getByRole("button", { name: label }); const labeled = page.getByLabel(label); const target = (await combos.count()) > index ? combos.nth(index) : (await buttons.count()) > index ? buttons.nth(index) : (await labeled.count()) > index ? labeled.nth(index) : null; if (!target) return ""; return ( (await target.innerText().catch(() => "")) || (await target.inputValue().catch(() => "")) || "" ) .replace(/\s+/g, " ") .trim(); } /** MotherShip Details 下拉是 combobox/button,不是 native select */ async function selectLabeledOptionNth( page: Page, label: RegExp, index: number, optionText: string | undefined, opts?: { force?: boolean }, ): Promise { const want = (optionText ?? "").trim(); if (!want) return; const combos = page.getByRole("combobox", { name: label }); const buttons = page.getByRole("button", { name: label }); const labeled = page.getByLabel(label); let target = (await combos.count()) > index ? combos.nth(index) : (await buttons.count()) > index ? buttons.nth(index) : (await labeled.count()) > index ? labeled.nth(index) : null; if (!target) return; const shown = ( (await target.innerText().catch(() => "")) || (await target.inputValue().catch(() => "")) || "" ) .replace(/\s+/g, " ") .trim(); if ( !opts?.force && shown && !/^select(\s+time)?$/i.test(shown) && shown.toLowerCase().includes(want.toLowerCase().slice(0, 4)) ) { return; } // native