|
|
import type { Locator, Page } from "playwright";
|
|
|
import { RPA_STREET_INPUT_CLICK_MS, RPA_WIDGET_HYDRATION_TIMEOUT_MS } from "@/lib/constants/rpa";
|
|
|
import { firstVisibleLocator, pageLocator } from "@/lib/rpa/locator";
|
|
|
import { clickFirstVisibleFromSpecs } from "@/lib/rpa/selector-specs";
|
|
|
import { isRpaAddressRushMode } from "@/lib/rpa/env";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import {
|
|
|
resolveRPAContext,
|
|
|
type RPAContext,
|
|
|
} from "@/workers/rpa/kernel/context";
|
|
|
import {
|
|
|
stabilizeQuoteLandingPage,
|
|
|
waitForQuoterWidgetHydrated,
|
|
|
} from "@/workers/rpa/page-prep";
|
|
|
|
|
|
const WIDGET_WAIT_MS = RPA_WIDGET_HYDRATION_TIMEOUT_MS;
|
|
|
const STREET_NAME_RE = /搜索地址|Search\s+(?:by\s+)?address/i;
|
|
|
const ADDRESS_SIDE_POLL_MS = 200;
|
|
|
|
|
|
function resolveCtx(pageOrCtx: Page | RPAContext): RPAContext {
|
|
|
return resolveRPAContext(pageOrCtx);
|
|
|
}
|
|
|
|
|
|
function sectionSpecs(
|
|
|
ctx: RPAContext,
|
|
|
side: "pickup" | "delivery",
|
|
|
): string[] {
|
|
|
return ctx.selectors.specs(
|
|
|
side === "pickup"
|
|
|
? "RPA_SELECTOR_PICKUP_SECTION"
|
|
|
: "RPA_SELECTOR_DELIVERY_SECTION",
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function streetSpecs(
|
|
|
ctx: RPAContext,
|
|
|
side: "pickup" | "delivery",
|
|
|
): string[] {
|
|
|
return ctx.selectors.specs(
|
|
|
side === "pickup"
|
|
|
? "RPA_SELECTOR_PICKUP_STREET"
|
|
|
: "RPA_SELECTOR_DELIVERY_STREET",
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function sideLabel(side: "pickup" | "delivery"): string {
|
|
|
return side === "pickup" ? "提货" : "派送";
|
|
|
}
|
|
|
|
|
|
async function isEditableStreetInput(loc: Locator): Promise<boolean> {
|
|
|
if (!(await loc.isVisible().catch(() => false))) {
|
|
|
return false;
|
|
|
}
|
|
|
const editable = await loc.isEditable().catch(() => false);
|
|
|
if (!editable) {
|
|
|
return false;
|
|
|
}
|
|
|
const readonly = await loc.getAttribute("readonly").catch(() => null);
|
|
|
const ariaReadonly = await loc.getAttribute("aria-readonly").catch(() => null);
|
|
|
const disabled = await loc.isDisabled().catch(() => false);
|
|
|
return !disabled && readonly === null && ariaReadonly !== "true";
|
|
|
}
|
|
|
|
|
|
async function findEmptyEditableStreetInput(
|
|
|
boxes: Locator,
|
|
|
count: number,
|
|
|
): Promise<Locator | null> {
|
|
|
for (let i = count - 1; i >= 0; i -= 1) {
|
|
|
const loc = boxes.nth(i);
|
|
|
if (!(await isEditableStreetInput(loc))) {
|
|
|
continue;
|
|
|
}
|
|
|
const val = ((await loc.inputValue().catch(() => "")) || "")
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim();
|
|
|
if (val.length < 8) {
|
|
|
return loc;
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
async function streetTextboxesInWidget(ctx: RPAContext): Promise<Locator> {
|
|
|
return ctx.selectors.widget(ctx.page).getByRole("textbox", {
|
|
|
name: STREET_NAME_RE,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function firstEditableInputInWidget(ctx: RPAContext): Promise<Locator | null> {
|
|
|
const inputs = ctx.selectors.widget(ctx.page).locator("input");
|
|
|
const count = await inputs.count().catch(() => 0);
|
|
|
for (let i = 0; i < count; i += 1) {
|
|
|
const loc = inputs.nth(i);
|
|
|
if (await isEditableStreetInput(loc)) {
|
|
|
return loc;
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
async function firstVisibleInputInWidget(ctx: RPAContext): Promise<Locator | null> {
|
|
|
const inputs = ctx.selectors.widget(ctx.page).locator("input");
|
|
|
const count = await inputs.count().catch(() => 0);
|
|
|
for (let i = 0; i < count; i += 1) {
|
|
|
const loc = inputs.nth(i);
|
|
|
if (await loc.isVisible().catch(() => false)) {
|
|
|
return loc;
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
async function findEditableStreetFromSpecs(
|
|
|
ctx: RPAContext,
|
|
|
specs: string[],
|
|
|
preferLast: boolean,
|
|
|
): Promise<Locator | null> {
|
|
|
const page = ctx.page;
|
|
|
for (const spec of specs) {
|
|
|
const root = pageLocator(page, spec);
|
|
|
const count = await root.count().catch(() => 0);
|
|
|
if (count === 0) {
|
|
|
continue;
|
|
|
}
|
|
|
const indices = preferLast
|
|
|
? Array.from({ length: count }, (_, i) => count - 1 - i)
|
|
|
: Array.from({ length: count }, (_, i) => i);
|
|
|
for (const i of indices) {
|
|
|
const loc = root.nth(i);
|
|
|
if (await isEditableStreetInput(loc)) {
|
|
|
return loc;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
export async function visibleStreetInput(
|
|
|
pageOrCtx: Page | RPAContext,
|
|
|
side: "pickup" | "delivery",
|
|
|
options?: { deliverySectionOpened?: boolean; enumerationMode?: boolean },
|
|
|
): Promise<Locator | null> {
|
|
|
const ctx = resolveCtx(pageOrCtx);
|
|
|
const preferLast = side === "delivery";
|
|
|
const boxes = await streetTextboxesInWidget(ctx);
|
|
|
const count = await boxes.count().catch(() => 0);
|
|
|
|
|
|
if (side === "delivery" && options?.enumerationMode) {
|
|
|
const indices = preferLast
|
|
|
? Array.from({ length: count }, (_, i) => count - 1 - i)
|
|
|
: Array.from({ length: count }, (_, i) => i);
|
|
|
for (const i of indices) {
|
|
|
const loc = boxes.nth(i);
|
|
|
if (await isEditableStreetInput(loc)) {
|
|
|
return loc;
|
|
|
}
|
|
|
}
|
|
|
return findEditableStreetFromSpecs(ctx, streetSpecs(ctx, side), preferLast);
|
|
|
}
|
|
|
|
|
|
if (side === "delivery") {
|
|
|
if (!options?.deliverySectionOpened && count < 2) {
|
|
|
return null;
|
|
|
}
|
|
|
if (count < 2) {
|
|
|
const empty = await findEmptyEditableStreetInput(boxes, count);
|
|
|
if (empty) {
|
|
|
return empty;
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const indices = preferLast
|
|
|
? Array.from({ length: count }, (_, i) => count - 1 - i)
|
|
|
: Array.from({ length: count }, (_, i) => i);
|
|
|
for (const i of indices) {
|
|
|
const loc = boxes.nth(i);
|
|
|
if (await isEditableStreetInput(loc)) {
|
|
|
return loc;
|
|
|
}
|
|
|
}
|
|
|
return findEditableStreetFromSpecs(ctx, streetSpecs(ctx, side), preferLast);
|
|
|
}
|
|
|
|
|
|
async function tryReadyDeliveryStreetInput(
|
|
|
ctx: RPAContext,
|
|
|
): Promise<Locator | null> {
|
|
|
const input = await visibleStreetInput(ctx, "delivery", {
|
|
|
deliverySectionOpened: true,
|
|
|
});
|
|
|
if (!input || !(await isEditableStreetInput(input))) {
|
|
|
return null;
|
|
|
}
|
|
|
const val = ((await input.inputValue().catch(() => "")) || "")
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim();
|
|
|
if (val.length >= 8) {
|
|
|
return null;
|
|
|
}
|
|
|
return input;
|
|
|
}
|
|
|
|
|
|
/** 提货锁定后等待独立派送输入框(禁止复用仍含提货文案的单框) */
|
|
|
export async function waitForDeliveryStreetInput(
|
|
|
pageOrCtx: Page | RPAContext,
|
|
|
timeoutMs = 2_500,
|
|
|
): Promise<Locator> {
|
|
|
const ctx = resolveCtx(pageOrCtx);
|
|
|
|
|
|
const immediate = await tryReadyDeliveryStreetInput(ctx);
|
|
|
if (immediate) {
|
|
|
return immediate;
|
|
|
}
|
|
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
const ready = await tryReadyDeliveryStreetInput(ctx);
|
|
|
if (ready) {
|
|
|
return ready;
|
|
|
}
|
|
|
|
|
|
await clickFirstVisibleFromSpecs(ctx.page, sectionSpecs(ctx, "delivery"));
|
|
|
await ctx.page.waitForTimeout(250);
|
|
|
|
|
|
const afterClick = await tryReadyDeliveryStreetInput(ctx);
|
|
|
if (afterClick) {
|
|
|
return afterClick;
|
|
|
}
|
|
|
await ctx.page.waitForTimeout(ADDRESS_SIDE_POLL_MS);
|
|
|
}
|
|
|
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
"派送地址输入框未就绪(提货可能未锁定,仍占用同一搜索框)",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function waitForQuoteWidget(ctx: RPAContext): Promise<void> {
|
|
|
const widget = ctx.selectors.widget(ctx.page);
|
|
|
await widget
|
|
|
.waitFor({ state: "visible", timeout: WIDGET_WAIT_MS })
|
|
|
.catch(() =>
|
|
|
widget.waitFor({ state: "attached", timeout: WIDGET_WAIT_MS }),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export async function clickStreetInput(
|
|
|
input: Locator,
|
|
|
side: "pickup" | "delivery",
|
|
|
): Promise<void> {
|
|
|
const label = sideLabel(side);
|
|
|
await input.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
if (!(await isEditableStreetInput(input))) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`${label}地址输入框不可编辑(可能命中已锁定的提货框)`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const focused = await input
|
|
|
.focus({ timeout: RPA_STREET_INPUT_CLICK_MS })
|
|
|
.then(() => true)
|
|
|
.catch(() => false);
|
|
|
|
|
|
if (focused) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
await input.click({ timeout: 3_000, force: true });
|
|
|
} catch {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`${label}地址输入框无法聚焦(请检查 RPA_HEADLESS=false / VPN / storageState)`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export async function openAddressSide(
|
|
|
pageOrCtx: Page | RPAContext,
|
|
|
side: "pickup" | "delivery",
|
|
|
options?: { enumerationMode?: boolean },
|
|
|
): Promise<Locator> {
|
|
|
const ctx = resolveCtx(pageOrCtx);
|
|
|
const page = ctx.page;
|
|
|
ctx.state.markAddressSide(side);
|
|
|
|
|
|
if (side === "pickup") {
|
|
|
const visible = await firstVisibleInputInWidget(ctx);
|
|
|
if (visible) {
|
|
|
await visible.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await visible.click({ timeout: 2_000, force: true }).catch(() => undefined);
|
|
|
if (await isEditableStreetInput(visible)) {
|
|
|
console.log("[rpa] openAddressSide:pickup 可见输入框激活成功");
|
|
|
return visible;
|
|
|
}
|
|
|
}
|
|
|
const direct = await firstEditableInputInWidget(ctx);
|
|
|
if (direct) {
|
|
|
console.log("[rpa] openAddressSide:pickup 直达输入框成功");
|
|
|
await clickStreetInput(direct, side);
|
|
|
return direct;
|
|
|
}
|
|
|
const existing = await visibleStreetInput(ctx, "pickup");
|
|
|
if (existing && (await isEditableStreetInput(existing))) {
|
|
|
console.log("[rpa] openAddressSide:pickup 快速通道成功");
|
|
|
await clickStreetInput(existing, side);
|
|
|
return existing;
|
|
|
}
|
|
|
console.log("[rpa] openAddressSide:pickup 快速通道失败,尝试点击分区");
|
|
|
}
|
|
|
|
|
|
if (side === "delivery" && !options?.enumerationMode) {
|
|
|
if (isRpaAddressRushMode()) {
|
|
|
const direct =
|
|
|
(await visibleStreetInput(ctx, "delivery", {
|
|
|
deliverySectionOpened: true,
|
|
|
})) ??
|
|
|
(await firstEditableInputInWidget(ctx)) ??
|
|
|
(await firstVisibleInputInWidget(ctx));
|
|
|
if (direct && (await isEditableStreetInput(direct))) {
|
|
|
console.log("[rpa] openAddressSide:delivery visual-rush 直达输入框");
|
|
|
await clickStreetInput(direct, side);
|
|
|
return direct;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 快速通道1:直接检查是否有空的delivery输入框
|
|
|
const ready = await tryReadyDeliveryStreetInput(ctx);
|
|
|
if (ready) {
|
|
|
console.log("[rpa] openAddressSide:delivery 快速通道1成功(空输入框)");
|
|
|
await clickStreetInput(ready, side);
|
|
|
return ready;
|
|
|
}
|
|
|
|
|
|
// 快速通道2:点击派送分区标签(含新版 Where to?)
|
|
|
const deliverTo = ctx.selectors
|
|
|
.widget(page)
|
|
|
.getByText(/^(Deliver to|Delivery to|Where to\?)$/i)
|
|
|
.first();
|
|
|
if (await deliverTo.isVisible().catch(() => false)) {
|
|
|
await deliverTo.click({ timeout: 2_000 }).catch(() => undefined);
|
|
|
await page.waitForTimeout(80);
|
|
|
const afterDeliverTo = await tryReadyDeliveryStreetInput(ctx);
|
|
|
if (afterDeliverTo) {
|
|
|
console.log("[rpa] openAddressSide:delivery 快速通道2成功(Deliver to点击)");
|
|
|
await clickStreetInput(afterDeliverTo, side);
|
|
|
return afterDeliverTo;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
console.log("[rpa] openAddressSide:delivery 快速通道失败,尝试分区 selector");
|
|
|
await clickFirstVisibleFromSpecs(page, sectionSpecs(ctx, "delivery")).catch(
|
|
|
() => undefined,
|
|
|
);
|
|
|
await page.waitForTimeout(200);
|
|
|
const afterSection = await tryReadyDeliveryStreetInput(ctx);
|
|
|
if (afterSection) {
|
|
|
console.log("[rpa] openAddressSide:delivery 分区点击后输入框就绪");
|
|
|
await clickStreetInput(afterSection, side);
|
|
|
return afterSection;
|
|
|
}
|
|
|
|
|
|
const fallback = await visibleStreetInput(ctx, "delivery", {
|
|
|
deliverySectionOpened: true,
|
|
|
});
|
|
|
if (fallback && (await isEditableStreetInput(fallback))) {
|
|
|
await clickStreetInput(fallback, side);
|
|
|
return fallback;
|
|
|
}
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
"派送地址输入框未就绪(请确认提货已锁定)",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const sections = sectionSpecs(ctx, side);
|
|
|
if (sections.length === 0) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`${sideLabel(side)}分区 selector 未配置`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
await clickFirstVisibleFromSpecs(page, sections);
|
|
|
|
|
|
const input = await visibleStreetInput(ctx, side, {
|
|
|
deliverySectionOpened: side === "delivery",
|
|
|
enumerationMode: options?.enumerationMode,
|
|
|
});
|
|
|
if (!input || !(await isEditableStreetInput(input))) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`${sideLabel(side)}地址输入框不可见(请检查中英 selector)`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
await clickStreetInput(input, side);
|
|
|
return input;
|
|
|
}
|
|
|
|
|
|
export async function waitForQuoteWidgetReady(
|
|
|
pageOrCtx: Page | RPAContext,
|
|
|
timeoutMs?: number,
|
|
|
): Promise<void> {
|
|
|
const ctx = resolveCtx(pageOrCtx);
|
|
|
await stabilizeQuoteLandingPage(ctx);
|
|
|
|
|
|
const widgetWaitMs = timeoutMs ?? WIDGET_WAIT_MS;
|
|
|
|
|
|
// 显式等待 quoter 资源加载 + widget hydration(en-US 首页渲染较慢,避免固定 sleep 提前进入)
|
|
|
// 提货分区出现即返回;deadline 在 hydration 完成后计时,保证分区点击循环获得完整预算
|
|
|
await waitForQuoterWidgetHydrated(ctx.page, widgetWaitMs).catch(() => undefined);
|
|
|
|
|
|
const deadline = Date.now() + widgetWaitMs;
|
|
|
while (Date.now() < deadline) {
|
|
|
const pickupInput = await visibleStreetInput(ctx, "pickup");
|
|
|
if (pickupInput && (await isEditableStreetInput(pickupInput))) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
await clickFirstVisibleFromSpecs(ctx.page, sectionSpecs(ctx, "pickup"));
|
|
|
await ctx.page.waitForTimeout(400);
|
|
|
}
|
|
|
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
"报价 widget 未就绪(提货地址输入不可见,可能为英文 UI 需更新 selector)",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** @deprecated 使用 waitForQuoteWidgetReady */
|
|
|
export async function ensureQuoteFormVisible(page: Page): Promise<void> {
|
|
|
await waitForQuoteWidgetReady(page);
|
|
|
}
|
|
|
|
|
|
export async function getVisibleStreetInput(
|
|
|
pageOrCtx: Page | RPAContext,
|
|
|
side: "pickup" | "delivery",
|
|
|
options?: { skipOpenSide?: boolean },
|
|
|
): Promise<Locator> {
|
|
|
const ctx = resolveCtx(pageOrCtx);
|
|
|
if (!options?.skipOpenSide) {
|
|
|
return openAddressSide(ctx, side);
|
|
|
}
|
|
|
const input = await visibleStreetInput(ctx, side, {
|
|
|
deliverySectionOpened: side === "delivery",
|
|
|
});
|
|
|
if (!input) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`${sideLabel(side)}地址输入框不可见`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
if (!(await isEditableStreetInput(input))) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`${sideLabel(side)}地址输入框不可编辑`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
return input;
|
|
|
}
|