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.

177 lines
5.8 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 { firstVisibleLocator } from "@/lib/rpa/locator";
/** env 未配时的中英双语内置回退MotherShip 首页 widget */
const BUILTIN_SELECTOR_FALLBACKS: Record<string, string[]> = {
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=Where 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: [
"#react-quoter-landing-plugin >> text=Get a freight quote",
"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<string>();
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<Locator | null> {
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<boolean> {
const loc = await firstVisibleFromSpecs(page, specs);
if (!loc) {
return false;
}
await loc.click().catch(() => undefined);
return true;
}
/** 可选字段:按 spec 列表尝试 fillReact 重渲染导致 detach 时重试) */
export async function fillFirstVisibleFromSpecs(
page: Page,
specs: string[],
value: string,
options?: { retries?: number },
): Promise<boolean> {
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;
}