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.

294 lines
7.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.

/**
* 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<string | RegExp>,
value: string,
): Promise<boolean> {
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<string | RegExp>,
value: string,
): Promise<boolean> {
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<boolean> {
async function formReady(): Promise<boolean> {
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<boolean> {
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<string | RegExp>;
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<boolean> {
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<void> {
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);
}