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