import type { Locator, Page } from "playwright"; import { firstVisibleLocator } from "@/lib/rpa/locator"; /** env 未配时的中英双语内置回退(MotherShip 首页 widget) */ const BUILTIN_SELECTOR_FALLBACKS: Record = { RPA_SELECTOR_PICKUP_SECTION: [ "#react-quoter-landing-plugin >> text=Pick up from", "#react-quoter-landing-plugin >> text=Where to pick up", "#react-quoter-landing-plugin >> text=从何处取货", ], RPA_SELECTOR_DELIVERY_SECTION: [ "#react-quoter-landing-plugin >> text=Deliver to", "#react-quoter-landing-plugin >> text=Delivery to", "#react-quoter-landing-plugin >> text=送货至", ], RPA_SELECTOR_PICKUP_STREET: [ "#react-quoter-landing-plugin >> placeholder=Search by address", "#react-quoter-landing-plugin >> role=textbox[name=\"Search by address\"]", "#react-quoter-landing-plugin >> role=textbox[name=\"Search address\"]", "#react-quoter-landing-plugin >> role=textbox[name=\"搜索地址\"]", "role=textbox[name=\"Search by address\"]", "role=textbox[name=\"Search address\"]", "role=textbox[name=\"搜索地址\"]", ], RPA_SELECTOR_DELIVERY_STREET: [ "#react-quoter-landing-plugin >> placeholder=Search by address", "#react-quoter-landing-plugin >> role=textbox[name=\"Search by address\"]", "#react-quoter-landing-plugin >> role=textbox[name=\"Search address\"]", "#react-quoter-landing-plugin >> role=textbox[name=\"搜索地址\"]", "role=textbox[name=\"Search by address\"]", "role=textbox[name=\"Search address\"]", "role=textbox[name=\"搜索地址\"]", ], RPA_SELECTOR_CARGO_SECTION: [ "#react-quoter-landing-plugin >> text=What are you shipping", "#react-quoter-landing-plugin >> text=What are you shipping?", "#react-quoter-landing-plugin >> filter:text=Shipment details>>nth=4", "#react-quoter-landing-plugin >> filter:text=Freight details>>nth=4", "#react-quoter-landing-plugin >> filter:text=货运详情>>nth=4", ], RPA_SELECTOR_SUBMIT: [ "role=button[name=\"GET QUOTE\"]", "role=button[name=\"Get freight quote\"]", "role=button[name=\"Get a freight quote\"]", "role=button[name=\"获取货运报价\"]", ], RPA_SELECTOR_STANDARD_TAB: [ "filter:text=/^Standard$/>>first", "filter:text=/^标准$/>>first", ], RPA_SELECTOR_GUARANTEED_TAB: [ "filter:text=/^Guaranteed$/>>first", "filter:text=/^保证送达$/>>first", ], RPA_SELECTOR_LOWEST_OPTION: ["text=Lowest price", "text=最低价格"], RPA_SELECTOR_ADDRESS_SUGGESTION: [ "#react-quoter-landing-plugin >> [role=\"listbox\"] [role=\"option\"]", "#react-quoter-landing-plugin >> li", ".pac-item", '[role="option"]', ], RPA_SELECTOR_PALLET_QTY: [ "placeholder=How many pallets?", "placeholder=需要几个托盘?", ], RPA_SELECTOR_WEIGHT: [ "placeholder=Weight (lbs)", "placeholder=Weight (kg)", "placeholder=重量(公斤)", ], RPA_SELECTOR_LENGTH: ["placeholder=Length", "placeholder=长度"], RPA_SELECTOR_WIDTH: ["placeholder=Width", "placeholder=宽度"], RPA_SELECTOR_HEIGHT: ["placeholder=Height", "placeholder=高度"], RPA_SELECTOR_PRICE: ["text=$"], RPA_SELECTOR_QUOTE_WIDGET: ["#react-quoter-landing-plugin"], RPA_SELECTOR_FREIGHT_REMOVE: [ 'role=button[name=/remove/i]', 'role=button[name=/delete/i]', 'role=button[name=/删除/i]', ], RPA_SELECTOR_FREIGHT_ADD: [ 'role=button[name="ADD ITEM"]', 'role=button[name=/Add item/i]', 'role=button[name=/Add freight/i]', 'role=button[name=/添加/i]', ], }; /** 逗号分隔的多 selector 规格(中英双语) */ export function parseSelectorSpecs(raw: string | undefined): string[] { if (!raw?.trim()) { return []; } return raw.split(",").map((item) => item.trim()).filter(Boolean); } /** env 优先,再合并内置回退,去重保序 */ export function getSelectorSpecs(envKey: string): string[] { const fromEnv = parseSelectorSpecs(process.env[envKey]); const fallbacks = BUILTIN_SELECTOR_FALLBACKS[envKey] ?? []; const seen = new Set(); const merged: string[] = []; for (const spec of [...fromEnv, ...fallbacks]) { if (seen.has(spec)) { continue; } seen.add(spec); merged.push(spec); } return merged; } export async function firstVisibleFromSpecs( page: Page, specs: string[], ): Promise { for (const spec of specs) { const loc = await firstVisibleLocator(page, spec); if (loc) { return loc; } } return null; } export async function clickFirstVisibleFromSpecs( page: Page, specs: string[], ): Promise { const loc = await firstVisibleFromSpecs(page, specs); if (!loc) { return false; } await loc.click().catch(() => undefined); return true; } /** 可选字段:按 spec 列表尝试 fill(React 重渲染导致 detach 时重试) */ export async function fillFirstVisibleFromSpecs( page: Page, specs: string[], value: string, options?: { retries?: number }, ): Promise { const retries = options?.retries ?? 1; for (let attempt = 0; attempt < retries; attempt += 1) { const loc = await firstVisibleFromSpecs(page, specs); if (!loc) { if (attempt < retries - 1) { await page.waitForTimeout(200); continue; } return false; } try { await loc.fill(value, { timeout: 8_000 }); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); const retriable = message.includes("detached") || message.includes("not attached") || message.includes("not visible"); if (!retriable || attempt >= retries - 1) { throw error; } await page.waitForTimeout(250); } } return false; }