You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

535 lines
16 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import type { Locator, Page } from "playwright";
import {
clickFirstVisibleFromSpecs,
getSelectorSpecs,
} from "@/lib/rpa/selector-specs";
import { pageLocator } from "@/lib/rpa/locator";
import { awaitHumanPacing } from "@/lib/rpa/human-pacing";
import { RpaError } from "@/modules/rpa/errors";
import { fillLocatorWithVerify } from "@/workers/rpa/cargo-field-fill";
import {
buildWeightDisplayForUnit,
inferWeightUnitFromFieldHint,
type MotherShipWeightDisplay,
} from "@/workers/rpa/cargo-weight";
import { dismissCookieConsent } from "@/workers/rpa/page-prep";
const CARGO_EDITOR_OPEN_WAIT_MS = 12_000;
const POLL_MS = 200;
const DRAWER_LOCATORS = [
'[role="dialog"]',
'[aria-modal="true"]',
'[data-state="open"][role="dialog"]',
] as const;
const CARGO_FIELD_PLACEHOLDER_RE =
/pallet|weight|length|width|height|托盘|重量|长度|宽度|高度/i;
/** MotherShip popover 内常见控件(无 role=dialog */
function popoverEditorSignals(page: Page): Locator[] {
return [
page.getByRole("button", { name: /ADD ITEM/i }),
page.getByPlaceholder(/weight/i),
page.getByPlaceholder(/how many pallets/i),
page.locator('input[placeholder*="Weight" i]'),
page.locator('input[placeholder*="Pallet" i]'),
page.getByText(/^ADD ITEM$/i),
];
}
function cargoFieldLocators(widget: Locator): Locator[] {
return [
widget.getByPlaceholder(/how many pallets/i).first(),
widget.getByPlaceholder(/需要几个托盘/i).first(),
widget.getByPlaceholder(/weight/i).first(),
widget.getByPlaceholder(/重量/i).first(),
widget.getByPlaceholder(/length|width|height|长度|宽度|高度/i).first(),
widget.locator('input[placeholder*="Pallet" i]').first(),
widget.locator('input[placeholder*="Weight" i]').first(),
widget.locator('input[placeholder*="托盘" i]').first(),
widget.locator('input[placeholder*="重量" i]').first(),
];
}
/** widget 内是否已有可填货物字段(含中文向导内联步) */
export async function isCargoFieldsVisible(
page: Page,
widgetSelector: string,
): Promise<boolean> {
const widget = pageLocator(page, widgetSelector);
for (const loc of cargoFieldLocators(widget)) {
if (await loc.isVisible().catch(() => false)) {
const editable = await loc.isEditable().catch(() => false);
if (editable) {
return true;
}
}
}
return false;
}
/** popover/dialog 或 widget 内联货物字段可见 */
export async function isCargoEditable(
page: Page,
widgetSelector: string,
): Promise<boolean> {
if (await isCargoEditorOpen(page)) {
return true;
}
return isCargoFieldsVisible(page, widgetSelector);
}
/** drawer / popover 是否已打开popover 以 editor 控件可见为准,非 dialog role */
export async function isCargoEditorOpen(page: Page): Promise<boolean> {
for (const selector of DRAWER_LOCATORS) {
const loc = page.locator(selector).first();
if (await loc.isVisible().catch(() => false)) {
return true;
}
}
for (const loc of popoverEditorSignals(page)) {
if (await loc.first().isVisible().catch(() => false)) {
return true;
}
}
return false;
}
/** 等待 cargo 可填popover 或中文内联步) */
export async function waitForCargoEditorOpen(
page: Page,
widgetSelector: string,
): Promise<void> {
if (!(await isCargoUiPresent(page, widgetSelector))) {
throw new RpaError("STRUCT_CHANGE", "cargo editor UI 不存在,无法等待打开", {
retryable: false,
});
}
const deadline = Date.now() + CARGO_EDITOR_OPEN_WAIT_MS;
while (Date.now() < deadline) {
if (await isCargoFieldsVisible(page, widgetSelector)) {
return;
}
await page.waitForTimeout(POLL_MS);
}
if (await isCargoEditable(page, widgetSelector)) {
return;
}
throw new RpaError("STRUCT_CHANGE", "cargo editor 未在时限内就绪", {
retryable: true,
});
}
/** 填表作用域dialog / popover / widget 内联 */
export async function resolveCargoEditorScope(
page: Page,
widgetSelector: string,
): Promise<Locator> {
for (const selector of DRAWER_LOCATORS) {
const loc = page.locator(selector).last();
if (await loc.isVisible().catch(() => false)) {
return loc;
}
}
if (await isCargoFieldsVisible(page, widgetSelector)) {
return pageLocator(page, widgetSelector);
}
if (await isCargoEditorOpen(page)) {
return page.locator("body");
}
throw new RpaError("STRUCT_CHANGE", "cargo editor 未打开,禁止填表", {
retryable: true,
});
}
async function clickTrigger(loc: Locator): Promise<boolean> {
if (!(await loc.isVisible().catch(() => false))) {
return false;
}
await loc.scrollIntoViewIfNeeded().catch(() => undefined);
await loc.click({ timeout: 8_000, force: true });
await loc.page().waitForTimeout(500);
return true;
}
/** widget 上是否存在可编辑的货物 UI中/英)或可分步展开的货物步骤 */
export async function isCargoUiPresent(
page: Page,
widgetSelector: string,
): Promise<boolean> {
if (await isCargoFieldsVisible(page, widgetSelector)) {
return true;
}
const widget = pageLocator(page, widgetSelector);
const signals: Locator[] = [
widget.getByText(/what are you shipping/i).first(),
widget.getByText(/\d+\s*pallet/i).first(),
widget.getByText(/\d+\s*托盘/).first(),
widget.getByText(/\d+W,\s*x\s*\d+L/i).first(),
widget.getByText(/货运详情/).first(),
widget.getByText(/Freight details/i).first(),
widget.getByText(/Shipment details/i).first(),
widget.getByPlaceholder(/cargo/i).first(),
widget.getByRole("button", { name: /pallet/i }).first(),
widget
.getByRole("button", {
name: /获取货运报价|GET QUOTE|Get freight quote|Get a freight quote/i,
})
.first(),
];
for (const loc of signals) {
if (await loc.isVisible().catch(() => false)) {
return true;
}
}
return isCargoWizardPastAddress(page, widgetSelector);
}
/** 双地址已锁定且不再处于地址搜索态 */
export async function isCargoWizardPastAddress(
page: Page,
widgetSelector: string,
): Promise<boolean> {
return page.evaluate((sel) => {
const widget = document.querySelector(sel);
if (!widget) {
return false;
}
const text = (
(widget as HTMLElement).innerText ??
widget.textContent ??
""
).replace(/\s+/g, " ");
const stateMatches = text.match(/,\s*[A-Z]{2}/g) ?? [];
if (stateMatches.length < 2) {
return false;
}
const searchInputs = widget.querySelectorAll<HTMLInputElement>("input");
for (const inp of searchInputs) {
const ph = inp.placeholder ?? "";
if (!/search|搜索/i.test(ph)) {
continue;
}
const visible = inp.offsetParent !== null;
const editable =
!inp.disabled &&
inp.readOnly === false &&
inp.getAttribute("aria-readonly") !== "true";
if (visible && editable) {
return false;
}
}
return true;
}, widgetSelector);
}
/** 地址填完后等待货物步出现MotherShip 渲染有延迟) */
export async function waitForCargoUiPresent(
page: Page,
widgetSelector: string,
timeoutMs = 20_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await isCargoUiPresent(page, widgetSelector)) {
return;
}
await dismissCookieConsent(page).catch(() => undefined);
await page.waitForTimeout(POLL_MS);
}
throw new RpaError(
"QUOTE_ENTRY_UNAVAILABLE",
"货物编辑 UI 在时限内未出现",
{ retryable: true },
);
}
async function waitCargoEditableAfterClick(
page: Page,
widgetSelector: string,
): Promise<boolean> {
const deadline = Date.now() + 4_000;
while (Date.now() < deadline) {
if (await isCargoEditable(page, widgetSelector)) {
return true;
}
await page.waitForTimeout(POLL_MS);
}
return false;
}
async function clickFreightStepInWidget(widget: Locator): Promise<boolean> {
const labels = ["货运详情", "Freight details", "Shipment details"];
for (const label of labels) {
const exact = widget.getByText(label, { exact: true });
const count = await exact.count().catch(() => 0);
for (let i = count - 1; i >= 0; i -= 1) {
const target = exact.nth(i);
if (await clickTrigger(target)) {
return true;
}
const buttonAncestor = target.locator("xpath=ancestor::button[1]");
if (await clickTrigger(buttonAncestor)) {
return true;
}
const roleButtonAncestor = target.locator(
"xpath=ancestor::*[@role='button'][1]",
);
if (await clickTrigger(roleButtonAncestor)) {
return true;
}
const parent = target.locator("xpath=..");
if (await clickTrigger(parent)) {
return true;
}
const grandParent = target.locator("xpath=../..");
if (await clickTrigger(grandParent)) {
return true;
}
}
}
return false;
}
/** 重新展开中文内联货物步(不抛错) */
export async function reopenInlineCargoStep(
page: Page,
widgetSelector: string,
): Promise<void> {
if (await isCargoFieldsVisible(page, widgetSelector)) {
return;
}
const widget = pageLocator(page, widgetSelector);
await clickFreightStepInWidget(widget);
await waitCargoEditableAfterClick(page, widgetSelector);
}
/**
* 点击 summary chip / 步骤标签,展开 cargo editorpopover 或中文内联步)。
*/
export async function expandCargoEditor(
page: Page,
widgetSelector: string,
): Promise<void> {
if (!(await isCargoUiPresent(page, widgetSelector))) {
throw new RpaError(
"STRUCT_CHANGE",
"无法展开 cargo editorwidget 无货物编辑 UI",
{ retryable: false },
);
}
if (await isCargoEditable(page, widgetSelector)) {
return;
}
await dismissCookieConsent(page);
const widget = pageLocator(page, widgetSelector);
if (await clickFreightStepInWidget(widget)) {
if (await waitCargoEditableAfterClick(page, widgetSelector)) {
console.log("[rpa] cargo-editor: 内联货物步已展开(货运详情)");
return;
}
}
const triggers: Locator[] = [
widget.getByRole("button", { name: /\d+\s*Pallet/i }).first(),
widget.locator("button, [role='button']").filter({ hasText: /Pallet/i }).first(),
widget.getByText(/^\d+\s*Pallet$/i).first(),
widget.getByText(/\d+\s*x\s*\d+\s*x\s*\d+/i).first(),
widget.getByText(/what are you shipping/i).first(),
widget.getByText(/你在运送什么/i).first(),
widget.locator("text=/\\d+\\s*Pallet/i").first(),
widget.getByText(/\d+\s*Pallet.*\d+\s*lbs?/i).first(),
widget.locator('[class*="summary"], [data-testid*="cargo"], [data-testid*="shipment"]').first(),
];
for (const loc of triggers) {
if (await clickTrigger(loc)) {
if (await waitCargoEditableAfterClick(page, widgetSelector)) {
console.log("[rpa] cargo-editor: popover opened via summary chip");
return;
}
}
}
const clicked = await clickFirstVisibleFromSpecs(
page,
getSelectorSpecs("RPA_SELECTOR_CARGO_SECTION"),
);
if (clicked) {
await page.waitForTimeout(500);
if (await waitCargoEditableAfterClick(page, widgetSelector)) {
console.log("[rpa] cargo-editor: 内联步已展开CARGO_SECTION spec");
return;
}
}
throw new RpaError(
"STRUCT_CHANGE",
"无法展开 cargo editor步骤标签/chip 未响应)",
{ retryable: true },
);
}
function resolveSpecOnScope(scope: Locator, spec: string): Locator {
const trimmed = spec.trim();
if (trimmed.startsWith("placeholder=")) {
const ph = trimmed.slice(12).replace(/^"|"$/g, "");
return scope.getByPlaceholder(ph);
}
const roleMatch = trimmed.match(/^role=(\w+)(?:\[name="([^"]*)"\])?$/);
if (roleMatch) {
const [, role, name] = roleMatch;
return name
? scope.getByRole(role as Parameters<Locator["getByRole"]>[0], { name })
: scope.getByRole(role as Parameters<Locator["getByRole"]>[0]);
}
if (trimmed.startsWith("text=")) {
const text = trimmed.slice(5);
if (text.startsWith("/") && text.endsWith("/")) {
return scope.getByText(new RegExp(text.slice(1, -1)));
}
return scope.getByText(text);
}
return scope.locator(trimmed);
}
const CARGO_FIELD_WAIT_MS = 20_000;
async function waitForFieldLocator(loc: Locator): Promise<boolean> {
const deadline = Date.now() + CARGO_FIELD_WAIT_MS;
while (Date.now() < deadline) {
const visible = await loc.isVisible().catch(() => false);
const editable = visible && (await loc.isEditable().catch(() => false));
if (editable) {
return true;
}
await loc.page().waitForTimeout(POLL_MS);
}
return false;
}
/** 在已打开的 editor / 内联步内按 spec 填值 */
export async function fillInCargoEditor(
page: Page,
envKey: string,
value: string,
widgetSelector: string,
options?: { commit?: boolean; forceClear?: boolean },
): Promise<boolean> {
if (!(await isCargoFieldsVisible(page, widgetSelector))) {
await reopenInlineCargoStep(page, widgetSelector);
}
const specs = getSelectorSpecs(envKey);
const scopes: Locator[] = [];
if (await isCargoFieldsVisible(page, widgetSelector)) {
scopes.push(pageLocator(page, widgetSelector));
} else {
if (await isCargoUiPresent(page, widgetSelector)) {
await reopenInlineCargoStep(page, widgetSelector);
}
if (await isCargoFieldsVisible(page, widgetSelector)) {
scopes.push(pageLocator(page, widgetSelector));
} else {
const scope = await resolveCargoEditorScope(page, widgetSelector);
scopes.push(scope);
}
}
for (const fillScope of scopes) {
for (const spec of specs) {
const loc = resolveSpecOnScope(fillScope, spec).first();
if (!(await waitForFieldLocator(loc))) {
continue;
}
try {
await fillLocatorWithVerify(page, loc, value, {
forceClear: options?.forceClear,
commit: options?.commit ?? false,
});
await awaitHumanPacing(page);
return true;
} catch {
/* 尝试下一 spec */
}
}
}
return false;
}
/** 按实际可见 weight 输入框 placeholder 决定填入值并填写 */
export async function fillWeightInCargoEditor(
page: Page,
widgetSelector: string,
weightLbPerPallet: number,
options?: { commit?: boolean; forceClear?: boolean },
): Promise<MotherShipWeightDisplay> {
if (!(await isCargoFieldsVisible(page, widgetSelector))) {
await reopenInlineCargoStep(page, widgetSelector);
}
const specs = getSelectorSpecs("RPA_SELECTOR_WEIGHT");
const scopes: Locator[] = [];
if (await isCargoFieldsVisible(page, widgetSelector)) {
scopes.push(pageLocator(page, widgetSelector));
} else {
if (await isCargoUiPresent(page, widgetSelector)) {
await reopenInlineCargoStep(page, widgetSelector);
}
if (await isCargoFieldsVisible(page, widgetSelector)) {
scopes.push(pageLocator(page, widgetSelector));
} else {
scopes.push(await resolveCargoEditorScope(page, widgetSelector));
}
}
scopes.push(page.locator("body"));
let lastError: unknown;
for (const fillScope of scopes) {
for (const spec of specs) {
const loc = resolveSpecOnScope(fillScope, spec).first();
if (!(await waitForFieldLocator(loc))) {
continue;
}
const hint = `${(await loc.getAttribute("placeholder")) ?? ""} ${(await loc.getAttribute("aria-label")) ?? ""}`;
const display = buildWeightDisplayForUnit(
weightLbPerPallet,
inferWeightUnitFromFieldHint(hint),
);
try {
await fillLocatorWithVerify(page, loc, display.displayValue, {
forceClear: options?.forceClear,
commit: options?.commit ?? true,
});
await awaitHumanPacing(page);
return display;
} catch (error) {
lastError = error;
}
}
}
if (lastError instanceof RpaError) {
throw lastError;
}
throw new RpaError(
"STRUCT_CHANGE",
"货物重量字段填写失败drawer 内RPA_SELECTOR_WEIGHT",
{ retryable: true },
);
}
/** @internal 单测 */
export function cargoPlaceholderMatches(placeholder: string): boolean {
return CARGO_FIELD_PLACEHOLDER_RE.test(placeholder);
}