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.

473 lines
14 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 { 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 hydrationen-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;
}