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 { isRpaVisualRushMode } 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 { 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 { 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 { return ctx.selectors.widget(ctx.page).getByRole("textbox", { name: STREET_NAME_RE, }); } async function firstEditableInputInWidget(ctx: RPAContext): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 (isRpaVisualRushMode()) { 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:点击"Deliver to"文本 const deliverTo = ctx.selectors .widget(page) .getByText(/^Deliver 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 快速通道失败,尝试fallback"); 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 { 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 { await waitForQuoteWidgetReady(page); } export async function getVisibleStreetInput( pageOrCtx: Page | RPAContext, side: "pickup" | "delivery", options?: { skipOpenSide?: boolean }, ): Promise { 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; }