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 { 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 { if (await isCargoEditorOpen(page)) { return true; } return isCargoFieldsVisible(page, widgetSelector); } /** drawer / popover 是否已打开(popover 以 editor 控件可见为准,非 dialog role) */ export async function isCargoEditorOpen(page: Page): Promise { 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 { 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 { 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 { 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 { 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 { 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("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 { 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 { 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 { 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 { if (await isCargoFieldsVisible(page, widgetSelector)) { return; } const widget = pageLocator(page, widgetSelector); await clickFreightStepInWidget(widget); await waitCargoEditableAfterClick(page, widgetSelector); } /** * 点击 summary chip / 步骤标签,展开 cargo editor(popover 或中文内联步)。 */ export async function expandCargoEditor( page: Page, widgetSelector: string, ): Promise { if (!(await isCargoUiPresent(page, widgetSelector))) { throw new RpaError( "STRUCT_CHANGE", "无法展开 cargo editor:widget 无货物编辑 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[0], { name }) : scope.getByRole(role as Parameters[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 { 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 { 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 { 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); }