|
|
/**
|
|
|
* 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 {
|
|
|
isMothershipWeightEachAllowed,
|
|
|
MOTHERSHIP_MAX_WEIGHT_EACH_LB,
|
|
|
normalizeMothershipReadyDateIso,
|
|
|
snapMothershipReadyDateToWeekday,
|
|
|
} from "@/lib/mothership/logged-in-constraints";
|
|
|
import {
|
|
|
extractMothershipAccessorialBlockMessages,
|
|
|
formatMothershipPortalQuoteMessage,
|
|
|
parseMothershipPortalQuoteMessageFromBody,
|
|
|
} from "@/lib/mothership/portal-quote-messages";
|
|
|
import { normalizeQuoteItems } from "@/workers/rpa/quote-capture/quote-schema-validator";
|
|
|
import {
|
|
|
createContext,
|
|
|
withRpaSessionLock,
|
|
|
} from "@/workers/rpa/session-manager";
|
|
|
|
|
|
/** 登录后一级查价稳定入口(录制 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 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);
|
|
|
if (await isCreateShipmentFormVisible(page)) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 为何:直达 /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);
|
|
|
await input.click({ timeout: 5_000 });
|
|
|
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"],
|
|
|
};
|
|
|
|
|
|
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[]);
|
|
|
}
|
|
|
|
|
|
async function openAccessorialDropdown(
|
|
|
page: Page,
|
|
|
side: "pickup" | "delivery",
|
|
|
): Promise<void> {
|
|
|
if (side === "pickup") {
|
|
|
const trigger = page.getByTestId("address-book-accessorials-trigger").first();
|
|
|
await trigger.waitFor({ state: "visible", timeout: FIELD_WAIT_MS });
|
|
|
await trigger.click();
|
|
|
} else {
|
|
|
const deliveryOpen = page.getByRole("button", {
|
|
|
name: /Select\s+Chevron\s*down/i,
|
|
|
});
|
|
|
if ((await deliveryOpen.count()) > 0) {
|
|
|
await deliveryOpen.last().click();
|
|
|
} else {
|
|
|
const triggers = page.getByTestId("address-book-accessorials-trigger");
|
|
|
const n = await triggers.count();
|
|
|
await triggers.nth(Math.max(0, n - 1)).click();
|
|
|
}
|
|
|
}
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
}
|
|
|
|
|
|
async function clickAccessorialOption(
|
|
|
page: Page,
|
|
|
side: "pickup" | "delivery",
|
|
|
id: string,
|
|
|
): Promise<void> {
|
|
|
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 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) {
|
|
|
console.log(`[rpa] logged-in: 跳过附加服务 side=${side}(未勾选)`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
await openAccessorialDropdown(page, side);
|
|
|
|
|
|
// 等首个 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}`);
|
|
|
}
|
|
|
|
|
|
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(",")}`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** YYYY-MM-DD 鈫?鏃ュ巻鏍煎瓙鍚嶃€孴hu 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()}`;
|
|
|
}
|
|
|
|
|
|
/** 鎵撳紑鏃ュ巻鍚庣偣鐩爣鏃ワ細瀹樼綉鏃ユ牸甯镐綆瀵规瘮搴︼紝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 monthLong = d.toLocaleDateString("en-US", { month: "long" });
|
|
|
const year = d.getFullYear();
|
|
|
|
|
|
// 鍒楀嚭褰撳墠鏃ュ巻鏍硷紝渚夸簬鎺掓煡鍚嶇О婕傜Щ
|
|
|
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: cellName }),
|
|
|
page.getByRole("gridcell", {
|
|
|
name: new RegExp(
|
|
|
`${cellName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
|
|
|
"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;
|
|
|
}
|
|
|
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;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
"登录态日历未点到 " +
|
|
|
isoDate +
|
|
|
" want=" +
|
|
|
cellName +
|
|
|
" sample=" +
|
|
|
named
|
|
|
.map((x) => x.name)
|
|
|
.filter(Boolean)
|
|
|
.slice(0, 8)
|
|
|
.join("|"),
|
|
|
{ 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).
|
|
|
*/
|
|
|
async function openReadyTimeDropdown(page: Page): Promise<void> {
|
|
|
const afterLabel = page.getByText("after", { exact: true });
|
|
|
if ((await afterLabel.count()) > 0) {
|
|
|
await afterLabel.first().click().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();
|
|
|
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();
|
|
|
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}`,
|
|
|
);
|
|
|
}
|
|
|
const dateBtn = 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,
|
|
|
});
|
|
|
await dateBtn.first().waitFor({ state: "visible", timeout: FIELD_WAIT_MS });
|
|
|
await dateBtn.first().click();
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
await clickReadyDateInCalendar(page, iso);
|
|
|
} 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();
|
|
|
} 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();
|
|
|
}
|
|
|
await page.waitForTimeout(PAUSE_XS);
|
|
|
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",
|
|
|
};
|
|
|
|
|
|
async function selectCargoTypeAt(
|
|
|
page: Page,
|
|
|
index: number,
|
|
|
cargoTypeId: string,
|
|
|
): Promise<void> {
|
|
|
const label = MS_CARGO_TYPE_EN[cargoTypeId] ?? "Pallet";
|
|
|
const typeInput = page.getByTestId("cargo-type-dropdown-input").nth(index);
|
|
|
await typeInput.waitFor({ state: "visible", timeout: FIELD_WAIT_MS });
|
|
|
await typeInput.click();
|
|
|
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 {
|
|
|
// 褰曞埗锛歭ocator('div').filter({ hasText: /^Pallet$/ }).nth(4)
|
|
|
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(",")}`,
|
|
|
);
|
|
|
|
|
|
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_MAX_WEIGHT_EACH_LB} lb(当前 ${line.weightLb})`,
|
|
|
{ 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))));
|
|
|
await page
|
|
|
.getByTestId("cargo-weight-input")
|
|
|
.nth(i)
|
|
|
.fill(String(Math.round(line.weightLb)));
|
|
|
await page
|
|
|
.getByTestId("cargo-length-input")
|
|
|
.nth(i)
|
|
|
.fill(String(Math.round(line.lengthIn)));
|
|
|
await page
|
|
|
.getByTestId("cargo-width-input")
|
|
|
.nth(i)
|
|
|
.fill(String(Math.round(line.widthIn)));
|
|
|
await page
|
|
|
.getByTestId("cargo-height-input")
|
|
|
.nth(i)
|
|
|
.fill(String(Math.round(line.heightIn)));
|
|
|
|
|
|
console.log(
|
|
|
`[rpa] logged-in: 宸插~璐х墿 row=${i} qty=${line.quantity} wt=${line.weightLb} dims=${line.lengthIn}x${line.widthIn}x${line.heightIn}`,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function fillLabeledTextNth(
|
|
|
page: Page,
|
|
|
label: RegExp,
|
|
|
index: number,
|
|
|
value: string | undefined,
|
|
|
): 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) return;
|
|
|
await target.click({ force: true }).catch(() => undefined);
|
|
|
await target.fill(text).catch(() => undefined);
|
|
|
}
|
|
|
|
|
|
/** MotherShip Details 下拉是 combobox/button,不是 native select */
|
|
|
async function selectLabeledOptionNth(
|
|
|
page: Page,
|
|
|
label: RegExp,
|
|
|
index: number,
|
|
|
optionText: string | undefined,
|
|
|
): 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 (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 patchDetailsRequiredFields(
|
|
|
page: Page,
|
|
|
req: QuoteRequest,
|
|
|
): Promise<boolean> {
|
|
|
if (!(await shouldPatchDetailsRequired(page))) {
|
|
|
return false;
|
|
|
}
|
|
|
const details = req.mothershipDetails;
|
|
|
const pieceQty = String(
|
|
|
details?.cargo?.[0]?.piece_count_qty ??
|
|
|
req.cargoLines?.[0]?.quantity ??
|
|
|
req.palletCount ??
|
|
|
1,
|
|
|
);
|
|
|
|
|
|
await fillLabeledTextNth(
|
|
|
page,
|
|
|
/Full company name/i,
|
|
|
0,
|
|
|
details?.pickup?.company_name || "Demo Pickup Co",
|
|
|
);
|
|
|
await fillLabeledTextNth(
|
|
|
page,
|
|
|
/Full company name/i,
|
|
|
1,
|
|
|
details?.delivery?.company_name || "Demo Delivery Co",
|
|
|
);
|
|
|
// 为何分两个邮箱:同邮箱会弹 Duplicate email,可能挡出价
|
|
|
await fillLabeledTextNth(
|
|
|
page,
|
|
|
/On-site contact email/i,
|
|
|
0,
|
|
|
details?.pickup?.contact_email || "pickup-ops@example.com",
|
|
|
);
|
|
|
await fillLabeledTextNth(
|
|
|
page,
|
|
|
/On-site contact email/i,
|
|
|
1,
|
|
|
details?.delivery?.contact_email || "delivery-ops@example.com",
|
|
|
);
|
|
|
await fillLabeledTextNth(
|
|
|
page,
|
|
|
/On-site contact phone/i,
|
|
|
0,
|
|
|
details?.pickup?.contact_phone || "5555550101",
|
|
|
);
|
|
|
await fillLabeledTextNth(
|
|
|
page,
|
|
|
/On-site contact phone/i,
|
|
|
1,
|
|
|
details?.delivery?.contact_phone || "5555550102",
|
|
|
);
|
|
|
await selectLabeledOptionNth(
|
|
|
page,
|
|
|
/Opens at/i,
|
|
|
0,
|
|
|
details?.pickup?.opens_at || "8:00 AM",
|
|
|
);
|
|
|
await selectLabeledOptionNth(
|
|
|
page,
|
|
|
/Closes at/i,
|
|
|
0,
|
|
|
details?.pickup?.closes_at || "5:00 PM",
|
|
|
);
|
|
|
await selectLabeledOptionNth(
|
|
|
page,
|
|
|
/Opens at/i,
|
|
|
1,
|
|
|
details?.delivery?.opens_at || "8:00 AM",
|
|
|
);
|
|
|
await selectLabeledOptionNth(
|
|
|
page,
|
|
|
/Closes at/i,
|
|
|
1,
|
|
|
details?.delivery?.closes_at || "5:00 PM",
|
|
|
);
|
|
|
await selectLabeledOptionNth(
|
|
|
page,
|
|
|
/Piece count type/i,
|
|
|
0,
|
|
|
details?.cargo?.[0]?.piece_count_type || "Pieces",
|
|
|
);
|
|
|
await fillLabeledTextNth(page, /Piece count quantity/i, 0, pieceQty);
|
|
|
await fillLabeledTextNth(
|
|
|
page,
|
|
|
/Cargo description/i,
|
|
|
0,
|
|
|
details?.cargo?.[0]?.description || "General freight",
|
|
|
);
|
|
|
console.log("[rpa] logged-in: patched details required fields");
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/** 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);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** 濉〃闃舵杞婚噺娓呴殰锛氬嬁鐐瑰鑸紝閬垮厤鎵撲贡鍦板潃/璐х墿琛ㄥ崟 */
|
|
|
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 readPortalQuoteBlockMessage(
|
|
|
page: Page,
|
|
|
): Promise<string | null> {
|
|
|
const body = await page.locator("body").innerText().catch(() => "");
|
|
|
return parseMothershipPortalQuoteMessageFromBody(body);
|
|
|
}
|
|
|
|
|
|
async function readPortalAccessorialBlockMessage(
|
|
|
page: Page,
|
|
|
): Promise<string | null> {
|
|
|
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 });
|
|
|
}
|
|
|
|
|
|
async function throwIfPortalQuoteBlocked(
|
|
|
page: Page,
|
|
|
): Promise<void> {
|
|
|
if (await isQuotesLoading(page)) return;
|
|
|
const msg = await readPortalQuoteBlockMessage(page);
|
|
|
if (!msg) return;
|
|
|
console.log("[rpa] logged-in: portal quote blocked: " + msg.slice(0, 200));
|
|
|
throw new RpaError("CARRIER_NO_CAPACITY", msg, { retryable: false });
|
|
|
}
|
|
|
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 内只等一级自然出价,不碰二级 Details */
|
|
|
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;
|
|
|
}
|
|
|
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(() => "");
|
|
|
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();
|
|
|
if (!(await btn.isVisible().catch(() => false))) return false;
|
|
|
if (await btn.isDisabled().catch(() => false)) {
|
|
|
console.log("[rpa] logged-in: click Save & update quote");
|
|
|
return false;
|
|
|
}
|
|
|
console.log("[rpa] logged-in: 鐐瑰嚮 Save & update quote 鍥炲埛鎶ヤ环");
|
|
|
await btn.click();
|
|
|
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 requiredPatchRounds = 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)) {
|
|
|
idleRounds = 0;
|
|
|
console.log("[rpa] logged-in: rates loading...");
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
await throwIfPortalAccessorialBlocked(page);
|
|
|
|
|
|
if (
|
|
|
req &&
|
|
|
requiredPatchRounds < 2 &&
|
|
|
(await shouldPatchDetailsRequired(page))
|
|
|
) {
|
|
|
requiredPatchRounds += 1;
|
|
|
console.log(
|
|
|
"[rpa] logged-in: required fields still blocking, re-patch #" +
|
|
|
String(requiredPatchRounds),
|
|
|
);
|
|
|
await patchDetailsRequiredFields(page, req);
|
|
|
await clickSaveAndUpdateQuote(page);
|
|
|
idleRounds = 0;
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
await throwIfPortalQuoteBlocked(page);
|
|
|
|
|
|
if (await clickSaveAndUpdateQuote(page)) {
|
|
|
idleRounds = 0;
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
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) {
|
|
|
console.log(
|
|
|
"[rpa] logged-in: portal quote blocked at timeout: " +
|
|
|
portalMsg.slice(0, 200),
|
|
|
);
|
|
|
throw new RpaError("CARRIER_NO_CAPACITY", portalMsg, {
|
|
|
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 },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 鏈夎处瀵嗘椂锛歞ashboard Ship 鈫?Continue 鈫?鎶?rate-card */
|
|
|
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";
|
|
|
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 dashboard quote start headed=${!headless} slowMo=${slowMo ?? 0} storage=${storagePath}`,
|
|
|
);
|
|
|
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));
|
|
|
// 褰曞埗椤哄簭锛氬弻鍦板潃纭鍚?鈫?鎻愯揣闄勫姞 鈫?娲鹃€侀檮鍔?鈫?鏃ユ湡/鏃跺埢 鈫?cargo
|
|
|
console.log(
|
|
|
`[rpa] logged-in payload accessorials pickup=[${(req.pickupAccessorials ?? []).join(",")}] delivery=[${(req.deliveryAccessorials ?? []).join(",")}] ready=${req.readyDate ?? "-"} ${req.readyTime ?? "-"}`,
|
|
|
);
|
|
|
step(
|
|
|
`pickup accessorials: ${(req.pickupAccessorials ?? []).join(",") || "(none)"}`,
|
|
|
);
|
|
|
await applyAccessorials(page, "pickup", req.pickupAccessorials);
|
|
|
step(
|
|
|
`delivery accessorials: ${(req.deliveryAccessorials ?? []).join(",") || "(none)"}`,
|
|
|
);
|
|
|
await applyAccessorials(page, "delivery", req.deliveryAccessorials);
|
|
|
step(`ready datetime: ${req.readyDate ?? "-"} ${req.readyTime ?? "-"}`);
|
|
|
await applyReadyDateTime(page, req.readyDate, req.readyTime);
|
|
|
step("fillCargo");
|
|
|
await fillCargo(page, req);
|
|
|
step("click Continue");
|
|
|
await dismissBlockingOverlays(page);
|
|
|
|
|
|
// 最终校验:两个地址均已确认,否则立即报错(避免空等 rate-card 120s)
|
|
|
{
|
|
|
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");
|
|
|
const gotRatesEarly = await waitForRatesAfterContinue(page);
|
|
|
if (!gotRatesEarly) {
|
|
|
await throwIfPortalAccessorialBlocked(page);
|
|
|
if (await shouldPatchDetailsRequired(page)) {
|
|
|
console.log(
|
|
|
"[rpa] logged-in: grace ended still blocked, patch details fallback",
|
|
|
);
|
|
|
await patchDetailsRequiredFields(page, req);
|
|
|
await clickSaveAndUpdateQuote(page);
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
} else {
|
|
|
await throwIfPortalQuoteBlocked(page);
|
|
|
}
|
|
|
} else {
|
|
|
console.log("[rpa] logged-in: skip details patch (rates from level-1)");
|
|
|
}
|
|
|
step("wait rate-card / scrape");
|
|
|
const raw = await scrapeRateCards(page, req);
|
|
|
const items = normalizeQuoteItems(raw);
|
|
|
await context.storageState({ path: storagePath }).catch(() => undefined);
|
|
|
finishTiming("ok");
|
|
|
console.log(
|
|
|
`[rpa] logged-in dashboard quote ok carriers=${items
|
|
|
.map((i) => i.carrier)
|
|
|
.join(",")}`,
|
|
|
);
|
|
|
return items;
|
|
|
} catch (err) {
|
|
|
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 meta = {
|
|
|
url: page.url(),
|
|
|
err: brief.slice(0, 800),
|
|
|
rateCardCount: rateN,
|
|
|
continueCount: continueN,
|
|
|
continueDisabled,
|
|
|
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)),
|
|
|
);
|
|
|
}
|
|
|
throw err;
|
|
|
} finally {
|
|
|
// headed: brief pause so final screen is visible
|
|
|
if (!headless) {
|
|
|
await page.waitForTimeout(2_000).catch(() => undefined);
|
|
|
}
|
|
|
await context.close().catch(() => undefined);
|
|
|
await browser.close().catch(() => undefined);
|
|
|
}
|
|
|
});
|
|
|
}
|