|
|
/**
|
|
|
* 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);
|
|
|
}
|