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.
chajia/workers/rpa/mothership-logged-in-quote.ts

4373 lines
143 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銆孋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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<string, RegExp> = {
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<string, string[]> = {
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<string, RegExp>
> = {
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<string[]> {
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
// 优先新 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<boolean> {
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<void> {
// 下拉式 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<void> {
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<string, number> = {
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<CalendarMonthRef | null> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string, string> = {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<string> {
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<string> {
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<void> {
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 <select> 兜底
const tag = await target.evaluate((el) => el.tagName).catch(() => "");
if (tag === "SELECT") {
await target.selectOption({ label: want }).catch(() =>
target!.selectOption({ index: 1 }),
);
return;
}
await target.click({ force: true }).catch(() => undefined);
await page.waitForTimeout(PAUSE_SM);
const escaped = want.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const exact = page.getByRole("option", { name: want, exact: true });
if ((await exact.count()) > 0) {
await exact.first().click();
} else {
const loose = page.getByRole("option", {
name: new RegExp("^\\s*" + escaped + "\\s*$", "i"),
});
if ((await loose.count()) > 0) {
await loose.first().click();
} else {
const any = page.getByRole("option");
const n = await any.count();
for (let i = 0; i < n; i += 1) {
const t = ((await any.nth(i).innerText().catch(() => "")) || "").trim();
if (t && !/^select/i.test(t)) {
await any.nth(i).click().catch(() => undefined);
break;
}
}
}
}
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(PAUSE_XS);
}
async function selectDropdownUnderLabel(
page: Page,
labelRe: RegExp,
optionText: string,
nthLabel: number,
): Promise<boolean> {
const want = optionText.trim();
if (!want) return false;
const labels = page.getByText(labelRe);
if ((await labels.count()) <= nthLabel) return false;
const label = labels.nth(nthLabel);
await label.scrollIntoViewIfNeeded().catch(() => undefined);
await page.waitForTimeout(PAUSE_XS);
// 标签下方最近的 button/combobox(官网 Opens at → Select time)
const root = label.locator(
"xpath=ancestor::*[.//button or .//*[@role='combobox']][1]",
);
const trigger = root
.getByRole("button")
.or(root.getByRole("combobox"))
.first();
if ((await trigger.count()) === 0) return false;
const shown = (
(await trigger.innerText().catch(() => "")) ||
(await trigger.inputValue().catch(() => "")) ||
""
)
.replace(/\s+/g, " ")
.trim();
if (
shown &&
!/^select(\s+time)?$/i.test(shown) &&
shown.toLowerCase().includes(want.toLowerCase().slice(0, 4))
) {
return true;
}
await trigger.click({ force: true }).catch(() => undefined);
await page.waitForTimeout(PAUSE_SM);
const escaped = want.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const exact = page.getByRole("option", { name: want, exact: true });
if ((await exact.count()) > 0) {
await exact.first().click();
} else {
const loose = page.getByRole("option", {
name: new RegExp(escaped, "i"),
});
if ((await loose.count()) > 0) {
await loose.first().click();
} else {
// 时间下拉:点第一个含 AM/PM 的选项
const any = page.getByRole("option");
const n = await any.count();
let clicked = false;
for (let i = 0; i < n; i += 1) {
const t = ((await any.nth(i).innerText().catch(() => "")) || "").trim();
if (/\d{1,2}:\d{2}\s*(AM|PM)/i.test(t) || (!/^select/i.test(t) && t)) {
if (
t.toLowerCase().includes(want.toLowerCase().slice(0, 4)) ||
/\bAM\b|\bPM\b/i.test(t)
) {
await any.nth(i).click().catch(() => undefined);
clicked = true;
break;
}
}
}
if (!clicked) return false;
}
}
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(PAUSE_XS);
return true;
}
async function fillByPlaceholderNth(
page: Page,
placeholder: RegExp,
index: number,
value: string,
force: boolean,
): Promise<boolean> {
const text = value.trim();
if (!text) return false;
const boxes = page.getByPlaceholder(placeholder);
if ((await boxes.count()) <= index) return false;
const target = boxes.nth(index);
const current = (await target.inputValue().catch(() => "")).trim();
if (current && !force) return true;
return safeFillInput(target, text, {
force,
label: `placeholder idx=${index}`,
});
}
async function fillCargoDescription(
page: Page,
value: string,
force: boolean,
): Promise<void> {
const text = value.trim();
if (!text) return;
// 中英标签 / placeholder 全覆盖
const candidates = [
page.getByRole("textbox", { name: /Cargo description|货物描述/i }),
page.getByPlaceholder(/Accurately describe your cargo/i),
page.getByLabel(/Cargo description|货物描述/i),
];
for (const loc of candidates) {
if ((await loc.count()) === 0) continue;
const target = loc.first();
const current = (await target.inputValue().catch(() => "")).trim();
if (current && !force) return;
await safeFillInput(target, text, { force, label: "cargo.description" });
return;
}
}
/** Details 页推荐附加服务:点 Skip 解锁联系人字段(提货/派送可能各有一个) */
async function clickSkipAccessorialRecommendations(page: Page): Promise<boolean> {
await closeInboxDrawer(page);
const skip = page.locator("button, a, [role='button']").filter({
hasText: /^Skip\b/i,
});
const n = await skip.count();
if (n > 0) {
let clicked = false;
for (let i = 0; i < n; i += 1) {
const btn = skip.nth(i);
const label = ((await btn.innerText().catch(() => "")) || "").replace(
/\s+/g,
" ",
);
if (!/none apply|skip/i.test(label)) continue;
console.log(
"[rpa] logged-in: click Skip #" +
String(i + 1) +
"/" +
String(n) +
" label=" +
label.slice(0, 40),
);
const ok = await btn
.click({ timeout: 3_000, force: true })
.then(() => true)
.catch(() => false);
if (ok) clicked = true;
await page.waitForTimeout(PAUSE_SM);
}
if (clicked) return true;
}
// 无 Skip:一级禁止盲点 Recommended(官网常推荐 Inside Pickup/Delivery,
// 用户未选却被勾上,导致拒价或脏状态)。仅 Skip 解锁字段即可。
console.log(
"[rpa] logged-in: no Skip → 不点 Recommended(避免自动勾 Inside/Liftgate)",
);
return false;
}
/** Full company name:优先 data-testid(与 contact-first-name 同族) */
async function fillCompanyNameNth(
page: Page,
index: number,
value: string,
force: boolean,
): Promise<void> {
const text = value.trim();
if (!text) return;
const side = index === 0 ? "pickup" : "delivery";
const testIds = [
`quote-details-${side}-company-name`,
`quote-details-${side}-company`,
`quote-details-${side}-full-company-name`,
`quote-details-${side}-business-name`,
];
for (const id of testIds) {
const loc = page.getByTestId(id);
if ((await loc.count()) === 0) continue;
console.log(`[rpa] logged-in: fill company testid=${id}`);
await safeFillInput(loc.first(), text, {
force: true,
label: id,
});
return;
}
// 模糊:任意含 company 的 details input
const fuzzy = page.locator(
`[data-testid*="${side}"][data-testid*="company" i], [name*="${side}"][name*="company" i]`,
);
if ((await fuzzy.count()) > 0) {
const id =
(await fuzzy.first().getAttribute("data-testid").catch(() => null)) ||
(await fuzzy.first().getAttribute("name").catch(() => null)) ||
"fuzzy-company";
console.log(`[rpa] logged-in: fill company fuzzy=${id}`);
await safeFillInput(fuzzy.first(), text, {
force: true,
label: String(id),
});
return;
}
const byRole = page.getByRole("textbox", {
name: /^Full company name$/i,
});
if ((await byRole.count()) > index) {
await safeFillInput(byRole.nth(index), text, {
force: true,
label: `company-role idx=${index}`,
});
}
}
/** 联系人字段:按 quote-details-* testid 填(email/phone 无 contact- 前缀) */
async function fillContactFieldsByTestId(
page: Page,
side: "pickup" | "delivery",
d: {
first: string;
last: string;
email: string;
phone: string;
},
): Promise<void> {
const map: Array<[string, string]> = [
[`quote-details-${side}-contact-first-name`, d.first],
[`quote-details-${side}-contact-last-name`, d.last],
[`quote-details-${side}-email`, d.email],
[`quote-details-${side}-contact-email`, d.email],
[`quote-details-${side}-phone-number`, d.phone],
[`quote-details-${side}-contact-phone`, d.phone],
];
for (const [id, val] of map) {
const loc = page.getByTestId(id);
if ((await loc.count()) === 0) continue;
const enabled = await loc.first().isEnabled().catch(() => false);
console.log(
`[rpa] logged-in: fill ${id} enabled=${enabled} val=${val.slice(0, 24)}`,
);
await safeFillInput(loc.first(), val, { force: true, label: id });
}
}
/** Details 关键必填是否已在 DOM 有值(用于区分「真无运力」vs「未填完」) */
async function detailsCriticalFieldsFilled(page: Page): Promise<boolean> {
const need: Array<[string, number]> = [
["quote-details-pickup-company-name", 2],
["quote-details-delivery-company-name", 2],
["quote-details-pickup-contact-first-name", 1],
["quote-details-delivery-contact-first-name", 1],
["quote-details-pickup-email", 3],
["quote-details-delivery-email", 3],
["quote-details-pickup-phone-number", 7],
["quote-details-delivery-phone-number", 7],
];
for (const [id, minLen] of need) {
const loc = page.getByTestId(id);
if ((await loc.count()) === 0) return false;
const v = (await loc.first().inputValue().catch(() => "")).trim();
if (v.length < minLen) {
console.log(`[rpa] logged-in: critical empty ${id}=${JSON.stringify(v)}`);
return false;
}
}
return true;
}
/**
* 仅在明确住宅标记时自动补 liftgate+residential。
* 官网实测:商业地址的 ste/suite/unit 不会自动勾住宅,也能直接出价;
* 旧正则把 ste/suite 当住宅会导致我们多勾附加服务,进而超时或误报「不支持住宅提货」。
*/
export function addressLooksResidential(addr: QuoteRequest["pickup"]): boolean {
const blob = [
addr.street,
addr.formattedAddress,
addr.mothershipDisplayLabel,
]
.filter(Boolean)
.join(" ");
return /\b(apt\.?|apartment)\b/i.test(blob);
}
function mergeAccessorialIds(
ids: string[] | undefined,
extra: string[],
): string[] {
const set = new Set(
[...(ids ?? []), ...extra].map((x) => x.trim()).filter(Boolean),
);
return [...set];
}
async function patchDetailsRequiredFields(
page: Page,
req: QuoteRequest,
opts?: { force?: boolean },
): Promise<boolean> {
if (!opts?.force && !(await shouldPatchDetailsRequired(page))) {
return false;
}
await closeInboxDrawer(page);
await dismissFormOverlays(page);
await clickSkipAccessorialRecommendations(page);
const d = buildMsCheckoutDetailsDefaults(req);
const force = Boolean(opts?.force);
// —— Pick-up ——
await fillCompanyNameNth(page, 0, d.pickupCompany, force);
await fillContactFieldsByTestId(page, "pickup", {
first: d.pickupFirst,
last: d.pickupLast,
email: d.pickupEmail,
phone: d.pickupPhone,
});
await fillLabeledTextNth(page, /Suite\s*\/\s*Unit|Suite|Unit/i, 0, d.pickupSuite, {
force,
});
await fillLabeledTextNth(
page,
/On-site contact first name|^First name$/i,
0,
d.pickupFirst,
{ force },
);
await fillLabeledTextNth(
page,
/On-site contact last name|^Last name$/i,
0,
d.pickupLast,
{ force },
);
// 邮箱:accessible name 不稳定,placeholder 为主
await fillByPlaceholderNth(page, /email@company\.com/i, 0, d.pickupEmail, force);
await fillLabeledTextNth(page, /On-site contact email|contact email|邮箱/i, 0, d.pickupEmail, {
force,
});
// email/phone 真实 testid(无 contact- 前缀)
await safeFillInput(page.getByTestId(`quote-details-pickup-email`), d.pickupEmail, {
force: true,
label: "quote-details-pickup-email",
}).catch(() => undefined);
await safeFillInput(
page.getByTestId(`quote-details-pickup-phone-number`),
d.pickupPhone,
{ force: true, label: "quote-details-pickup-phone-number" },
).catch(() => undefined);
await fillByPlaceholderNth(page, /\(123\)\s*456-7890|phone/i, 0, d.pickupPhone, force);
await fillLabeledTextNth(page, /On-site contact phone|contact phone|电话/i, 0, d.pickupPhone, {
force,
});
await fillByPlaceholderNth(
page,
/Add purchase order or reference number/i,
0,
d.pickupReference,
force,
);
await fillLabeledTextNth(page, /Reference number/i, 0, d.pickupReference, {
force,
});
await fillByPlaceholderNth(
page,
/Key details to help your driver|find the correct location/i,
0,
d.pickupNotes,
force,
);
await fillLabeledTextNth(page, /^Notes$/i, 0, d.pickupNotes, { force });
// —— Deliver to ——
await fillCompanyNameNth(page, 1, d.deliveryCompany, force);
await fillContactFieldsByTestId(page, "delivery", {
first: d.deliveryFirst,
last: d.deliveryLast,
email: d.deliveryEmail,
phone: d.deliveryPhone,
});
await fillLabeledTextNth(page, /Suite\s*\/\s*Unit|Suite|Unit/i, 1, d.deliverySuite, {
force,
});
await fillLabeledTextNth(
page,
/On-site contact first name|^First name$/i,
1,
d.deliveryFirst,
{ force },
);
await fillLabeledTextNth(
page,
/On-site contact last name|^Last name$/i,
1,
d.deliveryLast,
{ force },
);
await fillByPlaceholderNth(page, /email@company\.com/i, 1, d.deliveryEmail, force);
await fillLabeledTextNth(page, /On-site contact email|contact email|邮箱/i, 1, d.deliveryEmail, {
force,
});
await fillByPlaceholderNth(page, /\(123\)\s*456-7890|phone/i, 1, d.deliveryPhone, force);
await fillLabeledTextNth(page, /On-site contact phone|contact phone|电话/i, 1, d.deliveryPhone, {
force,
});
await fillByPlaceholderNth(
page,
/Add purchase order or reference number/i,
1,
d.deliveryReference,
force,
);
await fillLabeledTextNth(page, /Reference number/i, 1, d.deliveryReference, {
force,
});
await fillByPlaceholderNth(
page,
/Key details to help your driver|find the correct location/i,
1,
d.deliveryNotes,
force,
);
await fillLabeledTextNth(page, /^Notes$/i, 1, d.deliveryNotes, { force });
// Hours:accessible name 常为「Select time」
await selectDropdownUnderLabel(page, /^Opens at$/i, d.pickupOpens, 0);
await selectDropdownUnderLabel(page, /^Closes at$/i, d.pickupCloses, 0);
await selectDropdownUnderLabel(page, /^Opens at$/i, d.deliveryOpens, 1);
await selectDropdownUnderLabel(page, /^Closes at$/i, d.deliveryCloses, 1);
// Cargo
await ensurePieceCountTypeSelected(page, d.pieceCountType);
await selectDropdownUnderLabel(
page,
/^Piece count type$/i,
d.pieceCountType,
0,
);
await selectLabeledOptionNth(page, /Piece count type/i, 0, d.pieceCountType, {
force,
});
await fillLabeledTextNth(page, /Piece count quantity/i, 0, d.pieceCountQty, {
force,
});
await fillCargoDescription(page, d.cargoDescription, force);
console.log(
"[rpa] logged-in: patched details required fields force=" + String(force),
);
return true;
}
async function readByPlaceholderNth(
page: Page,
placeholder: RegExp,
index: number,
): Promise<string> {
const boxes = page.getByPlaceholder(placeholder);
if ((await boxes.count()) <= index) return "";
return (await boxes.nth(index).inputValue().catch(() => "")).trim();
}
/** 回读 Details 全字段;空或仍为 Select 记入 missing */
export async function verifyMsCheckoutDetailsFilled(
page: Page,
): Promise<string[]> {
const missing: string[] = [];
const checkText = async (key: string, label: RegExp, index: number) => {
const v = await readLabeledTextNth(page, label, index);
if (!v) missing.push(key);
};
const checkPh = async (key: string, ph: RegExp, index: number) => {
const v = await readByPlaceholderNth(page, ph, index);
if (!v) missing.push(key);
};
const checkOptUnderLabel = async (
key: string,
labelRe: RegExp,
nthLabel: number,
) => {
const labels = page.getByText(labelRe);
if ((await labels.count()) <= nthLabel) {
missing.push(key);
return;
}
const label = labels.nth(nthLabel);
const root = label.locator(
"xpath=ancestor::*[.//button or .//*[@role='combobox']][1]",
);
const trigger = root
.getByRole("button")
.or(root.getByRole("combobox"))
.first();
const v = (
(await trigger.innerText().catch(() => "")) ||
(await trigger.inputValue().catch(() => "")) ||
""
)
.replace(/\s+/g, " ")
.trim();
if (!v || /^select(\s+time)?$/i.test(v)) missing.push(key);
};
await checkText("pickup.company", /Full company name/i, 0);
await checkText("delivery.company", /Full company name/i, 1);
await checkText(
"pickup.first",
/On-site contact first name|^First name$/i,
0,
);
await checkText("pickup.last", /On-site contact last name|^Last name$/i, 0);
await checkText(
"delivery.first",
/On-site contact first name|^First name$/i,
1,
);
await checkText(
"delivery.last",
/On-site contact last name|^Last name$/i,
1,
);
// 邮箱:label 或 placeholder 任一有值即过
{
const pEmail =
(await readLabeledTextNth(page, /On-site contact email|contact email|邮箱/i, 0)) ||
(await readByPlaceholderNth(page, /email@company\.com/i, 0));
if (!pEmail || !pEmail.includes("@")) missing.push("pickup.email");
const dEmail =
(await readLabeledTextNth(page, /On-site contact email|contact email|邮箱/i, 1)) ||
(await readByPlaceholderNth(page, /email@company\.com/i, 1));
if (!dEmail || !dEmail.includes("@")) missing.push("delivery.email");
}
await checkText("pickup.phone", /On-site contact phone|contact phone|电话/i, 0);
await checkText("delivery.phone", /On-site contact phone|contact phone|电话/i, 1);
// Reference / Notes(场景覆盖,必填入探针随机值)
{
const pRef =
(await readLabeledTextNth(page, /Reference number/i, 0)) ||
(await readByPlaceholderNth(
page,
/Add purchase order or reference number/i,
0,
));
if (!pRef) missing.push("pickup.reference");
const dRef =
(await readLabeledTextNth(page, /Reference number/i, 1)) ||
(await readByPlaceholderNth(
page,
/Add purchase order or reference number/i,
1,
));
if (!dRef) missing.push("delivery.reference");
const pNotes =
(await readLabeledTextNth(page, /^Notes$/i, 0)) ||
(await readByPlaceholderNth(
page,
/Key details to help your driver|find the correct location/i,
0,
));
if (!pNotes) missing.push("pickup.notes");
const dNotes =
(await readLabeledTextNth(page, /^Notes$/i, 1)) ||
(await readByPlaceholderNth(
page,
/Key details to help your driver|find the correct location/i,
1,
));
if (!dNotes) missing.push("delivery.notes");
}
await checkOptUnderLabel("pickup.opens_at", /^Opens at$/i, 0);
await checkOptUnderLabel("pickup.closes_at", /^Closes at$/i, 0);
await checkOptUnderLabel("delivery.opens_at", /^Opens at$/i, 1);
await checkOptUnderLabel("delivery.closes_at", /^Closes at$/i, 1);
await checkOptUnderLabel("cargo.piece_count_type", /^Piece count type$/i, 0);
await checkText("cargo.piece_count_qty", /Piece count quantity/i, 0);
const descBox = page.getByRole("textbox", {
name: /Cargo description|货物描述/i,
});
const descPh = page.getByPlaceholder(/Accurately describe your cargo/i);
const desc =
(await descBox.count()) > 0
? descBox.first()
: (await descPh.count()) > 0
? descPh.first()
: null;
const descVal = desc
? (await desc.inputValue().catch(() => "")).trim()
: "";
if (!descVal) missing.push("cargo.description");
// 官网红字仍在 = 未真正写入
const body = await page.locator("body").innerText().catch(() => "");
if (/Pickup location is missing a valid email/i.test(body)) {
if (!missing.includes("pickup.email")) missing.push("pickup.email");
}
if (/Delivery location is missing a valid email/i.test(body)) {
if (!missing.includes("delivery.email")) missing.push("delivery.email");
}
if (/货物描述是必需的|Cargo description is required/i.test(body)) {
if (!missing.includes("cargo.description")) missing.push("cargo.description");
}
return missing;
}
/** Inbox Got it:整次会话最多点 3 次,避免报价页死循环重试 */
let inboxGotItClicks = 0;
async function dismissInboxOnboarding(page: Page): Promise<void> {
if (inboxGotItClicks >= 3) return;
const gotIt = page.getByRole("button", { name: /^Got it$/i });
if ((await gotIt.count()) === 0) return;
const btn = gotIt.first();
if (!(await btn.isVisible().catch(() => false))) return;
inboxGotItClicks += 1;
console.log("[rpa] logged-in: close Inbox Got it #" + String(inboxGotItClicks));
await btn.click({ timeout: 2_000, force: true }).catch(() => undefined);
await page.waitForTimeout(PAUSE_SM);
}
/** Inbox 抽屉挡住表单时 isVisible/click 会空等默认 30s */
async function closeInboxDrawer(page: Page): Promise<void> {
await dismissInboxOnboarding(page);
const marker = page
.getByText(
/Mark all as read|Stay up-to-date on your shipments with Inbox/i,
)
.first();
if (
(await marker.count()) === 0 ||
!(await marker.isVisible().catch(() => false))
) {
return;
}
console.log("[rpa] logged-in: close Inbox drawer Esc");
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(PAUSE_SM);
await dismissInboxOnboarding(page);
if (await marker.isVisible().catch(() => false)) {
// 点表单区域收起抽屉,避免点到导航重置
await page.mouse.click(360, 280).catch(() => undefined);
await page.waitForTimeout(PAUSE_XS);
await page.keyboard.press("Escape").catch(() => undefined);
}
// 仍开着:再 Esc + Got it,避免 Skip/公司名被挡
if (await marker.isVisible().catch(() => false)) {
await dismissInboxOnboarding(page);
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(PAUSE_SM);
}
}
/** 濉〃闃舵杞婚噺娓呴殰锛氬嬁鐐瑰鑸紝閬垮厤鎵撲贡鍦板潃/璐х墿琛ㄥ崟 */
async function dismissFormOverlays(page: Page): Promise<void> {
await dismissGlobalShipmentSearchModal(page);
await dismissInboxOnboarding(page);
const drawerOpen = page.getByText(/Mark all as read/i);
if (
(await drawerOpen.count()) > 0 &&
(await drawerOpen.first().isVisible().catch(() => false))
) {
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(PAUSE_XS);
}
}
/** Continue 鍚庨噸娓呴殰锛欼nbox 鎶藉眽 / 鍏ㄥ眬鎼滅储 / 娈嬬暀寮瑰眰 */
async function dismissBlockingOverlays(page: Page): Promise<void> {
await dismissGlobalShipmentSearchModal(page);
await closeInboxDrawer(page);
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(PAUSE_XS);
}
async function collectPortalRedAlertSnippets(page: Page): Promise<string[]> {
return page
.evaluate(() => {
const out: string[] = [];
const push = (raw: string) => {
const n = String(raw || "")
.replace(/\s+/g, " ")
.trim();
if (n.length < 6 || n.length > 400) return;
if (!out.includes(n)) out.push(n);
};
const nodes = document.querySelectorAll(
'[role="alert"], [aria-live="assertive"], [data-testid*="error" i], [data-testid*="alert" i], [class*="Alert"], [class*="error" i], [class*="Error"]',
);
for (const el of Array.from(nodes)) {
push((el as HTMLElement).innerText || "");
}
const root =
document.querySelector("main") ||
document.querySelector('[data-testid*="quote" i]') ||
document.body;
const all = root.querySelectorAll("p, span, div, li, h1, h2, h3, h4");
for (const el of Array.from(all)) {
const htmlEl = el as HTMLElement;
// 只要叶子或浅层节点,避免整页 Inbox 大块
if (htmlEl.childElementCount > 4) continue;
const style = window.getComputedStyle(htmlEl);
const color = style.color || "";
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (!m) continue;
const r = Number(m[1]);
const g = Number(m[2]);
const b = Number(m[3]);
// 红色系文案(官网底部报错常见)
if (r >= 160 && g <= 110 && b <= 110) {
push(htmlEl.innerText || "");
}
}
return out.slice(0, 16);
})
.catch(() => [] as string[]);
}
async function readPortalQuoteBlockMessage(
page: Page,
): Promise<string | null> {
// 优先 DOM 红色/alert,避免 Inbox 正文污染
const alerts = await collectPortalRedAlertSnippets(page);
const fromAlerts = parseMothershipPortalQuoteMessageFromAlerts(alerts);
if (fromAlerts) return fromAlerts;
const body = await page.locator("body").innerText().catch(() => "");
return parseMothershipPortalQuoteMessageFromBody(body);
}
async function readPortalAccessorialBlockMessage(
page: Page,
): Promise<string | null> {
const alerts = await collectPortalRedAlertSnippets(page);
const fromAlertRaw = extractMothershipAccessorialBlockMessages(
alerts.join("\n"),
);
const fromAlerts = formatMothershipPortalQuoteMessage(fromAlertRaw);
if (fromAlerts) return fromAlerts;
const fullFromAlerts = parseMothershipPortalQuoteMessageFromAlerts(alerts);
if (
fullFromAlerts &&
/附加|住宅|上门取件|accessorial|residential/i.test(fullFromAlerts)
) {
return fullFromAlerts;
}
const body = await page.locator("body").innerText().catch(() => "");
const msgs = extractMothershipAccessorialBlockMessages(body);
return formatMothershipPortalQuoteMessage(msgs);
}
/** 官网业务拒价:如实抛出给前端,非系统超时 */
async function throwIfPortalAccessorialBlocked(page: Page): Promise<void> {
if (await isQuotesLoading(page)) return;
const msg = await readPortalAccessorialBlockMessage(page);
if (!msg) return;
console.log("[rpa] logged-in: portal accessorial blocked: " + msg.slice(0, 200));
throw new RpaError("CARRIER_NO_CAPACITY", msg, { retryable: false });
}
function throwNeedsDetailsForLevel2(portalMsg?: string): never {
console.log(
"[rpa] logged-in: 一级无价 → NEEDS_DETAILS(引导二级): " +
(portalMsg ?? "").slice(0, 160),
);
throw new RpaError("NEEDS_DETAILS", MS_NEEDS_DETAILS_MESSAGE, {
retryable: false,
});
}
async function throwIfPortalQuoteBlocked(
page: Page,
): Promise<void> {
if (await isQuotesLoading(page)) return;
const msg = await readPortalQuoteBlockMessage(page);
if (!msg) return;
// 线路无价等硬拒优先,禁止误导用户去补二级
if (isPortalHardBusinessBlock(msg)) {
console.log("[rpa] logged-in: portal quote blocked: " + msg.slice(0, 200));
throw new RpaError("CARRIER_NO_CAPACITY", msg, { retryable: false });
}
// 「必填项」→ 立刻引导二级,禁止继续空转
if (
isPortalNeedsDetailsMessage(msg) ||
(await shouldPatchDetailsRequired(page))
) {
throwNeedsDetailsForLevel2(msg);
}
console.log("[rpa] logged-in: portal quote blocked: " + msg.slice(0, 200));
throw new RpaError("CARRIER_NO_CAPACITY", msg, { retryable: false });
}
/** piece/box/carton/drum 等常无 LTL 标价 → 改 Pallet 再刷价一次 */
function cargoNeedsPalletFallback(req: QuoteRequest): boolean {
const lines =
req.cargoLines && req.cargoLines.length > 0
? req.cargoLines
: [{ cargoType: req.cargoType || "pallet" }];
return lines.some((l) => {
const t = String(l.cargoType || "").trim().toLowerCase();
return t.length > 0 && !LTL_STABLE_CARGO_TYPES.has(t);
});
}
/** Details 页 Piece count type 常为「Select」导致官网「No rates found / required fields」 */
async function ensurePieceCountTypeSelected(
page: Page,
optionText = "Pieces",
): Promise<boolean> {
const want = optionText.trim() || "Pieces";
const label = page.getByText(/^Piece count type$/i).first();
if ((await label.count()) === 0) return false;
await label.scrollIntoViewIfNeeded().catch(() => undefined);
// 优先:标签后的第一个 button/combobox(避免落到 Cargo type)
const trigger = label
.locator("xpath=following::button[1] | following::*[@role='combobox'][1]")
.first();
const byRole = page.getByRole("combobox", { name: /Piece count type/i }).first();
const target =
(await trigger.count()) > 0 && (await trigger.isVisible().catch(() => false))
? trigger
: byRole;
if ((await target.count()) === 0) {
return selectDropdownUnderLabel(page, /^Piece count type$/i, want, 0);
}
const shown = (
(await target.innerText().catch(() => "")) ||
(await target.inputValue().catch(() => "")) ||
""
)
.replace(/\s+/g, " ")
.trim();
if (shown && !/^select$/i.test(shown) && new RegExp(want, "i").test(shown)) {
return true;
}
await target.click({ force: true }).catch(() => undefined);
await page.waitForTimeout(PAUSE_SM);
const exact = page.getByRole("option", { name: want, exact: true });
if ((await exact.count()) > 0) {
await exact.first().click();
} else {
const opts = page.getByRole("option");
const n = await opts.count();
for (let i = 0; i < n; i += 1) {
const t = ((await opts.nth(i).innerText().catch(() => "")) || "").trim();
if (/^pieces?$/i.test(t) || (/piece/i.test(t) && !/^select/i.test(t))) {
await opts.nth(i).click().catch(() => undefined);
break;
}
}
}
await page.keyboard.press("Escape").catch(() => undefined);
console.log("[rpa] logged-in: piece count type → " + want);
return true;
}
async function recoverNoRatesBySwitchingToPallet(
page: Page,
req: QuoteRequest,
): Promise<boolean> {
if (!cargoNeedsPalletFallback(req)) {
// 已是托盘:只再点一次 Save
if (await clickSaveAndUpdateQuote(page)) {
await page.waitForTimeout(PAUSE_MD);
return true;
}
return false;
}
const lineCount = Math.max(1, req.cargoLines?.length ?? 1);
console.log(
`[rpa] logged-in: 无价恢复 → 货型改为 Pallet rows=${lineCount}`,
);
for (let i = 0; i < lineCount; i += 1) {
try {
await selectCargoTypeAt(page, i, "pallet");
} catch (err) {
const brief = err instanceof Error ? err.message : String(err);
console.warn(
"[rpa] logged-in: pallet recover skip cargo UI: " + brief.slice(0, 120),
);
}
await page.waitForTimeout(PAUSE_XS);
}
if (await clickSaveAndUpdateQuote(page)) {
await page.waitForTimeout(PAUSE_MD);
return true;
}
const continueBtn = page.getByTestId("ship-create-continue-button");
if (
(await continueBtn.count().catch(() => 0)) > 0 &&
(await continueBtn.isVisible().catch(() => false)) &&
!(await continueBtn.isDisabled().catch(() => true))
) {
await continueBtn.click();
await page.waitForTimeout(PAUSE_MD);
return true;
}
return true;
}
async function isQuotesLoading(page: Page): Promise<boolean> {
const markers = [
page.getByText(/loading rates/i),
page.getByText(/getting (your )?quotes?/i),
page.getByText(/calculating/i),
page.getByText(/fetching rates/i),
page.getByText(/updating quote/i),
page.locator('[role="progressbar"]'),
page.locator('[aria-busy="true"]'),
];
for (const m of markers) {
if ((await m.count()) > 0 && (await m.first().isVisible().catch(() => false))) {
return true;
}
}
return false;
}
async function hasRateCardsOrOptions(page: Page): Promise<boolean> {
const rateCount = await page.getByTestId("rate-card").count().catch(() => 0);
if (rateCount > 0) return true;
const optionCount = await page
.locator('[data-testid^="quote-details-rate-option-"]')
.count()
.catch(() => 0);
return optionCount > 0;
}
/** Continue 后 grace 内只等一级自然出价;官网已出必填/硬拒则立刻结束 grace */
async function waitForRatesAfterContinue(page: Page): Promise<boolean> {
const deadline = Date.now() + RATE_GRACE_AFTER_CONTINUE_MS;
let overlayRounds = 0;
while (Date.now() < deadline) {
if (overlayRounds < 3) {
await dismissBlockingOverlays(page);
overlayRounds += 1;
}
if (await hasRateCardsOrOptions(page)) {
const n = await page.getByTestId("rate-card").count().catch(() => 0);
console.log(
"[rpa] logged-in: rate-card after Continue grace count=" + String(n),
);
return true;
}
// 加载中也扫红字:避免 spinner 假阳性空转整段 grace
const portalMsg = await readPortalQuoteBlockMessage(page);
if (portalMsg && isPortalHardBusinessBlock(portalMsg)) {
throw new RpaError("CARRIER_NO_CAPACITY", portalMsg, { retryable: false });
}
if (
portalMsg &&
isPortalNeedsDetailsMessage(portalMsg) &&
!(await isQuotesLoading(page))
) {
console.log(
"[rpa] logged-in: grace 内已见必填提示,提前结束等待: " +
portalMsg.slice(0, 120),
);
return false;
}
if (await shouldPatchDetailsRequired(page)) {
console.log("[rpa] logged-in: grace 内 required fields,提前结束等待");
return false;
}
if (await isQuotesLoading(page)) {
console.log("[rpa] logged-in: rates loading after Continue, waiting...");
await page.waitForTimeout(PAUSE_MD);
continue;
}
await page.waitForTimeout(PAUSE_MD);
}
console.log("[rpa] logged-in: grace elapsed after Continue, still no rate-card");
return false;
}
/** 仅非 loading 且仍被官网阻断时才补二级默认值 */
async function shouldPatchDetailsRequired(page: Page): Promise<boolean> {
if (await hasRateCardsOrOptions(page)) return false;
if (await isQuotesLoading(page)) return false;
const body = await page.locator("body").innerText().catch(() => "");
// 同屏已有「线路无价」红字时,禁止当成缺必填去引导二级
if (
/unable to find any rates for this lane|unfortunately we were unable to find any rates/i.test(
body,
)
) {
return false;
}
return /Please review all required fields to proceed/i.test(body);
}
async function clickSaveAndUpdateQuote(page: Page): Promise<boolean> {
const saveUpdate = page.getByRole("button", {
name: /Save\s*&\s*update quote/i,
});
if ((await saveUpdate.count()) === 0) return false;
const btn = saveUpdate.first();
await btn.scrollIntoViewIfNeeded().catch(() => undefined);
if (!(await btn.isVisible().catch(() => false))) return false;
// 按钮偶发短暂 disabled:等最多 ~3s
const enableDeadline = Date.now() + 3_000;
while (Date.now() < enableDeadline) {
if (!(await btn.isDisabled().catch(() => true))) break;
await page.waitForTimeout(PAUSE_SM);
}
if (await btn.isDisabled().catch(() => true)) {
console.log("[rpa] logged-in: Save & update quote 仍禁用,跳过本轮");
return false;
}
console.log("[rpa] logged-in: 点击 Save & update quote 刷价");
await btn.click({ timeout: 5_000 });
await page.waitForTimeout(PAUSE_MD);
return true;
}
async function clickQuotesStep(page: Page): Promise<boolean> {
// 仅允许点击货件流程内的 Quotes step,禁止误点左侧全局 Quotes 导航
const stepper = page
.locator("div, nav, header, main")
.filter({ hasText: /Quotes[\s\S]{0,40}Details[\s\S]{0,40}Review/i })
.first();
if ((await stepper.count()) > 0) {
const q = stepper.getByText(/^Quotes$/i);
if ((await q.count()) > 0 && (await q.first().isVisible().catch(() => false))) {
console.log("[rpa] logged-in: 鐐瑰嚮杩涘害鏉?Quotes");
await q.first().click().catch(() => undefined);
await page.waitForTimeout(PAUSE_MD);
return true;
}
}
return false;
}
/**
* Continue 后可能落到:Quotes 加载中 / Details+Save&update / Inbox 遮挡 / 无运力。
* 统一清障并驱动到可见 rate-card。
*/
async function ensureRateCardsReady(
page: Page,
req?: QuoteRequest,
): Promise<void> {
await dismissBlockingOverlays(page);
const deadline = Date.now() + RATE_WAIT_MS;
let idleRounds = 0;
let overlayRounds = 0;
let palletRecovered = false;
let needsDetailsRounds = 0;
while (Date.now() < deadline) {
// 遮罩清理限流:最多前 4 轮,避免报价页狂点 Got it
if (overlayRounds < 4) {
await dismissBlockingOverlays(page);
overlayRounds += 1;
} else if (overlayRounds === 4) {
await page.keyboard.press("Escape").catch(() => undefined);
overlayRounds += 1;
}
const rateCards = page.getByTestId("rate-card");
const rateCount = await rateCards.count();
// DOM 已有价卡即就绪(Inbox 遮罩时 isVisible 会假阴性)
if (rateCount > 0) {
if (!(await rateCards.first().isVisible().catch(() => false))) {
await page.keyboard.press("Escape").catch(() => undefined);
await page.waitForTimeout(PAUSE_XS);
}
console.log(
"[rpa] logged-in: rate-card ready count=" + String(rateCount),
);
return;
}
const option = page
.locator('[data-testid^="quote-details-rate-option-"]')
.first();
if ((await option.count()) > 0) {
console.log("[rpa] logged-in: rate-option ready");
return;
}
if (await isQuotesLoading(page)) {
// 即便 loading,官网红字/必填已出则立刻分流,禁止空转到 RATE_WAIT 耗尽
const loadingMsg = await readPortalQuoteBlockMessage(page);
if (loadingMsg && isPortalHardBusinessBlock(loadingMsg)) {
throw new RpaError("CARRIER_NO_CAPACITY", loadingMsg, {
retryable: false,
});
}
if (
loadingMsg &&
isPortalNeedsDetailsMessage(loadingMsg) &&
needsDetailsRounds >= 1
) {
throwNeedsDetailsForLevel2(loadingMsg);
}
idleRounds = 0;
console.log("[rpa] logged-in: rates loading...");
await page.waitForTimeout(PAUSE_MD);
continue;
}
await throwIfPortalAccessorialBlocked(page);
// 一级:只 Skip + Save&update。公司名/联系人留给用户确认后的二级 refine。
await clickSkipAccessorialRecommendations(page);
const saved = await clickSaveAndUpdateQuote(page);
if (saved) {
await page.waitForTimeout(PAUSE_MD);
if (await hasRateCardsOrOptions(page)) return;
const afterSaveMsg = await readPortalQuoteBlockMessage(page);
if (afterSaveMsg && isPortalHardBusinessBlock(afterSaveMsg)) {
throw new RpaError("CARRIER_NO_CAPACITY", afterSaveMsg, {
retryable: false,
});
}
if (
(afterSaveMsg && isPortalNeedsDetailsMessage(afterSaveMsg)) ||
(await shouldPatchDetailsRequired(page))
) {
// 一级 Skip+Save 一轮后仍缺必填 → 立刻引导二级(不再空转)
throwNeedsDetailsForLevel2(afterSaveMsg ?? undefined);
}
idleRounds = 0;
continue;
}
// 官网无价 + 非托盘货型:改 Pallet 再 Save 一次(仍不填 Details 联系人)
if (req && !palletRecovered && !(await isQuotesLoading(page))) {
const portalMsg = await readPortalQuoteBlockMessage(page);
if (
portalMsg &&
/未找到可用报价|no rates found|check.*required fields/i.test(portalMsg)
) {
palletRecovered = true;
if (await recoverNoRatesBySwitchingToPallet(page, req)) {
idleRounds = 0;
await page.waitForTimeout(PAUSE_MD);
continue;
}
}
}
const idlePortalMsg = await readPortalQuoteBlockMessage(page);
if (idlePortalMsg && isPortalHardBusinessBlock(idlePortalMsg)) {
throw new RpaError("CARRIER_NO_CAPACITY", idlePortalMsg, {
retryable: false,
});
}
if (
(idlePortalMsg && isPortalNeedsDetailsMessage(idlePortalMsg)) ||
(await shouldPatchDetailsRequired(page))
) {
needsDetailsRounds += 1;
// 已见必填且 Save 点不动/无效:第二轮 idle 即引导二级
if (needsDetailsRounds >= 1) {
throwNeedsDetailsForLevel2(idlePortalMsg ?? undefined);
}
}
// 已点过 Save 仍无价卡且持续无价文案 → 才判业务无运力
if (idleRounds >= 4) {
await throwIfPortalQuoteBlocked(page);
}
idleRounds += 1;
if (idleRounds === 3 || idleRounds === 8) {
console.log(
"[rpa] logged-in: still no rate-card idle=" +
String(idleRounds) +
" url=" +
page.url(),
);
}
await page.waitForTimeout(PAUSE_MD);
}
if (!(await isQuotesLoading(page))) {
const portalMsg = await readPortalQuoteBlockMessage(page);
if (portalMsg) {
// 一级最后再 Skip+Save 一次;仍无价:必填→引导二级,硬拒→回传官网红字
await closeInboxDrawer(page);
await clickSkipAccessorialRecommendations(page);
await clickSaveAndUpdateQuote(page);
await page.waitForTimeout(PAUSE_MD);
if (await hasRateCardsOrOptions(page)) {
console.log("[rpa] logged-in: rates after final Skip+Save");
return;
}
const finalMsg =
(await readPortalQuoteBlockMessage(page)) || portalMsg;
console.log(
"[rpa] logged-in: portal quote blocked at timeout: " +
finalMsg.slice(0, 200),
);
if (
isPortalNeedsDetailsMessage(finalMsg) &&
!isPortalHardBusinessBlock(finalMsg)
) {
throwNeedsDetailsForLevel2(finalMsg);
}
throw new RpaError("CARRIER_NO_CAPACITY", finalMsg, {
retryable: false,
});
}
}
// 超时前再扫一次红色提示,有则回传客户,避免笼统 PAGE_LOAD_TIMEOUT
const lastPortal = await readPortalQuoteBlockMessage(page);
if (lastPortal) {
if (
isPortalNeedsDetailsMessage(lastPortal) &&
!isPortalHardBusinessBlock(lastPortal)
) {
throwNeedsDetailsForLevel2(lastPortal);
}
throw new RpaError("CARRIER_NO_CAPACITY", lastPortal, {
retryable: false,
});
}
throw new RpaError(
"PAGE_LOAD_TIMEOUT",
"登录态等待 rate-card 超时(已尝试关 Inbox / Save&update / Quotes)",
{ retryable: true },
);
}
async function scrapeRateCards(
page: Page,
req?: QuoteRequest,
): Promise<QuoteItem[]> {
await ensureRateCardsReady(page, req);
// 价卡已在 DOM:只清一次遮罩,不再每轮 dismiss
await page.keyboard.press("Escape").catch(() => undefined);
await dismissInboxOnboarding(page);
const items: QuoteItem[] = [];
for (let attempt = 0; attempt < 4; attempt += 1) {
items.length = 0;
const optionRoots = page.locator(
'[data-testid^="quote-details-rate-option-"]',
);
const optionCount = await optionRoots.count();
if (optionCount > 0) {
for (let i = 0; i < optionCount; i += 1) {
const text = await optionRoots.nth(i).innerText().catch(() => "");
const parsed = parseLoggedInRateCardText(text);
if (parsed) items.push(parsed);
}
} else {
const cards = page.getByTestId("rate-card");
const n = await cards.count();
for (let i = 0; i < n; i += 1) {
const text = await cards.nth(i).innerText().catch(() => "");
const parsed = parseLoggedInRateCardText(text);
if (parsed) items.push(parsed);
}
}
if (items.length > 0) {
console.log(
"[rpa] logged-in: scraped rates=" +
String(items.length) +
" carriers=" +
items.map((x) => x.carrier).join(","),
);
return items;
}
console.log(
"[rpa] logged-in: rate-card text not parsed, retry " +
String(attempt + 1) +
"/4",
);
if (attempt === 1) {
await clickSaveAndUpdateQuote(page);
}
await page.waitForTimeout(PAUSE_MD);
}
throw new RpaError(
"RPA_DATA_INVALID",
"登录态 rate-card 未能解析出承运商报价",
{ retryable: true },
);
}
async function selectRateCardForCheckout(
page: Page,
carrierHint?: string,
): Promise<QuoteItem> {
const optionRoots = page.locator(
'[data-testid^="quote-details-rate-option-"]',
);
const useOptions = (await optionRoots.count()) > 0;
const cards = useOptions ? optionRoots : page.getByTestId("rate-card");
const n = await cards.count();
const parsed: Array<{ item: QuoteItem; index: number }> = [];
for (let i = 0; i < n; i += 1) {
const text = await cards.nth(i).innerText().catch(() => "");
const item = parseLoggedInRateCardText(text);
if (item) parsed.push({ item, index: i });
}
if (parsed.length === 0) {
throw new RpaError(
"RPA_DATA_INVALID",
"结账选价失败:未解析到承运商价卡",
{ retryable: true },
);
}
const pick = pickLoggedInRateCardIndex(
parsed.map((p) => p.item),
carrierHint,
);
const chosen = parsed[pick]!;
console.log(
"[rpa] logged-in: select rate-card carrier=" +
chosen.item.carrier +
" total=" +
String(chosen.item.rawTotal) +
" hint=" +
(carrierHint || "(lowest)"),
);
await cards.nth(chosen.index).click({ force: true });
await page.waitForTimeout(PAUSE_MD);
return chosen.item;
}
/**
* 官网保障方案:Carrier basic / FreightProtect + cargo value
*/
export async function applyMsCoverageOption(
page: Page,
input: {
coverage: "basic" | "freight_protect";
cargoValueUsd?: number;
},
): Promise<void> {
await dismissBlockingOverlays(page);
if (input.coverage === "basic") {
const basic = page.getByText(/Carrier basic coverage/i).first();
if (await basic.isVisible().catch(() => false)) {
await basic.click({ force: true }).catch(() => undefined);
console.log("[rpa] logged-in: coverage=basic");
} else {
console.log("[rpa] logged-in: coverage=basic (控件未找到,保持默认)");
}
await page.waitForTimeout(PAUSE_SM);
return;
}
const value = Number(input.cargoValueUsd);
if (!Number.isFinite(value) || value <= 0) {
throw new RpaError(
"RPA_DATA_INVALID",
"选择 FreightProtect 时必须提供大于 0 的货物价值(美元)",
{ retryable: false },
);
}
const fullLabel = page
.getByText(/FreightProtect full coverage|FreightProtect|full coverage/i)
.first();
if (!(await fullLabel.isVisible().catch(() => false))) {
throw new RpaError(
"STRUCT_CHANGE",
"未找到 FreightProtect 保障选项",
{ retryable: true },
);
}
await fullLabel.click({ force: true });
await page.waitForTimeout(PAUSE_SM);
const cargoInput = page
.getByPlaceholder(/Enter total cargo value|cargo value/i)
.or(page.getByLabel(/What is your cargo value|cargo value/i))
.first();
await cargoInput.waitFor({ state: "visible", timeout: FIELD_WAIT_MS });
const cents = Math.round(value * 100) / 100;
const fillValue = Number.isInteger(cents)
? String(cents)
: cents.toFixed(2);
await cargoInput.click({ force: true });
await cargoInput.fill("");
await cargoInput.fill(fillValue);
await cargoInput.press("Tab").catch(() => undefined);
console.log(
"[rpa] logged-in: coverage=freight_protect cargoValueUsd=" + String(value),
);
await page.waitForTimeout(PAUSE_SM);
}
async function clickProceedToCheckout(page: Page): Promise<void> {
const btn = page.getByRole("button", { name: /Proceed to checkout/i });
await btn.first().waitFor({ state: "visible", timeout: FIELD_WAIT_MS });
for (let i = 0; i < 16; i += 1) {
const target = btn.first();
const disabled = await target.isDisabled().catch(() => true);
if (!disabled) {
console.log("[rpa] logged-in: click Proceed to checkout");
await target.click();
await page.waitForTimeout(PAUSE_MD);
return;
}
if (i === 2 || i === 8) {
await clickSaveAndUpdateQuote(page);
}
await page.waitForTimeout(PAUSE_SM);
}
throw new RpaError(
"RPA_DATA_INVALID",
"Proceed to checkout 仍禁用(Details 必填可能未齐)",
{ retryable: true },
);
}
async function clickAgreeIfSafe(page: Page): Promise<boolean> {
const candidates = page.getByRole("button", {
name: /^(Agree|I agree|Agree and continue)$/i,
});
const n = await candidates.count();
for (let i = 0; i < n; i += 1) {
const btn = candidates.nth(i);
if (!(await btn.isVisible().catch(() => false))) continue;
const label = (
(await btn.innerText().catch(() => "")) ||
(await btn.getAttribute("aria-label").catch(() => "")) ||
""
)
.replace(/\s+/g, " ")
.trim();
if (isMsCheckoutPaymentButtonLabel(label)) {
console.log(
"[rpa] logged-in: skip payment-like Agree label=" + label.slice(0, 80),
);
continue;
}
console.log("[rpa] logged-in: click Agree label=" + label.slice(0, 80));
await btn.click();
await page.waitForTimeout(PAUSE_MD);
return true;
}
return false;
}
async function captureCheckoutScreenshot(page: Page): Promise<string> {
const diagDir = path.join(process.cwd(), ".rpa", "diag");
fs.mkdirSync(diagDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const shot = path.join(diagDir, `ms-checkout-${stamp}.png`);
await page.screenshot({ path: shot, fullPage: true }).catch(() => undefined);
return shot;
}
async function driveCheckoutToAgree(
page: Page,
req: QuoteRequest,
step: (name: string) => void,
opts?: { fillOnly?: boolean },
): Promise<MothershipCheckoutProbeResult> {
const fillOnly =
Boolean(opts?.fillOnly) || process.env.PROBE_MS_FILL_ONLY === "1";
step("select rate-card for checkout");
const carrierHint =
req.preferredCarrier?.trim() ||
process.env.PROBE_MS_CARRIER?.trim() ||
undefined;
const selected = await selectRateCardForCheckout(page, carrierHint);
step("apply coverage option");
const envCoverage = process.env.PROBE_MS_COVERAGE?.trim().toLowerCase();
const coverage: "basic" | "freight_protect" =
req.coverage ??
(envCoverage === "freight_protect" || envCoverage === "full"
? "freight_protect"
: "basic");
const cargoFromEnv = Number(process.env.PROBE_MS_CARGO_VALUE);
const cargoValueUsd =
req.cargoValueUsd ??
(Number.isFinite(cargoFromEnv) && cargoFromEnv > 0
? cargoFromEnv
: undefined);
await applyMsCoverageOption(page, { coverage, cargoValueUsd });
step("force patch details for checkout");
await patchDetailsRequiredFields(page, req, { force: true });
// Save 可能异步刷新表单;保存后再强制复填关键字段
await clickSaveAndUpdateQuote(page);
await page.waitForTimeout(PAUSE_MD);
await patchDetailsRequiredFields(page, req, { force: true });
await page.waitForTimeout(PAUSE_SM);
let missing = await verifyMsCheckoutDetailsFilled(page);
if (missing.length > 0) {
console.log(
"[rpa] logged-in: details missing after first fill, retry: " +
missing.join(","),
);
await patchDetailsRequiredFields(page, req, { force: true });
await page.waitForTimeout(PAUSE_SM);
missing = await verifyMsCheckoutDetailsFilled(page);
}
console.log(
"[rpa] logged-in: details fill verify missing=" +
(missing.length ? missing.join(",") : "(none)"),
);
if (fillOnly) {
const shot = await captureCheckoutScreenshot(page);
if (missing.length > 0) {
console.log(
"[rpa] logged-in: details fill INCOMPLETE shot=" + shot,
);
throw new RpaError(
"RPA_DATA_INVALID",
"Details 未全部填齐: " + missing.join(", ") + " shot=" + shot,
{ retryable: true },
);
}
console.log(
"[rpa] logged-in: checkout reached details_filled shot=" + shot,
);
return {
stage: "details_filled",
selectedCarrier: selected.carrier,
selectedTotal: selected.rawTotal,
screenshotPath: shot,
missingFields: [],
};
}
step("Proceed to checkout");
await clickProceedToCheckout(page);
const deadline = Date.now() + 60_000;
let agreed = false;
while (Date.now() < deadline) {
await dismissBlockingOverlays(page);
const body = await page.locator("body").innerText().catch(() => "");
if (isMsCheckoutPaymentPageText(body)) {
const shot = await captureCheckoutScreenshot(page);
console.log(
"[rpa] logged-in: checkout reached checkout_stop_before_payment shot=" +
shot,
);
return {
stage: "checkout_stop_before_payment",
selectedCarrier: selected.carrier,
selectedTotal: selected.rawTotal,
screenshotPath: shot,
};
}
if (!agreed) {
agreed = await clickAgreeIfSafe(page);
if (agreed) {
await page.waitForTimeout(PAUSE_MD);
continue;
}
}
const hasAgree = await page
.getByRole("button", { name: /^(Agree|I agree|Agree and continue)$/i })
.first()
.isVisible()
.catch(() => false);
const onReview =
/\/review|Review your|Review &/i.test(page.url() + "\n" + body) ||
(await page.getByText(/^Review$/i).first().isVisible().catch(() => false));
if (agreed || (!hasAgree && onReview)) {
const shot = await captureCheckoutScreenshot(page);
const stage = agreed ? "agree" : "review";
console.log(
"[rpa] logged-in: checkout reached " + stage + " shot=" + shot,
);
return {
stage,
selectedCarrier: selected.carrier,
selectedTotal: selected.rawTotal,
screenshotPath: shot,
};
}
await page.waitForTimeout(PAUSE_MD);
}
const shot = await captureCheckoutScreenshot(page);
throw new RpaError(
"PAGE_LOAD_TIMEOUT",
"结账等待 Agree/Review 超时 shot=" + shot,
{ retryable: true },
);
}
type LoggedInStepFn = (name: string) => void;
/** 共享:填表 → Continue → 刮价;不关浏览器 */
async function driveLoggedInQuoteOnPage(
page: Page,
req: QuoteRequest,
step: LoggedInStepFn,
): Promise<QuoteItem[]> {
step("goto create shipment");
await page.goto(MOTHERSHIP_CREATE_SHIPMENT_URL, {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
step("loginIfNeeded");
await loginDashboardIfNeeded(page);
step("dismissGlobalSearch");
await dismissGlobalShipmentSearchModal(page);
step("openCreateShipment");
await openCreateShipment(page);
step(`pickup address: ${addressQuery(req.pickup).slice(0, 60)}`);
await pickAddressSuggestion(page, "pickup", addressQuery(req.pickup));
step(`delivery address: ${addressQuery(req.delivery).slice(0, 60)}`);
await pickAddressSuggestion(page, "delivery", addressQuery(req.delivery));
{
const pickupTokens = addressQuery(req.pickup)
.split(/[,\s]+/)
.filter((t) => t.length >= 3)
.slice(0, 3);
const deliveryTokens = addressQuery(req.delivery)
.split(/[,\s]+/)
.filter((t) => t.length >= 3)
.slice(0, 3);
if (!(await isAddressConfirmed(page, "pickup", pickupTokens))) {
console.log("[rpa] logged-in: 附加服务前补选提货地址");
await pickAddressSuggestion(page, "pickup", addressQuery(req.pickup));
}
if (!(await isAddressConfirmed(page, "delivery", deliveryTokens))) {
console.log("[rpa] logged-in: 附加服务前补选派送地址");
await pickAddressSuggestion(page, "delivery", addressQuery(req.delivery));
}
}
const pickupAcc = mergeAccessorialIds(
req.pickupAccessorials,
// 官网:不支持住宅提货;禁止自动加 residential/liftgate 到 pickup
[],
);
const deliveryAcc = mergeAccessorialIds(
req.deliveryAccessorials,
addressLooksResidential(req.delivery) ? ["liftgate", "residential"] : [],
);
if (
pickupAcc.join() !== (req.pickupAccessorials ?? []).join() ||
deliveryAcc.join() !== (req.deliveryAccessorials ?? []).join()
) {
console.log(
`[rpa] logged-in: residential auto-accessorials pickup=[${pickupAcc}] delivery=[${deliveryAcc}]`,
);
}
console.log(
`[rpa] logged-in payload accessorials pickup=[${pickupAcc.join(",")}] delivery=[${deliveryAcc.join(",")}] ready=${req.readyDate ?? "-"} ${req.readyTime ?? "-"}`,
);
step(`pickup accessorials: ${pickupAcc.join(",") || "(none)"}`);
await applyAccessorials(page, "pickup", pickupAcc);
{
const deliveryInput = page.getByTestId("quote-create-delivery-input-search");
await deliveryInput.scrollIntoViewIfNeeded().catch(() => undefined);
await deliveryInput.click({ timeout: 3_000 }).catch(() => undefined);
const ready = await waitForSideAccessorialUi(page, "delivery", 18_000);
if (!ready && deliveryAcc.length > 0) {
console.log("[rpa] logged-in: 派送附加服务 UI 未就绪,补选派送地址");
await pickAddressSuggestion(page, "delivery", addressQuery(req.delivery));
await waitForSideAccessorialUi(page, "delivery", 12_000);
}
}
step(`delivery accessorials: ${deliveryAcc.join(",") || "(none)"}`);
await applyAccessorials(page, "delivery", deliveryAcc);
step(`ready datetime: ${req.readyDate ?? "-"} ${req.readyTime ?? "-"}`);
await closeInboxDrawer(page);
await dismissFormOverlays(page);
await applyReadyDateTime(page, req.readyDate, req.readyTime);
step("fillCargo");
await dismissFormOverlays(page);
await fillCargo(page, req);
step("click Continue");
await dismissBlockingOverlays(page);
{
const pickupTokens = addressQuery(req.pickup)
.split(/[,\s]+/)
.filter((t) => t.length >= 3)
.slice(0, 3);
const deliveryTokens = addressQuery(req.delivery)
.split(/[,\s]+/)
.filter((t) => t.length >= 3)
.slice(0, 3);
const pickupOk = await isAddressConfirmed(page, "pickup", pickupTokens);
const deliveryOk = await isAddressConfirmed(
page,
"delivery",
deliveryTokens,
);
if (!pickupOk || !deliveryOk) {
const which = !pickupOk ? "pickup" : "delivery";
throw new RpaError(
"ADDRESS_SUGGESTION_NOT_FOUND",
"点 Continue 前地址未确认(" + which + "),中止避免空等超时",
{ retryable: true },
);
}
}
const continueBtn = page.getByTestId("ship-create-continue-button");
await continueBtn.waitFor({ state: "visible", timeout: FIELD_WAIT_MS });
for (let i = 0; i < 12; i += 1) {
if (!(await continueBtn.isDisabled().catch(() => false))) break;
await page.waitForTimeout(PAUSE_SM);
}
if (await continueBtn.isDisabled().catch(() => false)) {
throw new RpaError(
"RPA_DATA_INVALID",
"登录态 Continue 按钮仍禁用(地址/货物可能未确认)",
{ retryable: true },
);
}
await continueBtn.click();
const navigated = await Promise.race([
page.waitForURL(/\/ship\/single/i, { timeout: 45_000 }).then(() => true),
page
.getByTestId("rate-card")
.first()
.waitFor({ state: "visible", timeout: 45_000 })
.then(() => true),
page
.getByRole("button", { name: /Save\s*&\s*update quote/i })
.first()
.waitFor({ state: "visible", timeout: 45_000 })
.then(() => true),
]).catch(() => false);
if (!navigated && /\/ship\/?$/i.test(page.url())) {
await throwIfPortalQuoteBlocked(page);
const body = await page.locator("body").innerText().catch(() => "");
if (/Please enter the address of your delivery location/i.test(body)) {
throw new RpaError(
"ADDRESS_SUGGESTION_NOT_FOUND",
"Continue 后仍停留在创建页且派送地址未确认,请重新选择派送地址",
{ retryable: true },
);
}
if (/Please enter the address of your pick-?up location/i.test(body)) {
throw new RpaError(
"ADDRESS_SUGGESTION_NOT_FOUND",
"Continue 后仍停留在创建页且提货地址未确认,请重新选择提货地址",
{ retryable: true },
);
}
}
step("wait initial rates after Continue");
let gotRatesEarly = await waitForRatesAfterContinue(page);
if (!gotRatesEarly) {
await throwIfPortalAccessorialBlocked(page);
// 一级询价:只 Skip 推荐附加服务 + Save&update,禁止填公司名/联系人(那是二级 refine)
console.log(
"[rpa] logged-in: grace 无价卡 → 关 Inbox / Skip → Save&update(一级,不填 Details)",
);
await closeInboxDrawer(page);
await dismissFormOverlays(page);
await clickSkipAccessorialRecommendations(page);
let saved = await clickSaveAndUpdateQuote(page);
await page.waitForTimeout(PAUSE_MD);
gotRatesEarly = await hasRateCardsOrOptions(page);
if (!gotRatesEarly && !saved) {
await clickSkipAccessorialRecommendations(page);
saved = await clickSaveAndUpdateQuote(page);
await page.waitForTimeout(PAUSE_MD);
gotRatesEarly = await hasRateCardsOrOptions(page);
}
if (!gotRatesEarly) {
const earlyMsg = await readPortalQuoteBlockMessage(page);
if (earlyMsg && isPortalHardBusinessBlock(earlyMsg)) {
throw new RpaError("CARRIER_NO_CAPACITY", earlyMsg, {
retryable: false,
});
}
if (
(earlyMsg && isPortalNeedsDetailsMessage(earlyMsg)) ||
(await shouldPatchDetailsRequired(page))
) {
// 一级 Continue+Save 后官网仍要必填 → 立刻弹二级引导,禁止再进 RATE_WAIT 空转
throwNeedsDetailsForLevel2(earlyMsg ?? undefined);
}
}
if (!gotRatesEarly && (await isQuotesLoading(page))) {
gotRatesEarly = await waitForRatesAfterContinue(page);
}
if (!gotRatesEarly && cargoNeedsPalletFallback(req)) {
// 仅改货型再 Save,仍不填联系人
await recoverNoRatesBySwitchingToPallet(page, req);
gotRatesEarly = await waitForRatesAfterContinue(page);
if (gotRatesEarly) {
console.log(
"[rpa] logged-in: pallet fallback recovered rates after Save&update",
);
}
}
// 此处不 throw 无价:交给 ensureRateCardsReady 继续 Skip/Save
} else {
console.log("[rpa] logged-in: rates from level-1 (no Details fill)");
}
step("wait rate-card / scrape");
const raw = await scrapeRateCards(page, req);
return normalizeQuoteItems(raw);
}
async function dumpLoggedInFailure(page: Page, err: unknown): Promise<void> {
const brief = err instanceof Error ? err.message : String(err);
console.error(
`[rpa] logged-in FAILED at url=${page.url()} err=${brief.slice(0, 300)}`,
);
try {
const diagDir = path.join(process.cwd(), ".rpa", "diag");
fs.mkdirSync(diagDir, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const shot = path.join(diagDir, `logged-in-fail-${stamp}.png`);
await page.screenshot({ path: shot, fullPage: true });
const body = (await page.locator("body").innerText().catch(() => "")).slice(
0,
5000,
);
const rateN = await page.getByTestId("rate-card").count().catch(() => -1);
const continueN = await page
.getByTestId("ship-create-continue-button")
.count()
.catch(() => -1);
const continueDisabled = await page
.getByTestId("ship-create-continue-button")
.first()
.isDisabled()
.catch(() => null);
const inputs = await page
.locator(
"input[name*='company' i], input[name*='contact' i], input[placeholder*='company' i], input[data-testid*='company' i], input[data-testid*='contact' i], input[data-testid*='first' i], input[data-testid*='last' i], input[data-testid*='email' i]",
)
.evaluateAll((els) =>
els.slice(0, 40).map((el) => {
const input = el as HTMLInputElement;
return {
testid: input.getAttribute("data-testid"),
name: input.getAttribute("name"),
placeholder: input.getAttribute("placeholder"),
aria: input.getAttribute("aria-label"),
disabled: input.disabled,
value: String(input.value || "").slice(0, 60),
};
}),
)
.catch(() => [] as Array<Record<string, unknown>>);
// 兜底:前 40 个可见 input
const allInputs =
inputs.length > 0
? inputs
: await page
.locator(
"[data-testid*='company' i], [data-testid*='contact' i], [name*='company' i], [name*='contact' i], input:not([type='hidden'])",
)
.evaluateAll((els) =>
els.slice(0, 50).map((el) => {
const input = el as HTMLInputElement;
return {
testid: input.getAttribute("data-testid"),
name: input.getAttribute("name"),
placeholder: input.getAttribute("placeholder"),
aria: input.getAttribute("aria-label"),
disabled: input.disabled,
value: String(input.value || "").slice(0, 60),
};
}),
)
.catch(() => []);
console.error(
"[rpa] logged-in diag inputs=" +
JSON.stringify(allInputs).slice(0, 2000),
);
const meta = {
url: page.url(),
err: brief.slice(0, 800),
rateCardCount: rateN,
continueCount: continueN,
continueDisabled,
inputs: allInputs,
bodyPreview: body,
};
const jsonPath = path.join(diagDir, `logged-in-fail-${stamp}.json`);
fs.writeFileSync(jsonPath, JSON.stringify(meta, null, 2), "utf8");
console.error(`[rpa] logged-in diag shot=${shot}`);
console.error(`[rpa] logged-in diag json=${jsonPath}`);
console.error(`[rpa] logged-in bodyPreview=\n${body.slice(0, 1500)}`);
} catch (diagErr) {
console.error(
"[rpa] logged-in diag dump failed: " +
(diagErr instanceof Error ? diagErr.message : String(diagErr)),
);
}
}
/** 有账密时:dashboard Ship → Continue → 刮 rate-card;有 session 则驻留不关浏览器 */
export async function runMothershipLoggedInDashboardQuote(
req: QuoteRequest,
): Promise<QuoteItem[]> {
inboxGotItClicks = 0;
if (!hasEffectiveMothershipLogin()) {
throw new RpaError(
"STRUCT_CHANGE",
"runMothershipLoggedInDashboardQuote 需要 MotherShip 账密",
{ retryable: false },
);
}
return withRpaSessionLock(async () => {
const storagePath = resolveStorageStatePath();
const headed = process.env.RPA_HEADED === "true";
const headless = headed ? false : process.env.RPA_HEADLESS !== "false";
// 共用 worker Browser,便于 park 后不泄漏进程
const browser = await getSharedRpaBrowser();
const context = await createContext(browser, {
storageStatePath: storagePath,
});
let page = await context.newPage();
let parked = false;
const t0 = Date.now();
let lastAt = t0;
let lastName = "launch";
const step = (name: string) => {
const now = Date.now();
console.log(
"[rpa] logged-in timing: step=\"" +
lastName +
"\" ms=" +
String(now - lastAt) +
" totalMs=" +
String(now - t0),
);
console.log("[rpa] logged-in step: " + name + " url=" + page.url());
lastName = name;
lastAt = now;
};
const finishTiming = (label: string) => {
const now = Date.now();
console.log(
"[rpa] logged-in timing: step=\"" +
lastName +
"\" ms=" +
String(now - lastAt) +
" totalMs=" +
String(now - t0),
);
console.log(
"[rpa] logged-in timing: DONE label=" +
label +
" totalMs=" +
String(now - t0),
);
};
try {
sweepExpiredParkedSessions();
console.log(
`[rpa] logged-in dashboard quote start headed=${!headless} storage=${storagePath} session=${req.quoteSessionId?.slice(0, 8) ?? "-"}`,
);
const items = await driveLoggedInQuoteOnPage(page, req, step);
await context.storageState({ path: storagePath }).catch(() => undefined);
const sessionId = req.quoteSessionId?.trim();
if (sessionId && canPersistParkedQuoteSession()) {
await parkQuoteSession(sessionId, page, context, {
ttlMs: MS_REFINE_TOTAL_MS,
});
parked = true;
console.log(
"[rpa] logged-in: parked after first rates session=" +
sessionId.slice(0, 8),
);
}
finishTiming(parked ? "ok-parked" : "ok");
console.log(
`[rpa] logged-in dashboard quote ok parked=${parked} carriers=${items
.map((i) => i.carrier)
.join(",")}`,
);
return items;
} catch (err) {
// 一级需二级:驻留 Details 页,供用户确认后 refine,禁止直接关页
if (
err instanceof RpaError &&
err.code === "NEEDS_DETAILS" &&
!parked
) {
const sessionId = req.quoteSessionId?.trim();
if (sessionId && canPersistParkedQuoteSession()) {
try {
await context.storageState({ path: storagePath }).catch(() => undefined);
await parkQuoteSession(sessionId, page, context, {
ttlMs: MS_REFINE_TOTAL_MS,
});
parked = true;
console.log(
"[rpa] logged-in: parked for NEEDS_DETAILS session=" +
sessionId.slice(0, 8),
);
} catch (parkErr) {
console.warn(
"[rpa] logged-in: NEEDS_DETAILS park failed: " +
(parkErr instanceof Error
? parkErr.message
: String(parkErr)
).slice(0, 160),
);
}
}
}
await dumpLoggedInFailure(page, err);
throw err;
} finally {
if (!parked) {
if (!headless) {
await page.waitForTimeout(2_000).catch(() => undefined);
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
}
}
});
}
/**
* 驻留页刷价:强制填二级 Details → Save & update quote → 再刮价 → 释放驻留
*/
export async function runMothershipLoggedInRefineOnParked(
sessionId: string,
req: QuoteRequest,
): Promise<QuoteItem[]> {
inboxGotItClicks = 0;
if (!hasEffectiveMothershipLogin()) {
throw new RpaError(
"STRUCT_CHANGE",
"runMothershipLoggedInRefineOnParked 需要 MotherShip 账密",
{ retryable: false },
);
}
const hold = await readMsRefineHold(sessionId);
if (hold && isMsRefineHoldExpired(hold)) {
await releaseParkedQuoteSession(sessionId);
throw new RpaError(
"PAGE_LOAD_TIMEOUT",
"报价会话已失效,请重新询价",
{ retryable: false },
);
}
return withRpaSessionLock(async () => {
const parked = await takeParkedQuoteSession(sessionId, { waitMs: 2_000 });
if (!parked) {
throw new RpaError(
"SESSION_EXPIRED",
"刷价驻留页已失效,请重新询价",
{ retryable: false },
);
}
const { page, context } = parked;
const storagePath = resolveStorageStatePath();
try {
console.log(
"[rpa] logged-in refine: patch details + Save&update session=" +
sessionId.slice(0, 8),
);
await dismissBlockingOverlays(page);
await patchDetailsRequiredFields(page, req, { force: true });
// 对齐官网:就绪时刻不得早于提货开门;刷价时重写 ready time
if (req.readyDate || req.readyTime) {
await applyReadyDateTime(page, req.readyDate, req.readyTime);
}
await clickSaveAndUpdateQuote(page);
await page.waitForTimeout(PAUSE_MD);
await patchDetailsRequiredFields(page, req, { force: true });
if (req.readyDate || req.readyTime) {
await applyReadyDateTime(page, req.readyDate, req.readyTime);
}
const missing = await verifyMsCheckoutDetailsFilled(page);
if (missing.length > 0) {
console.warn(
"[rpa] logged-in refine: still missing " + missing.join(","),
);
}
await clickSaveAndUpdateQuote(page);
await ensureRateCardsReady(page, req);
const raw = await scrapeRateCards(page, req);
const items = normalizeQuoteItems(raw);
await context.storageState({ path: storagePath }).catch(() => undefined);
console.log(
"[rpa] logged-in refine ok carriers=" +
items.map((i) => i.carrier).join(","),
);
return items;
} catch (err) {
await dumpLoggedInFailure(page, err);
throw err;
} finally {
// takeParked 已出池,直接关页
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
}
});
}
/**
* 登录态探针:刮价后选卡 → 强制填 Details → Proceed to checkout → Agree(若有)。
* 硬停:绝不点击支付类按钮;若落地支付页则 stage=checkout_stop_before_payment。
*/
export async function runMothershipLoggedInCheckoutToAgree(
req: QuoteRequest,
opts?: { fillOnly?: boolean },
): Promise<MothershipCheckoutProbeResult> {
inboxGotItClicks = 0;
if (!hasEffectiveMothershipLogin()) {
throw new RpaError(
"STRUCT_CHANGE",
"runMothershipLoggedInCheckoutToAgree 需要 MotherShip 账密",
{ retryable: false },
);
}
const fillOnly =
Boolean(opts?.fillOnly) || process.env.PROBE_MS_FILL_ONLY === "1";
return withRpaSessionLock(async () => {
const storagePath = resolveStorageStatePath();
const headed = process.env.RPA_HEADED === "true";
const headless = headed ? false : process.env.RPA_HEADLESS !== "false";
const slowMoRaw = process.env.RPA_SLOW_MO_MS?.trim();
const slowMo =
!headless && slowMoRaw && Number.isFinite(Number(slowMoRaw))
? Number(slowMoRaw)
: !headless
? 60
: undefined;
const browser = await launchRpaBrowser({
headless,
...(slowMo !== undefined ? { slowMo } : {}),
});
const context = await createContext(browser, {
storageStatePath: storagePath,
});
const page = await context.newPage();
const t0 = Date.now();
let lastAt = t0;
let lastName = "launch";
const step = (name: string) => {
const now = Date.now();
console.log(
"[rpa] logged-in timing: step=\"" +
lastName +
"\" ms=" +
String(now - lastAt) +
" totalMs=" +
String(now - t0),
);
console.log("[rpa] logged-in step: " + name + " url=" + page.url());
lastName = name;
lastAt = now;
};
const finishTiming = (label: string) => {
const now = Date.now();
console.log(
"[rpa] logged-in timing: step=\"" +
lastName +
"\" ms=" +
String(now - lastAt) +
" totalMs=" +
String(now - t0),
);
console.log(
"[rpa] logged-in timing: DONE label=" +
label +
" totalMs=" +
String(now - t0),
);
};
try {
console.log(
`[rpa] logged-in checkout-to-agree start headed=${!headless} slowMo=${slowMo ?? 0} storage=${storagePath}`,
);
await driveLoggedInQuoteOnPage(page, req, step);
const result = await driveCheckoutToAgree(page, req, step, {
fillOnly,
});
await context.storageState({ path: storagePath }).catch(() => undefined);
finishTiming("checkout-" + result.stage);
console.log(
`[rpa] logged-in checkout ok stage=${result.stage} carrier=${result.selectedCarrier} total=${result.selectedTotal}`,
);
return result;
} catch (err) {
await dumpLoggedInFailure(page, err);
throw err;
} finally {
// fill-only / headed: 多停一会方便目视确认
const holdMs = !headless && fillOnly ? 8_000 : 3_000;
if (!headless) {
await page.waitForTimeout(holdMs).catch(() => undefined);
}
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
});
}