/** * Flock get-a-quote 表单交互 — 匿名录制路径 + 登录后 Quick 路径 */ import type { Page } from "playwright"; import { clickFirstVisibleFromSpecs, fillFirstVisibleFromSpecs, parseSelectorSpecs, } from "@/lib/rpa/selector-specs"; import { fillFlockField, getFlockSelectorSpecs, } from "@/lib/rpa/flock-selector-specs"; import { getFlockFieldPauseMs, getFlockFormWaitMs } from "@/lib/flock/env"; import { waitWithFlockStallWatch } from "@/workers/rpa/flock/stall-watch"; import { isFlockQuoteLimitReached } from "@/workers/rpa/flock/page-state"; import { flockLocatorCount } from "@/workers/rpa/flock/page-alive"; import { clickFlockLoggedInQuoteSubmit, fillFlockLoggedInQuoteForm, isFlockLoggedInQuoteForm, } from "@/workers/rpa/flock/logged-in-form"; import type { FlockQuoteInput } from "@/workers/rpa/flock/types"; const DISPLAY_DATE_RE = /^(\d{2})\/(\d{2})\/(\d{4})$/; const FIELD_WAIT_MS = 20_000; function fieldPauseMs(): number { return getFlockFieldPauseMs(); } async function fillTextboxByNames( page: Page, names: Array, value: string, ): Promise { for (const name of names) { const loc = page.getByRole("textbox", { name }).first(); try { await loc.waitFor({ state: "visible", timeout: FIELD_WAIT_MS }); await loc.fill(value); return true; } catch { continue; } } return false; } async function fillWithEnvOrRecording( page: Page, envKey: string, recordingNames: Array, value: string, ): Promise { const envSpecs = parseSelectorSpecs(process.env[envKey]); if (envSpecs.length > 0) { const ok = await fillFirstVisibleFromSpecs(page, envSpecs, value, { retries: 3, }); if (ok) return true; } if (await fillTextboxByNames(page, recordingNames, value)) { return true; } return fillFlockField(page, envKey, value); } /** 等待查价表单挂载(React hydration);遇上限页立即失败 */ export async function waitForFlockQuoteForm(page: Page): Promise { async function formReady(): Promise { if (await isFlockQuoteLimitReached(page)) { return false; } if (await isFlockLoggedInQuoteForm(page)) { return true; } const markers = [ page.getByRole("textbox", { name: /Pickup ZIP Code/i }), page.getByRole("textbox", { name: /Delivery ZIP Code/i }), page.getByTestId( "anonymous_marketing_lead_quote_request_page__quote_request__submit_button", ), ]; for (const loc of markers) { if ( (await flockLocatorCount(loc)) > 0 && (await loc.first().isVisible().catch(() => false)) ) { return true; } } return false; } const { ok } = await waitWithFlockStallWatch(page, "等待查价表单", formReady, { timeoutMs: getFlockFormWaitMs(), pollMs: 400, }); return ok; } /** 日历选日:录制为 Choose date → gridcell */ export async function fillFlockPickupDate( page: Page, pickupDateDisplay: string, ): Promise { const m = DISPLAY_DATE_RE.exec(pickupDateDisplay.trim()); if (!m) { return fillWithEnvOrRecording( page, "FLOCK_SELECTOR_PICKUP_DATE", [/Shipment Pickup/i, /Pickup Date/i, /取货日期/i], pickupDateDisplay, ); } const dayRaw = m[2]!; const dayNum = String(Number(dayRaw)); const dateBtn = page .getByRole("button", { name: /Choose date|选择日期/i }) .first(); if ((await flockLocatorCount(dateBtn)) > 0) { await dateBtn.click().catch(() => undefined); await page.waitForTimeout(400); for (const dayLabel of [dayNum, dayRaw]) { const cell = page.getByRole("gridcell", { name: dayLabel }).first(); if ((await flockLocatorCount(cell)) > 0) { await cell.click(); return true; } } } return fillWithEnvOrRecording( page, "FLOCK_SELECTOR_PICKUP_DATE", [/Shipment Pickup/i, /Pickup Date/i, /取货日期/i], pickupDateDisplay, ); } /** 按录制顺序填写查价表单(自动识别匿名 / 登录后 Quick) */ export async function fillFlockQuoteForm( page: Page, input: FlockQuoteInput, ): Promise<{ ok: boolean; error?: string }> { if ( input.formMode === "logged_in_quick" || (await isFlockLoggedInQuoteForm(page)) ) { console.log( "[flock-form] 登录后 Quick 表单路径(对齐 quote-request-quick-form__submit_button)", ); return fillFlockLoggedInQuoteForm(page, input); } const fields: Array<{ envKey: string; names: Array; value: string; label: string; }> = [ { envKey: "FLOCK_SELECTOR_PICKUP_ZIP", names: ["Pickup ZIP Code", /Pickup ZIP/i, /取件.*ZIP/i], value: input.pickupZip, label: "取件 ZIP", }, { envKey: "FLOCK_SELECTOR_DELIVERY_ZIP", names: ["Delivery ZIP Code", /Delivery ZIP/i, /投递.*ZIP/i], value: input.deliveryZip, label: "投递 ZIP", }, { envKey: "FLOCK_SELECTOR_PALLET_COUNT", names: ["Number of pallets", /Quantity/i, /pallets/i, /托盘/i], value: String(input.palletCount), label: "托盘数", }, { envKey: "FLOCK_SELECTOR_TOTAL_WEIGHT", names: [ "Total shipment weight (lbs)", /Total Weight \(lbs\)/i, /Total shipment weight/i, /总.*重/i, ], value: String(input.totalWeightLb), label: "总重量", }, { envKey: "FLOCK_SELECTOR_LENGTH", names: ["Length (in)", /Length/i, /长度/i], value: String(input.lengthIn), label: "长度", }, { envKey: "FLOCK_SELECTOR_WIDTH", names: ["Width (in)", /Width/i, /宽度/i], value: String(input.widthIn), label: "宽度", }, { envKey: "FLOCK_SELECTOR_HEIGHT", names: ["Height (in)", /Height/i, /高度/i], value: String(input.heightIn), label: "高度", }, ]; if ( !(await fillWithEnvOrRecording( page, fields[0]!.envKey, fields[0]!.names, fields[0]!.value, )) ) { return { ok: false, error: "QUOTE_ENTRY_UNAVAILABLE:无法定位取件 ZIP" }; } if (fieldPauseMs() > 0) { await page.waitForTimeout(fieldPauseMs()); } if (!(await fillFlockPickupDate(page, input.pickupDate))) { return { ok: false, error: "QUOTE_ENTRY_UNAVAILABLE:无法填写取货日期" }; } if (fieldPauseMs() > 0) { await page.waitForTimeout(fieldPauseMs()); } for (const field of fields.slice(1)) { if ( !(await fillWithEnvOrRecording( page, field.envKey, field.names, field.value, )) ) { return { ok: false, error: `QUOTE_ENTRY_UNAVAILABLE:无法定位${field.label}`, }; } if (fieldPauseMs() > 0) { await page.waitForTimeout(fieldPauseMs()); } } return { ok: true }; } export async function clickFlockQuoteSubmit(page: Page): Promise { if (await clickFlockLoggedInQuoteSubmit(page)) { return true; } const testId = page.getByTestId( "anonymous_marketing_lead_quote_request_page__quote_request__submit_button", ); if ((await flockLocatorCount(testId)) > 0) { await testId.first().click(); return true; } return clickFirstVisibleFromSpecs( page, getFlockSelectorSpecs("FLOCK_SELECTOR_NEXT"), ); } /** 结果页「Build another quote」或回入口 */ export async function navigateFlockNewQuote( page: Page, quoteUrl: string, ): Promise { const buildAnother = page .getByRole("link", { name: /Build another quote/i }) .first(); if ((await flockLocatorCount(buildAnother)) > 0) { await buildAnother.click(); await page.waitForLoadState("domcontentloaded").catch(() => undefined); await page.waitForTimeout(2_000); return; } const { gotoWithResilience } = await import("@/lib/rpa/page-goto"); await gotoWithResilience(page, quoteUrl); }