|
|
/**
|
|
|
* Flock 登录态结账:选档 → 最低价 → Yes I want this rate → 填表
|
|
|
* 默认 fillOnly:填齐后硬停,永不点 Complete your order / 支付
|
|
|
*/
|
|
|
import fs from "node:fs";
|
|
|
import path from "node:path";
|
|
|
import type { Locator, Page } from "playwright";
|
|
|
import {
|
|
|
pickLowestRateOptionIndex,
|
|
|
pickFlexibilityOptionIndex,
|
|
|
pickCarrierOptionIndex,
|
|
|
parseFlockRateUsd,
|
|
|
enrichFlexibilityOption,
|
|
|
enrichCarrierOption,
|
|
|
type FlockCarrierOption,
|
|
|
type FlockCheckoutTier,
|
|
|
type FlockFlexibilityKey,
|
|
|
type FlockFlexibilityOption,
|
|
|
type FlockTierPricingScrape,
|
|
|
} from "@/lib/flock/flock-checkout-selection";
|
|
|
import {
|
|
|
getFlockStorageStatePath,
|
|
|
getFlockLoginUrl,
|
|
|
type FlockTestAccount,
|
|
|
} from "@/lib/flock/env";
|
|
|
import {
|
|
|
getEffectiveFlockLogin,
|
|
|
hasEffectiveFlockLogin,
|
|
|
mustForceFlockAccountLogin,
|
|
|
} from "@/lib/flock/login-context";
|
|
|
import { resolveFlockQuoteAccount } from "@/lib/flock/resolve-account";
|
|
|
import { launchRpaBrowser } from "@/lib/rpa/browser-launch";
|
|
|
import { createRpaBrowserContext } from "@/lib/rpa/browser-context";
|
|
|
import { gotoWithResilience } from "@/lib/rpa/page-goto";
|
|
|
import {
|
|
|
isRpaHeaded,
|
|
|
resolveRpaHeadless,
|
|
|
resolveRpaSlowMoMs,
|
|
|
} from "@/lib/rpa/env";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import type { FlockCheckoutDetails } from "@/modules/flock/checkout-validation";
|
|
|
import { FLOCK_RESULT_TEST_IDS } from "@/workers/rpa/flock/form-logic-map";
|
|
|
import { ensureFlockLoggedIn } from "@/workers/rpa/flock/login";
|
|
|
import { openFlockLoggedInQuoteEntry } from "@/workers/rpa/flock/logged-in-form";
|
|
|
import { runFlockQuoteOnPage } from "@/workers/rpa/flock/visual-chain";
|
|
|
import { isFlockQuoteResultsPage } from "@/workers/rpa/flock/register-account";
|
|
|
import type { FlockQuoteInput } from "@/workers/rpa/flock/types";
|
|
|
|
|
|
const PAUSE_SM = 400;
|
|
|
const PAUSE_MD = 900;
|
|
|
|
|
|
/** 禁止点击的支付 / 完成下单按钮 */
|
|
|
export const FLOCK_CHECKOUT_PAYMENT_BUTTON_RE =
|
|
|
/^(Complete\s+your\s+order|Pay(\b| now| with)|Place\s+order|Submit\s+payment|Confirm\s+payment|Add\s+(a\s+)?payment)/i;
|
|
|
|
|
|
export type FlockCheckoutProbeResult = {
|
|
|
stage:
|
|
|
| "details_filled"
|
|
|
| "agree"
|
|
|
| "review"
|
|
|
| "checkout_stop_before_payment";
|
|
|
preferredTier: FlockCheckoutTier;
|
|
|
selectedTotal: number;
|
|
|
screenshotPath?: string;
|
|
|
missingFields?: string[];
|
|
|
reference?: string | null;
|
|
|
};
|
|
|
|
|
|
export type FlockCheckoutDetailsDefaults = {
|
|
|
pickupCompany: string;
|
|
|
deliveryCompany: string;
|
|
|
pickupAddress1: string;
|
|
|
pickupAddress2: string;
|
|
|
deliveryAddress1: string;
|
|
|
deliveryAddress2: string;
|
|
|
pickupCity: string;
|
|
|
deliveryCity: string;
|
|
|
pickupState: string;
|
|
|
deliveryState: string;
|
|
|
pickupZip: string;
|
|
|
deliveryZip: string;
|
|
|
pickupContact: string;
|
|
|
deliveryContact: string;
|
|
|
pickupEmail: string;
|
|
|
deliveryEmail: string;
|
|
|
pickupPhone: string;
|
|
|
deliveryPhone: string;
|
|
|
pickupOpens: string;
|
|
|
pickupCloses: string;
|
|
|
deliveryOpens: string;
|
|
|
deliveryCloses: string;
|
|
|
deliveryPo: string;
|
|
|
bolRemarks: string;
|
|
|
notes: string;
|
|
|
/** Declaration Statement */
|
|
|
declarationStatement: string;
|
|
|
/** 货运分类码;FlockDirect 常可选,仍填以保证覆盖 */
|
|
|
nmfc: string;
|
|
|
/** Documentation Required for Pickup */
|
|
|
documentationRequired: boolean;
|
|
|
useBillingForPickup: boolean;
|
|
|
useBillingForDelivery: boolean;
|
|
|
weekendDelivery: boolean;
|
|
|
};
|
|
|
|
|
|
export function isFlockCheckoutPaymentButtonLabel(label: string): boolean {
|
|
|
const t = label.replace(/\s+/g, " ").trim();
|
|
|
if (!t) return false;
|
|
|
if (FLOCK_CHECKOUT_PAYMENT_BUTTON_RE.test(t)) return true;
|
|
|
return /\b(credit\s*card|debit\s*card|add\s+(a\s+)?card)\b/i.test(t);
|
|
|
}
|
|
|
|
|
|
export function isFlockCheckoutPaymentPageText(body: string): boolean {
|
|
|
return /Complete your order|Add payment method|Enter card details|Payment method|Pay with|Card number/i.test(
|
|
|
body,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export function buildFlockCheckoutDefaults(opts?: {
|
|
|
pickupZip?: string;
|
|
|
deliveryZip?: string;
|
|
|
tag?: string;
|
|
|
details?: FlockCheckoutDetails;
|
|
|
}): FlockCheckoutDetailsDefaults {
|
|
|
const tag = opts?.tag ?? "chk";
|
|
|
const d = opts?.details;
|
|
|
const p = d?.pickup ?? {};
|
|
|
const del = d?.delivery ?? {};
|
|
|
const deliveryGeo = geoForZip(opts?.deliveryZip || del.zip || "75201");
|
|
|
const pickupGeo = geoForZip(opts?.pickupZip || p.zip || "90001");
|
|
|
const extra = d as
|
|
|
| {
|
|
|
nmfc?: string;
|
|
|
po_number?: string;
|
|
|
bol_remarks?: string;
|
|
|
notes?: string;
|
|
|
declaration_statement?: string;
|
|
|
documentation_required?: boolean;
|
|
|
use_billing_for_pickup?: boolean;
|
|
|
use_billing_for_delivery?: boolean;
|
|
|
}
|
|
|
| undefined;
|
|
|
const weekend =
|
|
|
typeof del.weekend_delivery === "boolean"
|
|
|
? del.weekend_delivery
|
|
|
: false;
|
|
|
return {
|
|
|
pickupCompany: p.company_name?.trim() || `Pickup Co ${tag}`,
|
|
|
deliveryCompany: del.company_name?.trim() || `Delivery Co ${tag}`,
|
|
|
pickupAddress1: p.address1?.trim() || pickupGeo.street,
|
|
|
pickupAddress2: p.address2?.trim() || `Suite ${tag.slice(0, 3).toUpperCase()}`,
|
|
|
deliveryAddress1: del.address1?.trim() || deliveryGeo.street,
|
|
|
deliveryAddress2:
|
|
|
del.address2?.trim() || `Dock ${tag.slice(0, 3).toUpperCase()}`,
|
|
|
pickupCity: p.city?.trim() || pickupGeo.city,
|
|
|
deliveryCity: del.city?.trim() || deliveryGeo.city,
|
|
|
pickupState: p.state?.trim() || pickupGeo.state,
|
|
|
deliveryState: del.state?.trim() || deliveryGeo.state,
|
|
|
pickupZip: p.zip?.trim() || opts?.pickupZip || pickupGeo.zip,
|
|
|
deliveryZip: del.zip?.trim() || opts?.deliveryZip || deliveryGeo.zip,
|
|
|
pickupContact: p.contact_name?.trim() || `Pickup Contact ${tag}`,
|
|
|
deliveryContact: del.contact_name?.trim() || `Delivery Contact ${tag}`,
|
|
|
// 避免 555 虚假号段;官网校验真实美式号码
|
|
|
pickupEmail: p.contact_email?.trim() || `pickup.${tag}@aegrace.com`,
|
|
|
deliveryEmail: del.contact_email?.trim() || `delivery.${tag}@aegrace.com`,
|
|
|
pickupPhone: p.contact_phone?.trim() || "(626) 595-1180",
|
|
|
deliveryPhone: del.contact_phone?.trim() || "(412) 281-0100",
|
|
|
pickupOpens: p.opens_at?.trim() || "9:00 AM",
|
|
|
pickupCloses: p.closes_at?.trim() || "5:00 PM",
|
|
|
deliveryOpens: del.opens_at?.trim() || "9:00 AM",
|
|
|
deliveryCloses: del.closes_at?.trim() || "5:00 PM",
|
|
|
deliveryPo:
|
|
|
extra?.po_number?.trim() ||
|
|
|
del.po_number?.trim() ||
|
|
|
`PO-${tag.toUpperCase()}`,
|
|
|
bolRemarks:
|
|
|
extra?.bol_remarks?.trim() ||
|
|
|
del.bol_remarks?.trim() ||
|
|
|
`BOL ${tag}`,
|
|
|
notes: extra?.notes?.trim() || del.notes?.trim() || `Notes ${tag}`,
|
|
|
declarationStatement: extra?.declaration_statement?.trim() || "",
|
|
|
nmfc: extra?.nmfc?.trim() || `100240-${tag.slice(0, 2).toUpperCase()}`,
|
|
|
documentationRequired: extra?.documentation_required === true,
|
|
|
useBillingForPickup: extra?.use_billing_for_pickup !== false,
|
|
|
useBillingForDelivery: extra?.use_billing_for_delivery === true,
|
|
|
weekendDelivery: weekend,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function geoForZip(zip: string): {
|
|
|
street: string;
|
|
|
city: string;
|
|
|
state: string;
|
|
|
zip: string;
|
|
|
} {
|
|
|
const z = zip.trim();
|
|
|
const map: Record<string, { street: string; city: string; state: string }> = {
|
|
|
"90248": {
|
|
|
street: "19500 S Vermont Ave",
|
|
|
city: "Torrance",
|
|
|
state: "CA",
|
|
|
},
|
|
|
"90001": {
|
|
|
street: "1000 E Florence Ave",
|
|
|
city: "Los Angeles",
|
|
|
state: "CA",
|
|
|
},
|
|
|
"94103": {
|
|
|
street: "998 Market St",
|
|
|
city: "San Francisco",
|
|
|
state: "CA",
|
|
|
},
|
|
|
"15222": {
|
|
|
street: "210 6th Ave",
|
|
|
city: "Pittsburgh",
|
|
|
state: "PA",
|
|
|
},
|
|
|
"75201": {
|
|
|
street: "1601 Elm St",
|
|
|
city: "Dallas",
|
|
|
state: "TX",
|
|
|
},
|
|
|
"60601": {
|
|
|
street: "233 S Wacker Dr",
|
|
|
city: "Chicago",
|
|
|
state: "IL",
|
|
|
},
|
|
|
};
|
|
|
const hit = map[z];
|
|
|
if (hit) return { ...hit, zip: z };
|
|
|
return {
|
|
|
street: "100 Main Street",
|
|
|
city: "Los Angeles",
|
|
|
state: "CA",
|
|
|
zip: z || "90001",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function resolveStoragePath(): string {
|
|
|
const raw = getFlockStorageStatePath();
|
|
|
return path.isAbsolute(raw) ? raw : path.join(process.cwd(), raw);
|
|
|
}
|
|
|
|
|
|
async function captureCheckoutScreenshot(page: Page): Promise<string> {
|
|
|
const diagDir = path.join(process.cwd(), ".rpa", "diag");
|
|
|
fs.mkdirSync(diagDir, { recursive: true });
|
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
|
const shot = path.join(diagDir, `flock-checkout-${stamp}.png`);
|
|
|
await page.screenshot({ path: shot, fullPage: true }).catch(() => undefined);
|
|
|
return shot;
|
|
|
}
|
|
|
|
|
|
async function fillByLabel(
|
|
|
page: Page,
|
|
|
name: RegExp,
|
|
|
value: string,
|
|
|
): Promise<boolean> {
|
|
|
const loc = page.getByLabel(name).first();
|
|
|
if (!(await loc.isVisible().catch(() => false))) return false;
|
|
|
await loc.click({ clickCount: 3 }).catch(() => undefined);
|
|
|
await loc.fill(value).catch(async () => {
|
|
|
await loc.press("Control+a").catch(() => undefined);
|
|
|
await loc.type(value, { delay: 10 }).catch(() => undefined);
|
|
|
});
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
async function fillByRoleTextbox(
|
|
|
page: Page,
|
|
|
name: RegExp,
|
|
|
value: string,
|
|
|
): Promise<boolean> {
|
|
|
const loc = page.getByRole("textbox", { name }).first();
|
|
|
if (!(await loc.isVisible().catch(() => false))) return false;
|
|
|
await loc.click({ clickCount: 3 }).catch(() => undefined);
|
|
|
await loc.fill(value).catch(async () => {
|
|
|
await loc.press("Control+a").catch(() => undefined);
|
|
|
await loc.type(value, { delay: 10 }).catch(() => undefined);
|
|
|
});
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
async function fillField(
|
|
|
page: Page,
|
|
|
names: RegExp[],
|
|
|
value: string,
|
|
|
): Promise<boolean> {
|
|
|
for (const name of names) {
|
|
|
if (await fillByLabel(page, name, value)) return true;
|
|
|
if (await fillByRoleTextbox(page, name, value)) return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** React 受控输入:原生 setter + input/change */
|
|
|
async function writeInputValue(loc: Locator, value: string): Promise<boolean> {
|
|
|
await loc.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await loc.click({ clickCount: 3 }).catch(() => undefined);
|
|
|
const ok = await loc
|
|
|
.evaluate((el, v) => {
|
|
|
const input = el as HTMLInputElement;
|
|
|
const setter = Object.getOwnPropertyDescriptor(
|
|
|
window.HTMLInputElement.prototype,
|
|
|
"value",
|
|
|
)?.set;
|
|
|
if (setter) setter.call(input, v);
|
|
|
else input.value = v;
|
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
return (input.value || "").trim().length > 0;
|
|
|
}, value)
|
|
|
.catch(() => false);
|
|
|
if (ok) return true;
|
|
|
await loc.fill(value).catch(async () => {
|
|
|
await loc.press("Control+a").catch(() => undefined);
|
|
|
await loc.type(value, { delay: 15 }).catch(() => undefined);
|
|
|
});
|
|
|
const got = (await loc.inputValue().catch(() => "")).trim();
|
|
|
return !!got;
|
|
|
}
|
|
|
|
|
|
/** NMFC 常无 accessible name:取 Shipment details 下、PO 之前的首个文本框 */
|
|
|
async function fillNmfcField(page: Page, value: string): Promise<boolean> {
|
|
|
if (!value.trim()) return false;
|
|
|
const candidates: Locator[] = [
|
|
|
page.getByPlaceholder(/NMFC/i).first(),
|
|
|
page.getByLabel(/^NMFC$/i).first(),
|
|
|
page.getByRole("textbox", { name: /^NMFC$/i }).first(),
|
|
|
page.locator("input[name*='nmfc' i], input[id*='nmfc' i]").first(),
|
|
|
page
|
|
|
.getByLabel(/PO Number/i)
|
|
|
.first()
|
|
|
.locator(
|
|
|
"xpath=preceding::input[not(@type='checkbox') and not(@type='hidden')][1]",
|
|
|
),
|
|
|
page
|
|
|
.getByRole("textbox", { name: /PO Number/i })
|
|
|
.first()
|
|
|
.locator(
|
|
|
"xpath=preceding::input[not(@type='checkbox') and not(@type='hidden')][1]",
|
|
|
),
|
|
|
];
|
|
|
for (const loc of candidates) {
|
|
|
if (!(await loc.isVisible({ timeout: 800 }).catch(() => false))) continue;
|
|
|
if (await writeInputValue(loc, value)) {
|
|
|
const got = (await loc.inputValue().catch(() => "")).trim();
|
|
|
console.log(`[flock-checkout] NMFC filled=${got}`);
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
const sectionInputs = page
|
|
|
.getByText(/Shipment details/i)
|
|
|
.first()
|
|
|
.locator(
|
|
|
"xpath=ancestor::*[contains(., 'PO Number')][1]//input[not(@type='checkbox') and not(@type='hidden')]",
|
|
|
);
|
|
|
const n = await sectionInputs.count().catch(() => 0);
|
|
|
for (let i = 0; i < Math.min(n, 4); i += 1) {
|
|
|
const loc = sectionInputs.nth(i);
|
|
|
if (!(await loc.isVisible().catch(() => false))) continue;
|
|
|
const cur = (await loc.inputValue().catch(() => "")).trim();
|
|
|
// 跳过已有 PO 值的框
|
|
|
if (cur && /^PO-/i.test(cur)) continue;
|
|
|
if (cur && cur !== value) continue;
|
|
|
if (await writeInputValue(loc, value)) {
|
|
|
console.log(`[flock-checkout] NMFC filled via section#${i}`);
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
console.log("[flock-checkout] NMFC fill failed(控件未写入)");
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function selectComboboxOption(
|
|
|
page: Page,
|
|
|
name: RegExp,
|
|
|
optionText: string,
|
|
|
): Promise<boolean> {
|
|
|
const box = page.getByRole("combobox", { name }).first();
|
|
|
if (!(await box.isVisible({ timeout: 2_000 }).catch(() => false))) {
|
|
|
return false;
|
|
|
}
|
|
|
await box.click().catch(() => undefined);
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
const opt = page
|
|
|
.getByRole("option", { name: new RegExp(optionText.replace(/\s+/g, "\\s+"), "i") })
|
|
|
.first();
|
|
|
if (await opt.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
|
|
await opt.click();
|
|
|
return true;
|
|
|
}
|
|
|
const byText = page.getByText(optionText, { exact: false }).first();
|
|
|
if (await byText.isVisible({ timeout: 1_000 }).catch(() => false)) {
|
|
|
await byText.click().catch(() => undefined);
|
|
|
return true;
|
|
|
}
|
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 是否仍在两档 pricing options 页 */
|
|
|
export async function isFlockPricingOptionsPage(page: Page): Promise<boolean> {
|
|
|
if (await isFlockQuoteResultsPage(page)) return true;
|
|
|
const body = await page.locator("body").innerText().catch(() => "");
|
|
|
return /pricing options|See Pricing Options|FlockDirect/i.test(body);
|
|
|
}
|
|
|
|
|
|
/** 是否已进入结账填表(Your role / Pickup location) */
|
|
|
export async function isFlockCheckoutFormPage(page: Page): Promise<boolean> {
|
|
|
const body = await page.locator("body").innerText().catch(() => "");
|
|
|
return (
|
|
|
/Your role|Pickup location|Pickup Contact Name|I am the shipper/i.test(
|
|
|
body,
|
|
|
) && !/See Pricing Options/i.test(body)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function diagSeePricingShot(page: Page, label: string): Promise<void> {
|
|
|
const dir = path.join(process.cwd(), ".rpa", "diag");
|
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
const shot = path.join(dir, `see-pricing-${Date.now()}-${label}.png`);
|
|
|
await page.screenshot({ path: shot, fullPage: true }).catch(() => undefined);
|
|
|
console.log(`[flock-hold][diag] ${label} shot=${shot}`);
|
|
|
}
|
|
|
|
|
|
export async function clickSeePricingForTier(
|
|
|
page: Page,
|
|
|
tier: FlockCheckoutTier,
|
|
|
): Promise<void> {
|
|
|
const testId =
|
|
|
tier === "flock_direct"
|
|
|
? FLOCK_RESULT_TEST_IDS.direct
|
|
|
: FLOCK_RESULT_TEST_IDS.standard;
|
|
|
const card = page.getByTestId(testId).first();
|
|
|
if (await card.isVisible({ timeout: 8_000 }).catch(() => false)) {
|
|
|
// 检测「该档不可用」:官网显示 "couldn't find any" 时按钮永远 disabled
|
|
|
const cardText = await card.innerText().catch(() => "");
|
|
|
if (/couldn.t find any|no.*options available/i.test(cardText)) {
|
|
|
console.warn(`[flock-hold] ${tier} 不可用:${cardText.slice(0, 80)}`);
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
`${tier} 不可用(官网无此档报价)`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
const btn = card.getByRole("button", { name: /See Pricing Options/i }).first();
|
|
|
if (await btn.isVisible().catch(() => false)) {
|
|
|
const disabledAttr = await btn.getAttribute("disabled").catch(() => null);
|
|
|
const ariaDisabled = await btn.getAttribute("aria-disabled").catch(() => null);
|
|
|
console.log(
|
|
|
`[flock-hold] ${tier} See Pricing 可见 enabled=${await btn.isEnabled().catch(() => false)} disabledAttr=${disabledAttr} aria-disabled=${ariaDisabled}`,
|
|
|
);
|
|
|
await diagSeePricingShot(page, `${tier}-before-wait`);
|
|
|
// 为何:双卡价 done 后按钮常仍 disabled(官网异步解绑),最多等 45s
|
|
|
const waitStart = Date.now();
|
|
|
const enabledDeadline = waitStart + 45_000;
|
|
|
let waitTicks = 0;
|
|
|
while (Date.now() < enabledDeadline) {
|
|
|
if (await btn.isEnabled().catch(() => false)) break;
|
|
|
waitTicks += 1;
|
|
|
if (waitTicks === 1 || waitTicks % 10 === 0) {
|
|
|
console.log(
|
|
|
`[flock-hold] ${tier} 等待 See Pricing 解禁… ${Math.round((Date.now() - waitStart) / 1000)}s`,
|
|
|
);
|
|
|
}
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
}
|
|
|
await btn.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
if (await btn.isEnabled().catch(() => false)) {
|
|
|
console.log(`[flock-hold] ${tier} See Pricing 已 enabled,正常点击`);
|
|
|
await btn.click({ timeout: 5_000 });
|
|
|
} else {
|
|
|
console.warn(
|
|
|
`[flock-hold] ${tier} See Pricing 仍 disabled,JS 移除 disabled 后点击`,
|
|
|
);
|
|
|
await diagSeePricingShot(page, `${tier}-still-disabled`);
|
|
|
// MUI 按钮 disabled 时 force click 被 React 拦截;直接操作 DOM
|
|
|
await btn.evaluate((el) => {
|
|
|
el.removeAttribute("disabled");
|
|
|
el.removeAttribute("aria-disabled");
|
|
|
(el as HTMLElement).style.pointerEvents = "auto";
|
|
|
});
|
|
|
await page.waitForTimeout(200);
|
|
|
await btn.click({ timeout: 5_000 }).catch(async () => {
|
|
|
// 极端兜底:dispatchEvent
|
|
|
await btn.dispatchEvent("click").catch(() => undefined);
|
|
|
});
|
|
|
}
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
// 等灵活价区或承运商列表加载,最多 15s
|
|
|
let detailVisible = false;
|
|
|
const detailDeadline = Date.now() + 15_000;
|
|
|
while (Date.now() < detailDeadline) {
|
|
|
detailVisible = await isFlockPricingDetailPage(page);
|
|
|
if (detailVisible) break;
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
}
|
|
|
console.log(
|
|
|
`[flock-hold] ${tier} 点击后 detailVisible=${detailVisible}`,
|
|
|
);
|
|
|
await diagSeePricingShot(page, `${tier}-after-click`);
|
|
|
if (!detailVisible) {
|
|
|
console.warn(`[flock-hold] ${tier} 未进入档内价区,JS 再点一次`);
|
|
|
await btn.evaluate((el) => {
|
|
|
el.removeAttribute("disabled");
|
|
|
(el as HTMLElement).style.pointerEvents = "auto";
|
|
|
});
|
|
|
await btn.click({ force: true, timeout: 5_000 }).catch(() => undefined);
|
|
|
await page.waitForTimeout(2_000);
|
|
|
detailVisible = await isFlockPricingDetailPage(page);
|
|
|
await diagSeePricingShot(page, `${tier}-after-retry`);
|
|
|
if (!detailVisible) {
|
|
|
// 最后兜底:直接导航到该报价的 pricing 页(如果 URL 可推)
|
|
|
const url = page.url();
|
|
|
const match = url.match(/\/quotes?\/([^/?#]+)/);
|
|
|
if (match) {
|
|
|
const pricingUrl = `https://app.flockfreight.com/quotes/${match[1]}/pricing`;
|
|
|
console.warn(`[flock-hold] ${tier} 尝试直接导航 ${pricingUrl}`);
|
|
|
await page
|
|
|
.goto(pricingUrl, { waitUntil: "networkidle", timeout: 30_000 })
|
|
|
.catch(() => undefined);
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
// 无按钮时整卡可点
|
|
|
await card.click().catch(() => undefined);
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 兜底:按文案找卡片内按钮
|
|
|
const heading =
|
|
|
tier === "flock_direct"
|
|
|
? page.getByText(/FlockDirect/i).first()
|
|
|
: page.getByText(/^Standard$/i).first();
|
|
|
const section = heading.locator("xpath=ancestor::*[self::article or self::div][1]");
|
|
|
const btn = section
|
|
|
.getByRole("button", { name: /See Pricing Options/i })
|
|
|
.first();
|
|
|
if (await btn.isVisible().catch(() => false)) {
|
|
|
const enabledDeadline = Date.now() + 45_000;
|
|
|
while (Date.now() < enabledDeadline) {
|
|
|
if (await btn.isEnabled().catch(() => false)) break;
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
}
|
|
|
if (await btn.isEnabled().catch(() => false)) {
|
|
|
await btn.click();
|
|
|
} else {
|
|
|
await btn.click({ force: true }).catch(() => undefined);
|
|
|
}
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
return;
|
|
|
}
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
`未找到 ${tier} 的 See Pricing Options`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 是否已进入档内详情:灵活价页 或 Standard 承运商列表 */
|
|
|
export async function isFlockPricingDetailPage(page: Page): Promise<boolean> {
|
|
|
const flex = await page
|
|
|
.getByText(/2-day flexibility|1-day flexibility|No flexibility/i)
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
if (flex) return true;
|
|
|
const carrierHeading = await page
|
|
|
.getByText(/Select your preferred LTL carrier/i)
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
if (carrierHeading) return true;
|
|
|
const selectBtn = await page
|
|
|
.getByRole("button", { name: /^Select(\s*→)?$/i })
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
return selectBtn;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Standard LTL:刮取承运商列表(Carrier / Transit / Dates / Rate / Select)
|
|
|
*/
|
|
|
export async function scrapeStandardCarrierOptions(
|
|
|
page: Page,
|
|
|
): Promise<{ locators: Locator[]; options: FlockCarrierOption[] }> {
|
|
|
const locators: Locator[] = [];
|
|
|
const options: FlockCarrierOption[] = [];
|
|
|
const seen = new Set<string>();
|
|
|
|
|
|
const selectBtns = page.getByRole("button", { name: /^Select(\s*→)?$/i });
|
|
|
const count = await selectBtns.count().catch(() => 0);
|
|
|
for (let i = 0; i < count; i += 1) {
|
|
|
const btn = selectBtns.nth(i);
|
|
|
if (!(await btn.isVisible().catch(() => false))) continue;
|
|
|
const row = btn.locator(
|
|
|
"xpath=ancestor::*[.//text()[contains(.,'$')]][1]",
|
|
|
);
|
|
|
const text = ((await row.innerText().catch(() => "")) || "")
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim();
|
|
|
if (!text || text.length > 600) continue;
|
|
|
const rate = parseFlockRateUsd(text);
|
|
|
if (rate == null || rate < 50) continue;
|
|
|
const enriched = enrichCarrierOption({ label: text, rateUsd: rate });
|
|
|
if (!enriched) continue;
|
|
|
const dedupe = `${enriched.carrierName}|${enriched.rateUsd}`;
|
|
|
if (seen.has(dedupe)) continue;
|
|
|
seen.add(dedupe);
|
|
|
locators.push(btn);
|
|
|
options.push(enriched);
|
|
|
}
|
|
|
|
|
|
if (options.length === 0) {
|
|
|
const found = await page.evaluate(() => {
|
|
|
const out: { text: string }[] = [];
|
|
|
const nodes = Array.from(
|
|
|
document.querySelectorAll("tr, [role='row'], li, div, article"),
|
|
|
);
|
|
|
for (const el of nodes) {
|
|
|
const t = (el as HTMLElement).innerText?.replace(/\s+/g, " ").trim() ?? "";
|
|
|
if (t.length < 12 || t.length > 500) continue;
|
|
|
if (!/\$\s*[\d,]+/.test(t)) continue;
|
|
|
if (!/\bSelect\b/i.test(t)) continue;
|
|
|
if (/See Pricing Options|Quote shipment again/i.test(t)) continue;
|
|
|
out.push({ text: t });
|
|
|
if (out.length >= 12) break;
|
|
|
}
|
|
|
return out;
|
|
|
}).catch(() => [] as { text: string }[]);
|
|
|
|
|
|
for (const row of found) {
|
|
|
const rate = parseFlockRateUsd(row.text);
|
|
|
if (rate == null || rate < 50) continue;
|
|
|
const enriched = enrichCarrierOption({ label: row.text, rateUsd: rate });
|
|
|
if (!enriched) continue;
|
|
|
const dedupe = `${enriched.carrierName}|${enriched.rateUsd}`;
|
|
|
if (seen.has(dedupe)) continue;
|
|
|
seen.add(dedupe);
|
|
|
const btn = page
|
|
|
.getByRole("button", { name: /^Select(\s*→)?$/i })
|
|
|
.nth(options.length);
|
|
|
locators.push(btn);
|
|
|
options.push(enriched);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (options.length > 0) {
|
|
|
console.log(
|
|
|
`[flock-hold] scraped carriers n=${options.length} first=${options[0]?.carrierName}`,
|
|
|
);
|
|
|
}
|
|
|
return { locators, options };
|
|
|
}
|
|
|
|
|
|
export async function scrapeFlexibilityOptions(
|
|
|
page: Page,
|
|
|
): Promise<{ locators: Locator[]; options: FlockFlexibilityOption[] }> {
|
|
|
const locators: Locator[] = [];
|
|
|
const options: FlockFlexibilityOption[] = [];
|
|
|
|
|
|
// 官网常用自定义 radio(非 input[type=radio]);按档内文案行抓取
|
|
|
const labels = [
|
|
|
/2-day flexibility/i,
|
|
|
/1-day flexibility/i,
|
|
|
/No flexibility/i,
|
|
|
];
|
|
|
for (const labelRe of labels) {
|
|
|
const labelLoc = page.getByText(labelRe).first();
|
|
|
if (!(await labelLoc.isVisible().catch(() => false))) continue;
|
|
|
const row = labelLoc.locator(
|
|
|
"xpath=ancestor::*[.//text()[contains(.,'$')]][1]",
|
|
|
);
|
|
|
const text = (
|
|
|
(await row.innerText().catch(() => "")) ||
|
|
|
(await labelLoc.evaluate((el) => {
|
|
|
let cur: HTMLElement | null = el as HTMLElement;
|
|
|
for (let i = 0; i < 10 && cur; i += 1) {
|
|
|
const t = (cur.innerText || "").replace(/\s+/g, " ");
|
|
|
if (/\$\s*[\d,]+/.test(t) && /flexibility/i.test(t)) return t;
|
|
|
cur = cur.parentElement;
|
|
|
}
|
|
|
return (el.textContent || "").replace(/\s+/g, " ");
|
|
|
}).catch(() => ""))
|
|
|
)
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim();
|
|
|
if (/Save on average|LTL fees|Don't ship air/i.test(text)) continue;
|
|
|
const rate = parseFlockRateUsd(text);
|
|
|
if (rate == null || rate < 200) continue;
|
|
|
const enriched = enrichFlexibilityOption({
|
|
|
label: text.slice(0, 140),
|
|
|
rateUsd: rate,
|
|
|
});
|
|
|
if (!enriched) continue;
|
|
|
// 优先点左侧圆圈/整行
|
|
|
const clickTarget = row
|
|
|
.locator(
|
|
|
"[role='radio'], input[type='radio'], button, [class*='radio'], [class*='Radio']",
|
|
|
)
|
|
|
.first();
|
|
|
if (await clickTarget.isVisible().catch(() => false)) {
|
|
|
locators.push(clickTarget);
|
|
|
} else {
|
|
|
locators.push(labelLoc);
|
|
|
}
|
|
|
options.push(enriched);
|
|
|
}
|
|
|
|
|
|
// evaluate 兜底:扫整页含 flexibility+$ 的块
|
|
|
if (options.length === 0) {
|
|
|
const found = await page.evaluate(() => {
|
|
|
const out: Array<{ label: string; rateText: string }> = [];
|
|
|
const walk = document.body.querySelectorAll("div,li,label,tr,button");
|
|
|
for (const el of Array.from(walk)) {
|
|
|
const t = ((el as HTMLElement).innerText || "")
|
|
|
.replace(/\s+/g, " ")
|
|
|
.trim();
|
|
|
if (t.length < 10 || t.length > 280) continue;
|
|
|
if (!/flexibility/i.test(t)) continue;
|
|
|
if (!/\$\s*[\d,]+\.?\d*/.test(t)) continue;
|
|
|
if (/Save on average|LTL fees|Don't ship air/i.test(t)) continue;
|
|
|
out.push({ label: t.slice(0, 140), rateText: t });
|
|
|
}
|
|
|
// 去重:同价只留最短块
|
|
|
const byRate = new Map<string, { label: string; rateText: string }>();
|
|
|
for (const item of out) {
|
|
|
const m = item.rateText.replace(/,/g, "").match(/\$\s*([\d]+(?:\.\d{1,2})?)/);
|
|
|
if (!m) continue;
|
|
|
const prev = byRate.get(m[1]!);
|
|
|
if (!prev || item.label.length < prev.label.length) {
|
|
|
byRate.set(m[1]!, item);
|
|
|
}
|
|
|
}
|
|
|
return Array.from(byRate.values());
|
|
|
});
|
|
|
for (const item of found) {
|
|
|
const rate = parseFlockRateUsd(item.rateText);
|
|
|
if (rate == null || rate < 200) continue;
|
|
|
const enriched = enrichFlexibilityOption({
|
|
|
label: item.label,
|
|
|
rateUsd: rate,
|
|
|
});
|
|
|
if (!enriched) continue;
|
|
|
const loc = page.getByText(item.label.slice(0, 40), { exact: false }).first();
|
|
|
if (!(await loc.isVisible().catch(() => false))) continue;
|
|
|
locators.push(loc);
|
|
|
options.push(enriched);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return { locators, options };
|
|
|
}
|
|
|
|
|
|
async function selectFlexibilityRate(
|
|
|
page: Page,
|
|
|
preferredKey?: FlockFlexibilityKey | null,
|
|
|
): Promise<{ rateUsd: number; label: string; key: FlockFlexibilityKey }> {
|
|
|
// 等档内价表(Is your pickup date flexible?)
|
|
|
await page
|
|
|
.getByText(/Is your pickup date flexible|day flexibility|No flexibility/i)
|
|
|
.first()
|
|
|
.waitFor({ state: "visible", timeout: 20_000 })
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
const deadline = Date.now() + 25_000;
|
|
|
let scraped = await scrapeFlexibilityOptions(page);
|
|
|
while (scraped.options.length === 0 && Date.now() < deadline) {
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
scraped = await scrapeFlexibilityOptions(page);
|
|
|
}
|
|
|
const idx = pickFlexibilityOptionIndex(scraped.options, preferredKey);
|
|
|
if (idx < 0 || !scraped.locators[idx] || !scraped.options[idx]) {
|
|
|
const shot = await captureCheckoutScreenshot(page);
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
"未找到档内定价选项(flexibility rates) shot=" + shot,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
const target = scraped.locators[idx]!;
|
|
|
const opt = scraped.options[idx]!;
|
|
|
await target.click({ force: true }).catch(async () => {
|
|
|
await target.click();
|
|
|
});
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
console.log(
|
|
|
`[flock-checkout] selected rate=$${opt.rateUsd} key=${opt.key} label=${opt.label.slice(0, 60)}`,
|
|
|
);
|
|
|
return { rateUsd: opt.rateUsd, label: opt.label, key: opt.key };
|
|
|
}
|
|
|
|
|
|
async function selectCarrierRate(
|
|
|
page: Page,
|
|
|
preferredCarrier?: string | null,
|
|
|
): Promise<{ rateUsd: number; label: string; carrierName: string }> {
|
|
|
await page
|
|
|
.getByText(/Select your preferred LTL carrier/i)
|
|
|
.first()
|
|
|
.waitFor({ state: "visible", timeout: 15_000 })
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
const deadline = Date.now() + 25_000;
|
|
|
let scraped = await scrapeStandardCarrierOptions(page);
|
|
|
while (scraped.options.length === 0 && Date.now() < deadline) {
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
scraped = await scrapeStandardCarrierOptions(page);
|
|
|
}
|
|
|
const idx = pickCarrierOptionIndex(scraped.options, preferredCarrier);
|
|
|
if (idx < 0 || !scraped.locators[idx] || !scraped.options[idx]) {
|
|
|
const shot = await captureCheckoutScreenshot(page);
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
"未找到承运商报价选项 shot=" + shot,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
const target = scraped.locators[idx]!;
|
|
|
const opt = scraped.options[idx]!;
|
|
|
await target.click({ force: true }).catch(async () => {
|
|
|
await target.click();
|
|
|
});
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
console.log(
|
|
|
`[flock-checkout] selected carrier=${opt.carrierName} rate=$${opt.rateUsd}`,
|
|
|
);
|
|
|
return {
|
|
|
rateUsd: opt.rateUsd,
|
|
|
label: opt.label,
|
|
|
carrierName: opt.carrierName,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
async function selectLowestFlexibilityRate(
|
|
|
page: Page,
|
|
|
): Promise<{ rateUsd: number; label: string }> {
|
|
|
const picked = await selectFlexibilityRate(page, null);
|
|
|
return { rateUsd: picked.rateUsd, label: picked.label };
|
|
|
}
|
|
|
|
|
|
async function clickYesIWantThisRate(page: Page): Promise<void> {
|
|
|
const btn = page
|
|
|
.getByRole("button", { name: /Yes,\s*I want this rate/i })
|
|
|
.first();
|
|
|
// 选中后按钮才出现;多等一会
|
|
|
if (!(await btn.isVisible({ timeout: 20_000 }).catch(() => false))) {
|
|
|
const shot = await captureCheckoutScreenshot(page);
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
"未找到 Yes, I want this rate 按钮 shot=" + shot,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
const label = ((await btn.innerText().catch(() => "")) || "").trim();
|
|
|
if (isFlockCheckoutPaymentButtonLabel(label)) {
|
|
|
throw new RpaError("RPA_DATA_INVALID", "拒绝点击支付类按钮: " + label, {
|
|
|
retryable: false,
|
|
|
});
|
|
|
}
|
|
|
await btn.click();
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
}
|
|
|
|
|
|
async function ensureShipperRole(page: Page): Promise<void> {
|
|
|
const shipper = page
|
|
|
.getByRole("button", { name: /I am the shipper/i })
|
|
|
.or(page.getByText(/I am the shipper/i))
|
|
|
.first();
|
|
|
if (await shipper.isVisible().catch(() => false)) {
|
|
|
await shipper.click().catch(() => undefined);
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function tryUseBillingAddress(page: Page, sectionHint: RegExp): Promise<void> {
|
|
|
const section = page.getByText(sectionHint).first();
|
|
|
if (!(await section.isVisible().catch(() => false))) return;
|
|
|
const container = section.locator(
|
|
|
"xpath=ancestor::*[self::section or self::div][1]",
|
|
|
);
|
|
|
const btn = container
|
|
|
.getByRole("button", { name: /Use billing address/i })
|
|
|
.first();
|
|
|
if (await btn.isVisible().catch(() => false)) {
|
|
|
await btn.click().catch(() => undefined);
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function readInputValue(page: Page, name: RegExp): Promise<string> {
|
|
|
const loc = page.getByLabel(name).first();
|
|
|
if (!(await loc.isVisible().catch(() => false))) return "";
|
|
|
return (await loc.inputValue().catch(() => "")).trim();
|
|
|
}
|
|
|
|
|
|
async function setLabeledCheckbox(
|
|
|
page: Page,
|
|
|
name: RegExp,
|
|
|
checked: boolean,
|
|
|
): Promise<boolean> {
|
|
|
const box = page.getByRole("checkbox", { name }).first();
|
|
|
if (!(await box.isVisible({ timeout: 1_500 }).catch(() => false))) {
|
|
|
// 部分 UI 用 label+自定义控件
|
|
|
const label = page.getByText(name).first();
|
|
|
if (!(await label.isVisible().catch(() => false))) return false;
|
|
|
const nearby = label
|
|
|
.locator("xpath=ancestor::label[1]//input[@type='checkbox']")
|
|
|
.or(label.locator("xpath=preceding::input[@type='checkbox'][1]"))
|
|
|
.first();
|
|
|
if (!(await nearby.isVisible().catch(() => false))) {
|
|
|
// 点击文案切换
|
|
|
const isOn = /checked|true/i.test(
|
|
|
(await label.getAttribute("aria-checked").catch(() => "")) || "",
|
|
|
);
|
|
|
if (isOn !== checked) await label.click().catch(() => undefined);
|
|
|
return true;
|
|
|
}
|
|
|
const cur = await nearby.isChecked().catch(() => false);
|
|
|
if (cur !== checked) await nearby.click().catch(() => undefined);
|
|
|
return true;
|
|
|
}
|
|
|
const cur = await box.isChecked().catch(() => false);
|
|
|
if (cur !== checked) await box.click().catch(() => undefined);
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
async function fillShipmentDetailsOnly(
|
|
|
page: Page,
|
|
|
defaults: FlockCheckoutDetailsDefaults,
|
|
|
): Promise<void> {
|
|
|
await fillNmfcField(page, defaults.nmfc);
|
|
|
await fillField(page, [/PO Number/i, /^PO Number$/i], defaults.deliveryPo);
|
|
|
await fillField(page, [/BOL Remarks/i], defaults.bolRemarks);
|
|
|
if (defaults.declarationStatement.trim()) {
|
|
|
await fillField(
|
|
|
page,
|
|
|
[/Declaration Statement/i, /Declaration/i],
|
|
|
defaults.declarationStatement,
|
|
|
);
|
|
|
}
|
|
|
await fillField(page, [/Notes for Flock Freight/i], defaults.notes);
|
|
|
await setLabeledCheckbox(
|
|
|
page,
|
|
|
/Documentation Required for Pickup/i,
|
|
|
defaults.documentationRequired,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function fillLabeledIn(
|
|
|
root: Locator,
|
|
|
name: RegExp,
|
|
|
value: string,
|
|
|
): Promise<boolean> {
|
|
|
const candidates = [
|
|
|
root.getByLabel(name).first(),
|
|
|
root.getByRole("textbox", { name }).first(),
|
|
|
root.getByPlaceholder(name).first(),
|
|
|
];
|
|
|
for (const loc of candidates) {
|
|
|
if (!(await loc.isVisible({ timeout: 1_200 }).catch(() => false))) continue;
|
|
|
await loc.click({ clickCount: 3 }).catch(() => undefined);
|
|
|
await loc.fill("").catch(() => undefined);
|
|
|
await loc.fill(value).catch(async () => {
|
|
|
await loc.press("Control+a").catch(() => undefined);
|
|
|
await loc.type(value, { delay: 15 }).catch(() => undefined);
|
|
|
});
|
|
|
await loc.press("Tab").catch(() => undefined);
|
|
|
const got = await loc.inputValue().catch(() => "");
|
|
|
if (got.trim()) return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function fillCheckoutForm(
|
|
|
page: Page,
|
|
|
defaults: FlockCheckoutDetailsDefaults,
|
|
|
): Promise<void> {
|
|
|
await page
|
|
|
.getByText(/Pickup location|Delivery location|Shipment details|Your role/i)
|
|
|
.first()
|
|
|
.waitFor({ state: "visible", timeout: 30_000 })
|
|
|
.catch(() => undefined);
|
|
|
|
|
|
const hasNmfc = await page
|
|
|
.getByPlaceholder(/NMFC/i)
|
|
|
.or(page.getByLabel(/^NMFC$/i))
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
const hasPickup = await page
|
|
|
.getByText(/Pickup location/i)
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
const hasDelivery = await page
|
|
|
.getByText(/Delivery location/i)
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
|
|
|
if (hasNmfc && !hasPickup && !hasDelivery) {
|
|
|
console.log("[flock-checkout] page=Shipment details(仅 NMFC/PO/BOL)");
|
|
|
await fillShipmentDetailsOnly(page, defaults);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (hasPickup || (await page.getByText(/Your role/i).first().isVisible().catch(() => false))) {
|
|
|
await ensureShipperRole(page);
|
|
|
await page
|
|
|
.getByText(/AMERICAN EASTERN GRACE|card on file|Highview|Chino Hills/i)
|
|
|
.first()
|
|
|
.waitFor({ state: "visible", timeout: 20_000 })
|
|
|
.catch(() => undefined);
|
|
|
if (defaults.useBillingForPickup) {
|
|
|
await tryUseBillingAddress(page, /Pickup location/i);
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
}
|
|
|
|
|
|
const pickupAddr = await readInputValue(page, /Address 1/i);
|
|
|
const forceManualPickup =
|
|
|
defaults.useBillingForPickup === false || !pickupAddr;
|
|
|
if (forceManualPickup) {
|
|
|
await fillField(page, [/Company Name/i], defaults.pickupCompany);
|
|
|
await fillField(
|
|
|
page,
|
|
|
[/^Address 1/i, /Address 1 \*/i],
|
|
|
defaults.pickupAddress1,
|
|
|
);
|
|
|
await fillField(page, [/^Address 2/i, /Address 2/i], defaults.pickupAddress2);
|
|
|
await fillField(page, [/^City \*?$/i, /^City$/i], defaults.pickupCity);
|
|
|
await fillField(
|
|
|
page,
|
|
|
[/State\/Province/i, /^State \*?$/i],
|
|
|
defaults.pickupState,
|
|
|
);
|
|
|
await fillField(page, [/Zip Code/i], defaults.pickupZip);
|
|
|
} else {
|
|
|
// Address 2 仅精确匹配,避免误伤其它字段
|
|
|
await fillField(page, [/^Address 2/i, /Address 2/i], defaults.pickupAddress2);
|
|
|
}
|
|
|
await fillField(page, [/Pickup Contact Name/i], defaults.pickupContact);
|
|
|
await fillField(page, [/Pickup Contact Phone/i], defaults.pickupPhone);
|
|
|
await fillField(page, [/Pickup Contact Email/i], defaults.pickupEmail);
|
|
|
await selectComboboxOption(page, /^Open/i, defaults.pickupOpens);
|
|
|
await selectComboboxOption(page, /^Close/i, defaults.pickupCloses);
|
|
|
}
|
|
|
|
|
|
// Delivery:可能同页底部,也可能是下一步整页
|
|
|
if (hasDelivery || (await page.getByText(/Delivery location/i).first().isVisible().catch(() => false))) {
|
|
|
console.log("[flock-checkout] fill Delivery location");
|
|
|
const deliveryHeading = page.getByText(/Delivery location/i).first();
|
|
|
await deliveryHeading.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
if (defaults.useBillingForDelivery) {
|
|
|
await tryUseBillingAddress(page, /Delivery location/i);
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
}
|
|
|
const delSection = page.locator("body");
|
|
|
await fillLabeledIn(delSection, /Company Name/i, defaults.deliveryCompany);
|
|
|
const forceManualDelivery = defaults.useBillingForDelivery === false;
|
|
|
const addrOk = forceManualDelivery
|
|
|
? await fillLabeledIn(
|
|
|
delSection,
|
|
|
/Address 1/i,
|
|
|
defaults.deliveryAddress1,
|
|
|
)
|
|
|
: true;
|
|
|
if (!addrOk) {
|
|
|
// 地址联想:逐字输入后点首项
|
|
|
const addr = page.getByLabel(/Address 1/i).last();
|
|
|
if (await addr.isVisible().catch(() => false)) {
|
|
|
await addr.click({ clickCount: 3 }).catch(() => undefined);
|
|
|
await addr.fill("").catch(() => undefined);
|
|
|
await addr.type(defaults.deliveryAddress1, { delay: 40 }).catch(() => undefined);
|
|
|
await page.waitForTimeout(800);
|
|
|
const suggestion = page
|
|
|
.getByRole("option")
|
|
|
.or(page.locator("[class*='suggestion'], [class*='autocomplete'] li"))
|
|
|
.first();
|
|
|
if (await suggestion.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
|
|
await suggestion.click().catch(() => undefined);
|
|
|
} else {
|
|
|
await addr.press("Enter").catch(() => undefined);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
if (forceManualDelivery) {
|
|
|
await fillLabeledIn(delSection, /^Address 2/i, defaults.deliveryAddress2);
|
|
|
await fillLabeledIn(delSection, /^City/i, defaults.deliveryCity);
|
|
|
await fillLabeledIn(delSection, /State\/Province|^State/i, defaults.deliveryState);
|
|
|
await fillLabeledIn(delSection, /Zip Code/i, defaults.deliveryZip);
|
|
|
} else {
|
|
|
await fillLabeledIn(delSection, /^Address 2/i, defaults.deliveryAddress2);
|
|
|
}
|
|
|
// 联系人必须用 Delivery 前缀,避免命中 Pickup Contact
|
|
|
await page
|
|
|
.getByRole("textbox", { name: /Delivery Contact Name/i })
|
|
|
.first()
|
|
|
.fill(defaults.deliveryContact)
|
|
|
.catch(() => undefined);
|
|
|
await page
|
|
|
.getByLabel(/Delivery Contact Name/i)
|
|
|
.first()
|
|
|
.fill(defaults.deliveryContact)
|
|
|
.catch(() => undefined);
|
|
|
await page
|
|
|
.getByRole("textbox", { name: /Delivery Contact Phone/i })
|
|
|
.first()
|
|
|
.fill(defaults.deliveryPhone)
|
|
|
.catch(() => undefined);
|
|
|
await page
|
|
|
.getByLabel(/Delivery Contact Phone/i)
|
|
|
.first()
|
|
|
.fill(defaults.deliveryPhone)
|
|
|
.catch(() => undefined);
|
|
|
await page
|
|
|
.getByRole("textbox", { name: /Delivery Contact Email/i })
|
|
|
.first()
|
|
|
.fill(defaults.deliveryEmail)
|
|
|
.catch(() => undefined);
|
|
|
await page
|
|
|
.getByLabel(/Delivery Contact Email/i)
|
|
|
.first()
|
|
|
.fill(defaults.deliveryEmail)
|
|
|
.catch(() => undefined);
|
|
|
await selectComboboxOption(page, /Delivery.*Open|^Open/i, defaults.deliveryOpens);
|
|
|
await selectComboboxOption(page, /Delivery.*Close|^Close/i, defaults.deliveryCloses);
|
|
|
// 周末派送
|
|
|
const weekendYes = page.getByRole("button", { name: /^Yes$/i }).last();
|
|
|
const weekendNo = page.getByRole("button", { name: /^No$/i }).last();
|
|
|
if (defaults.weekendDelivery) {
|
|
|
if (await weekendYes.isVisible().catch(() => false)) {
|
|
|
await weekendYes.click().catch(() => undefined);
|
|
|
}
|
|
|
} else if (await weekendNo.isVisible().catch(() => false)) {
|
|
|
await weekendNo.click().catch(() => undefined);
|
|
|
}
|
|
|
await fillLabeledIn(delSection, /PO Number/i, defaults.deliveryPo);
|
|
|
await fillLabeledIn(delSection, /BOL Remarks/i, defaults.bolRemarks);
|
|
|
await fillLabeledIn(delSection, /Notes for Flock Freight/i, defaults.notes);
|
|
|
} else if (!hasPickup) {
|
|
|
await fillShipmentDetailsOnly(page, defaults);
|
|
|
}
|
|
|
|
|
|
// Shipment details 段(同页或独立页):NMFC / PO / BOL / Notes / Documentation
|
|
|
const shipmentHeading = page.getByText(/Shipment details/i).first();
|
|
|
if (await shipmentHeading.isVisible().catch(() => false)) {
|
|
|
await shipmentHeading.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
console.log("[flock-checkout] fill Shipment details extras");
|
|
|
await fillShipmentDetailsOnly(page, defaults);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export async function verifyFlockCheckoutDetailsFilled(
|
|
|
page: Page,
|
|
|
): Promise<string[]> {
|
|
|
const missing: string[] = [];
|
|
|
|
|
|
const hasNmfc = await page
|
|
|
.getByPlaceholder(/NMFC/i)
|
|
|
.or(page.getByLabel(/^NMFC$/i))
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
const hasPickupLoc = await page
|
|
|
.getByText(/Pickup location/i)
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
const hasDeliveryLoc = await page
|
|
|
.getByText(/Delivery location/i)
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
const shipmentVisible = await page
|
|
|
.getByText(/Shipment details/i)
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
|
|
|
if (hasDeliveryLoc) {
|
|
|
const addr = page.getByLabel(/Address 1/i).last();
|
|
|
if (await addr.isVisible().catch(() => false)) {
|
|
|
const v = await addr.inputValue().catch(() => "");
|
|
|
if (!v.trim()) missing.push("delivery_address");
|
|
|
}
|
|
|
const contact = page.getByLabel(/Delivery Contact Name/i).first();
|
|
|
if (await contact.isVisible().catch(() => false)) {
|
|
|
const v = await contact.inputValue().catch(() => "");
|
|
|
if (!v.trim()) missing.push("delivery_contact");
|
|
|
}
|
|
|
const phone = page.getByLabel(/Delivery Contact Phone/i).first();
|
|
|
if (await phone.isVisible().catch(() => false)) {
|
|
|
const v = await phone.inputValue().catch(() => "");
|
|
|
if (!v.trim()) missing.push("delivery_phone");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (hasPickupLoc) {
|
|
|
const phone = page.getByLabel(/Pickup Contact Phone/i).first();
|
|
|
if (await phone.isVisible().catch(() => false)) {
|
|
|
const v = await phone.inputValue().catch(() => "");
|
|
|
if (!v.trim()) missing.push("pickup_phone");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (shipmentVisible || hasNmfc) {
|
|
|
const nmfc = page
|
|
|
.getByLabel(/^NMFC$/i)
|
|
|
.or(page.getByPlaceholder(/NMFC/i))
|
|
|
.or(
|
|
|
page
|
|
|
.getByText(/^NMFC$/i)
|
|
|
.locator("xpath=following::input[1]"),
|
|
|
)
|
|
|
.first();
|
|
|
if (await nmfc.isVisible().catch(() => false)) {
|
|
|
const v = await nmfc.inputValue().catch(() => "");
|
|
|
if (!v.trim()) {
|
|
|
// FlockDirect 文案声明可不填;Next 已可点则记 soft,否则 hard
|
|
|
const nextBtn = page.getByRole("button", { name: /^Next$/i }).first();
|
|
|
const nextOk =
|
|
|
(await nextBtn.isVisible().catch(() => false)) &&
|
|
|
!(await nextBtn.isDisabled().catch(() => true));
|
|
|
const helper = await page
|
|
|
.getByText(/NMFC is not needed/i)
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
if (nextOk && helper) {
|
|
|
console.log(
|
|
|
"[flock-checkout] NMFC 空但 Next 可点且官网声明可不填 — soft skip",
|
|
|
);
|
|
|
} else {
|
|
|
missing.push("nmfc");
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
const po = page.getByLabel(/PO Number/i).first();
|
|
|
if (await po.isVisible().catch(() => false)) {
|
|
|
const v = await po.inputValue().catch(() => "");
|
|
|
if (!v.trim()) missing.push("po_number");
|
|
|
}
|
|
|
const bol = page.getByLabel(/BOL Remarks/i).first();
|
|
|
if (await bol.isVisible().catch(() => false)) {
|
|
|
const v = await bol.inputValue().catch(() => "");
|
|
|
if (!v.trim()) missing.push("bol_remarks");
|
|
|
}
|
|
|
const notes = page.getByLabel(/Notes for Flock Freight/i).first();
|
|
|
if (await notes.isVisible().catch(() => false)) {
|
|
|
const v = await notes.inputValue().catch(() => "");
|
|
|
if (!v.trim()) missing.push("notes");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// fillOnly 验收:Next 必须可点(证明表单齐,仍不点下单)
|
|
|
const nextBtn = page.getByRole("button", { name: /^Next$/i }).first();
|
|
|
if (await nextBtn.isVisible().catch(() => false)) {
|
|
|
const disabled = await nextBtn.isDisabled().catch(() => true);
|
|
|
if (disabled) missing.push("next_disabled");
|
|
|
}
|
|
|
|
|
|
// 仅红字「必填/非法」;忽略 helper
|
|
|
const errLoc = page.locator(
|
|
|
"text=/Address 1 is required|Contact Name is required|Contact Phone is required|does not match the postal code|does not service this state|Please enter a valid phone number/i",
|
|
|
);
|
|
|
const errCount = await errLoc.count().catch(() => 0);
|
|
|
for (let i = 0; i < Math.min(errCount, 6); i += 1) {
|
|
|
if (await errLoc.nth(i).isVisible().catch(() => false)) {
|
|
|
missing.push("form_validation");
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 误报收口:PO 已填且无「is required」红字 → 去掉 form_validation
|
|
|
if (missing.includes("form_validation")) {
|
|
|
const po = page.getByLabel(/PO Number/i).first();
|
|
|
const poVal = (await po.inputValue().catch(() => "")).trim();
|
|
|
const hardRequired = page.locator("text=/is required/i");
|
|
|
let hardVisible = false;
|
|
|
const hn = await hardRequired.count().catch(() => 0);
|
|
|
for (let i = 0; i < Math.min(hn, 8); i += 1) {
|
|
|
if (await hardRequired.nth(i).isVisible().catch(() => false)) {
|
|
|
hardVisible = true;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
if (poVal && !hardVisible) {
|
|
|
return missing.filter((m) => m !== "form_validation");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return missing;
|
|
|
}
|
|
|
|
|
|
async function clickNextIfSafe(page: Page): Promise<boolean> {
|
|
|
const btn = page.getByRole("button", { name: /^Next$/i }).first();
|
|
|
if (!(await btn.isVisible().catch(() => false))) return false;
|
|
|
if (await btn.isDisabled().catch(() => true)) return false;
|
|
|
const label = ((await btn.innerText().catch(() => "")) || "Next").trim();
|
|
|
if (isFlockCheckoutPaymentButtonLabel(label)) return false;
|
|
|
await btn.click();
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
/** 从档内灵活性价页回到 Direct/Standard 卡片列表 */
|
|
|
export async function returnToFlockTierPage(page: Page): Promise<void> {
|
|
|
const seePricing = page
|
|
|
.getByRole("button", { name: /See Pricing Options/i })
|
|
|
.first();
|
|
|
if (await seePricing.isVisible({ timeout: 1_500 }).catch(() => false)) {
|
|
|
return;
|
|
|
}
|
|
|
const backCandidates = [
|
|
|
page.getByRole("link", { name: /Go back to shipping options/i }).first(),
|
|
|
page.getByText(/Go back to shipping options/i).first(),
|
|
|
page.getByRole("button", { name: /^(Back|←)/i }).first(),
|
|
|
page.getByRole("link", { name: /^(Back|←)/i }).first(),
|
|
|
page.getByRole("button", { name: /Change (service|option|quote)/i }).first(),
|
|
|
page.locator("button").filter({ hasText: /^Back$/i }).first(),
|
|
|
];
|
|
|
for (const btn of backCandidates) {
|
|
|
if (await btn.isVisible().catch(() => false)) {
|
|
|
await btn.click().catch(() => undefined);
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
if (await seePricing.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
await page.goBack().catch(() => undefined);
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 刮取单档报价选项(灵活价优先,空则试承运商列表),并回到档位列表页
|
|
|
*/
|
|
|
export async function scrapeOneTierFlexibility(
|
|
|
page: Page,
|
|
|
tier: FlockCheckoutTier,
|
|
|
opts?: { scrapeDeadlineMs?: number },
|
|
|
): Promise<FlockFlexibilityOption[]> {
|
|
|
const result = await scrapeOneTierPricingOptions(page, tier, opts);
|
|
|
return result.options;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 刮取单档档内价:灵活价 或 Standard 承运商列表
|
|
|
*/
|
|
|
export async function scrapeOneTierPricingOptions(
|
|
|
page: Page,
|
|
|
tier: FlockCheckoutTier,
|
|
|
opts?: { scrapeDeadlineMs?: number },
|
|
|
): Promise<FlockTierPricingScrape> {
|
|
|
const scrapeDeadlineMs = opts?.scrapeDeadlineMs ?? 20_000;
|
|
|
const empty: FlockTierPricingScrape = {
|
|
|
options_type: "flexibility",
|
|
|
options: [],
|
|
|
carrier_options: [],
|
|
|
};
|
|
|
await returnToFlockTierPage(page);
|
|
|
try {
|
|
|
await clickSeePricingForTier(page, tier);
|
|
|
} catch (err) {
|
|
|
console.warn(
|
|
|
`[flock-hold] ${tier} See Pricing 失败:`,
|
|
|
err instanceof Error ? err.message : err,
|
|
|
);
|
|
|
await returnToFlockTierPage(page).catch(() => undefined);
|
|
|
return empty;
|
|
|
}
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
|
|
|
const deadline = Date.now() + scrapeDeadlineMs;
|
|
|
let scraped = await scrapeFlexibilityOptions(page);
|
|
|
while (scraped.options.length === 0 && Date.now() < deadline) {
|
|
|
// 已进入承运商页则不再空等 flex
|
|
|
if (await isFlockPricingDetailPage(page)) {
|
|
|
const carriers = await scrapeStandardCarrierOptions(page);
|
|
|
if (carriers.options.length > 0) {
|
|
|
console.log(
|
|
|
`[flock-hold] scraped ${tier} carriers=${carriers.options.length}`,
|
|
|
);
|
|
|
await returnToFlockTierPage(page);
|
|
|
return {
|
|
|
options_type: "carriers",
|
|
|
options: [],
|
|
|
carrier_options: carriers.options,
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
scraped = await scrapeFlexibilityOptions(page);
|
|
|
}
|
|
|
|
|
|
if (scraped.options.length === 0) {
|
|
|
console.warn(`[flock-hold] ${tier} 首次未刮到灵活价,尝试承运商列表`);
|
|
|
let carriers = await scrapeStandardCarrierOptions(page);
|
|
|
if (carriers.options.length === 0) {
|
|
|
await returnToFlockTierPage(page).catch(() => undefined);
|
|
|
await clickSeePricingForTier(page, tier).catch(() => undefined);
|
|
|
const retryDeadline = Date.now() + 10_000;
|
|
|
while (carriers.options.length === 0 && Date.now() < retryDeadline) {
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
carriers = await scrapeStandardCarrierOptions(page);
|
|
|
if (carriers.options.length === 0) {
|
|
|
scraped = await scrapeFlexibilityOptions(page);
|
|
|
if (scraped.options.length > 0) break;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
if (carriers.options.length > 0) {
|
|
|
console.log(
|
|
|
`[flock-hold] scraped ${tier} carriers=${carriers.options.length}`,
|
|
|
);
|
|
|
await returnToFlockTierPage(page);
|
|
|
return {
|
|
|
options_type: "carriers",
|
|
|
options: [],
|
|
|
carrier_options: carriers.options,
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (scraped.options.length > 0) {
|
|
|
console.log(
|
|
|
`[flock-hold] scraped ${tier} options=${scraped.options.length}`,
|
|
|
);
|
|
|
await returnToFlockTierPage(page);
|
|
|
return {
|
|
|
options_type: "flexibility",
|
|
|
options: scraped.options,
|
|
|
carrier_options: [],
|
|
|
};
|
|
|
}
|
|
|
|
|
|
console.warn(`[flock-hold] ${tier} 未刮到档内价`);
|
|
|
await returnToFlockTierPage(page);
|
|
|
return empty;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 探针/全量:同一会话内依次刮 Direct + Standard(灵活价或承运商)
|
|
|
*/
|
|
|
export async function scrapeBothTiersPricingOptions(page: Page): Promise<{
|
|
|
flexibilityByTier: Partial<
|
|
|
Record<FlockCheckoutTier, FlockFlexibilityOption[]>
|
|
|
>;
|
|
|
carriersByTier: Partial<Record<FlockCheckoutTier, FlockCarrierOption[]>>;
|
|
|
}> {
|
|
|
const flexibilityByTier: Partial<
|
|
|
Record<FlockCheckoutTier, FlockFlexibilityOption[]>
|
|
|
> = {};
|
|
|
const carriersByTier: Partial<
|
|
|
Record<FlockCheckoutTier, FlockCarrierOption[]>
|
|
|
> = {};
|
|
|
const tiers: FlockCheckoutTier[] = ["flock_direct", "standard"];
|
|
|
for (const tier of tiers) {
|
|
|
try {
|
|
|
const result = await scrapeOneTierPricingOptions(page, tier);
|
|
|
if (
|
|
|
result.options_type === "flexibility" &&
|
|
|
result.options.length > 0
|
|
|
) {
|
|
|
flexibilityByTier[tier] = result.options;
|
|
|
} else if (
|
|
|
result.options_type === "carriers" &&
|
|
|
result.carrier_options.length > 0
|
|
|
) {
|
|
|
carriersByTier[tier] = result.carrier_options;
|
|
|
}
|
|
|
} catch (err) {
|
|
|
console.warn(
|
|
|
`[flock-hold] scrape ${tier} failed:`,
|
|
|
err instanceof Error ? err.message : err,
|
|
|
);
|
|
|
await returnToFlockTierPage(page).catch(() => undefined);
|
|
|
}
|
|
|
}
|
|
|
return { flexibilityByTier, carriersByTier };
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* @deprecated 请用 scrapeBothTiersPricingOptions(含承运商)
|
|
|
*/
|
|
|
export async function scrapeBothTiersFlexibility(
|
|
|
page: Page,
|
|
|
): Promise<Partial<Record<FlockCheckoutTier, FlockFlexibilityOption[]>>> {
|
|
|
const { flexibilityByTier } = await scrapeBothTiersPricingOptions(page);
|
|
|
return flexibilityByTier;
|
|
|
}
|
|
|
|
|
|
async function ensureOnPricingPage(
|
|
|
page: Page,
|
|
|
input: FlockQuoteInput,
|
|
|
account: FlockTestAccount,
|
|
|
reference?: string | null,
|
|
|
): Promise<string | null> {
|
|
|
let ref = reference?.trim() || null;
|
|
|
|
|
|
// 驻留页已在 Direct/Standard 列表:勿重跑查价
|
|
|
const seePricing = page
|
|
|
.getByRole("button", { name: /See Pricing Options/i })
|
|
|
.first();
|
|
|
if (await seePricing.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
|
|
return ref;
|
|
|
}
|
|
|
if (await isFlockPricingOptionsPage(page)) {
|
|
|
return ref;
|
|
|
}
|
|
|
if (await isFlockCheckoutFormPage(page)) {
|
|
|
return ref;
|
|
|
}
|
|
|
|
|
|
if (ref) {
|
|
|
const url = `https://app.flockfreight.com/quote/${ref}`;
|
|
|
console.log(`[flock-checkout] try open reference ${url}`);
|
|
|
await gotoWithResilience(page, url).catch(() => undefined);
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
if (await isFlockPricingOptionsPage(page)) {
|
|
|
return ref;
|
|
|
}
|
|
|
if (await isFlockCheckoutFormPage(page)) {
|
|
|
return ref;
|
|
|
}
|
|
|
console.log("[flock-checkout] reference 页失效,回退重跑查价");
|
|
|
}
|
|
|
|
|
|
const opened = await openFlockLoggedInQuoteEntry(page);
|
|
|
if (!opened) {
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
"无法打开登录态查价入口以重跑结账",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const quoteResult = await runFlockQuoteOnPage(page, input, account);
|
|
|
if (!quoteResult.ok || quoteResult.quotes.length === 0) {
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
quoteResult.errorMessage ?? "结账前重跑查价失败",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
ref = quoteResult.reference ?? ref;
|
|
|
if (!(await isFlockPricingOptionsPage(page))) {
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
"查价后未进入 pricing options 页",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
return ref;
|
|
|
}
|
|
|
|
|
|
async function driveCheckoutFill(
|
|
|
page: Page,
|
|
|
opts: {
|
|
|
preferredTier: FlockCheckoutTier;
|
|
|
preferredFlexibility?: FlockFlexibilityKey | null;
|
|
|
preferredCarrier?: string | null;
|
|
|
input: FlockQuoteInput;
|
|
|
fillOnly: boolean;
|
|
|
details?: FlockCheckoutDetails;
|
|
|
reference?: string | null;
|
|
|
account: FlockTestAccount;
|
|
|
},
|
|
|
): Promise<FlockCheckoutProbeResult> {
|
|
|
const step = (name: string) =>
|
|
|
console.log(`[flock-checkout] step: ${name}`);
|
|
|
|
|
|
step("ensure pricing page");
|
|
|
const reference = await ensureOnPricingPage(
|
|
|
page,
|
|
|
opts.input,
|
|
|
opts.account,
|
|
|
opts.reference,
|
|
|
);
|
|
|
|
|
|
// 若已在填表页(罕见:reference 直达),跳过选价
|
|
|
let selectedTotal = 0;
|
|
|
if (!(await isFlockCheckoutFormPage(page))) {
|
|
|
step(`select tier ${opts.preferredTier}`);
|
|
|
await clickSeePricingForTier(page, opts.preferredTier);
|
|
|
await captureCheckoutScreenshot(page);
|
|
|
|
|
|
const wantCarrier = Boolean(opts.preferredCarrier?.trim());
|
|
|
const onCarrierPage = await page
|
|
|
.getByText(/Select your preferred LTL carrier/i)
|
|
|
.first()
|
|
|
.isVisible()
|
|
|
.catch(() => false);
|
|
|
|
|
|
if (wantCarrier || onCarrierPage) {
|
|
|
step(
|
|
|
opts.preferredCarrier
|
|
|
? `select carrier ${opts.preferredCarrier}`
|
|
|
: "select lowest carrier rate",
|
|
|
);
|
|
|
const picked = await selectCarrierRate(page, opts.preferredCarrier);
|
|
|
selectedTotal = picked.rateUsd;
|
|
|
await captureCheckoutScreenshot(page);
|
|
|
// 承运商 Select 通常直达填表;若仍出现 Yes 再点
|
|
|
const yesBtn = page
|
|
|
.getByRole("button", { name: /Yes,\s*I want this rate/i })
|
|
|
.first();
|
|
|
if (await yesBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
|
|
step("Yes, I want this rate");
|
|
|
await clickYesIWantThisRate(page);
|
|
|
await captureCheckoutScreenshot(page);
|
|
|
}
|
|
|
} else {
|
|
|
step(
|
|
|
opts.preferredFlexibility
|
|
|
? `select flexibility ${opts.preferredFlexibility}`
|
|
|
: "select lowest flexibility rate",
|
|
|
);
|
|
|
const picked = await selectFlexibilityRate(
|
|
|
page,
|
|
|
opts.preferredFlexibility,
|
|
|
);
|
|
|
selectedTotal = picked.rateUsd;
|
|
|
await captureCheckoutScreenshot(page);
|
|
|
|
|
|
step("Yes, I want this rate");
|
|
|
await clickYesIWantThisRate(page);
|
|
|
await captureCheckoutScreenshot(page);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const defaults = buildFlockCheckoutDefaults({
|
|
|
pickupZip: opts.input.pickupZip,
|
|
|
deliveryZip: opts.input.deliveryZip,
|
|
|
tag: `f${Date.now().toString(36).slice(-4)}`,
|
|
|
details: opts.details,
|
|
|
});
|
|
|
|
|
|
step("fill checkout form");
|
|
|
await fillCheckoutForm(page, defaults);
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
await page.keyboard.press("Tab").catch(() => undefined);
|
|
|
await page.waitForTimeout(PAUSE_SM);
|
|
|
|
|
|
let missing = await verifyFlockCheckoutDetailsFilled(page);
|
|
|
if (missing.length > 0) {
|
|
|
console.log(
|
|
|
"[flock-checkout] missing after first fill: " + missing.join(","),
|
|
|
);
|
|
|
await fillCheckoutForm(page, defaults);
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
await page.keyboard.press("Tab").catch(() => undefined);
|
|
|
// Next 偶发延迟启用:最多等 12s
|
|
|
const nextBtn = page.getByRole("button", { name: /^Next$/i }).first();
|
|
|
const deadline = Date.now() + 12_000;
|
|
|
while (Date.now() < deadline) {
|
|
|
if (
|
|
|
(await nextBtn.isVisible().catch(() => false)) &&
|
|
|
!(await nextBtn.isDisabled().catch(() => true))
|
|
|
) {
|
|
|
break;
|
|
|
}
|
|
|
await page.waitForTimeout(500);
|
|
|
}
|
|
|
missing = await verifyFlockCheckoutDetailsFilled(page);
|
|
|
}
|
|
|
console.log(
|
|
|
"[flock-checkout] verify missing=" +
|
|
|
(missing.length ? missing.join(",") : "(none)"),
|
|
|
);
|
|
|
|
|
|
if (opts.fillOnly) {
|
|
|
const shot = await captureCheckoutScreenshot(page);
|
|
|
if (missing.length > 0) {
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
"结账表单未全部填齐: " + missing.join(", ") + " shot=" + shot,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
console.log("[flock-checkout] reached details_filled shot=" + shot);
|
|
|
return {
|
|
|
stage: "details_filled",
|
|
|
preferredTier: opts.preferredTier,
|
|
|
selectedTotal,
|
|
|
screenshotPath: shot,
|
|
|
missingFields: [],
|
|
|
reference,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
step("Next (non-fillOnly)");
|
|
|
await clickNextIfSafe(page);
|
|
|
|
|
|
const deadline = Date.now() + 45_000;
|
|
|
while (Date.now() < deadline) {
|
|
|
const body = await page.locator("body").innerText().catch(() => "");
|
|
|
if (isFlockCheckoutPaymentPageText(body)) {
|
|
|
const shot = await captureCheckoutScreenshot(page);
|
|
|
return {
|
|
|
stage: "checkout_stop_before_payment",
|
|
|
preferredTier: opts.preferredTier,
|
|
|
selectedTotal,
|
|
|
screenshotPath: shot,
|
|
|
reference,
|
|
|
};
|
|
|
}
|
|
|
// 绝不点 Complete your order
|
|
|
const complete = page
|
|
|
.getByRole("button", { name: /Complete your order/i })
|
|
|
.first();
|
|
|
if (await complete.isVisible().catch(() => false)) {
|
|
|
const shot = await captureCheckoutScreenshot(page);
|
|
|
return {
|
|
|
stage: "checkout_stop_before_payment",
|
|
|
preferredTier: opts.preferredTier,
|
|
|
selectedTotal,
|
|
|
screenshotPath: shot,
|
|
|
reference,
|
|
|
};
|
|
|
}
|
|
|
const agree = page
|
|
|
.getByRole("button", { name: /^(Agree|I agree|Agree and continue)$/i })
|
|
|
.first();
|
|
|
if (await agree.isVisible().catch(() => false)) {
|
|
|
const label = ((await agree.innerText().catch(() => "")) || "").trim();
|
|
|
if (!isFlockCheckoutPaymentButtonLabel(label)) {
|
|
|
await agree.click();
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
const shot = await captureCheckoutScreenshot(page);
|
|
|
return {
|
|
|
stage: "agree",
|
|
|
preferredTier: opts.preferredTier,
|
|
|
selectedTotal,
|
|
|
screenshotPath: shot,
|
|
|
reference,
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
}
|
|
|
|
|
|
const shot = await captureCheckoutScreenshot(page);
|
|
|
throw new RpaError(
|
|
|
"PAGE_LOAD_TIMEOUT",
|
|
|
"结账等待 Agree/支付前页超时 shot=" + shot,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function isLoggedInUiReady(page: Page): Promise<boolean> {
|
|
|
const url = page.url().toLowerCase();
|
|
|
if (/\/login|sign-in|signin/.test(url)) return false;
|
|
|
const newQuote = page
|
|
|
.getByRole("button", { name: /New quote/i })
|
|
|
.or(page.getByTestId("header-new-quote-button"))
|
|
|
.first();
|
|
|
if (await newQuote.isVisible().catch(() => false)) return true;
|
|
|
if (/\/(home|dashboard|quote\/)/i.test(url)) {
|
|
|
const body = await page.locator("body").innerText().catch(() => "");
|
|
|
if (/New quote|Request Quote|Active Shipments|Quote History/i.test(body)) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 有头:等人肉登录成功(最长 timeoutMs) */
|
|
|
async function waitForManualFlockLogin(
|
|
|
page: Page,
|
|
|
context: import("playwright").BrowserContext,
|
|
|
timeoutMs = 5 * 60_000,
|
|
|
): Promise<boolean> {
|
|
|
const loginUrl = getFlockLoginUrl();
|
|
|
console.log(
|
|
|
`[flock-checkout] ★ 请在弹出浏览器中手动登录 Flock(${loginUrl}),登录成功后脚本自动继续;最长 ${Math.round(timeoutMs / 1000)}s`,
|
|
|
);
|
|
|
await gotoWithResilience(page, loginUrl).catch(() => undefined);
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
while (Date.now() < deadline) {
|
|
|
if (await isLoggedInUiReady(page)) {
|
|
|
const statePath = resolveStoragePath();
|
|
|
try {
|
|
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
|
await context.storageState({ path: statePath });
|
|
|
console.log(`[flock-checkout] 手动登录成功,已写回 ${statePath}`);
|
|
|
} catch {
|
|
|
console.log("[flock-checkout] 手动登录成功(storage 写回失败可忽略)");
|
|
|
}
|
|
|
return true;
|
|
|
}
|
|
|
await page.waitForTimeout(2_000);
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
function isPlaceholderLogin(): boolean {
|
|
|
const creds = getEffectiveFlockLogin();
|
|
|
const email = creds?.email?.toLowerCase() ?? "";
|
|
|
return (
|
|
|
process.env.PROBE_FLOCK_MANUAL_LOGIN === "1" ||
|
|
|
email.includes("storage-session") ||
|
|
|
email === "x" ||
|
|
|
!creds?.password ||
|
|
|
creds.password === "storage-session"
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 仅拉档内价:登录 → See Pricing Options → scrape 灵活价或承运商 → 不停 Yes
|
|
|
*/
|
|
|
export async function runFlockScrapePricingOptions(
|
|
|
input: FlockQuoteInput,
|
|
|
opts: {
|
|
|
preferredTier: FlockCheckoutTier;
|
|
|
reference?: string | null;
|
|
|
/** 首价 park 会话;有则接管驻留页刮档后重新 park */
|
|
|
quoteSessionId?: string | null;
|
|
|
},
|
|
|
): Promise<FlockTierPricingScrape & {
|
|
|
reference: string | null;
|
|
|
screenshotPath?: string;
|
|
|
}> {
|
|
|
if (!hasEffectiveFlockLogin()) {
|
|
|
throw new RpaError(
|
|
|
"PROVIDER_LOGIN_FAILED",
|
|
|
"runFlockScrapePricingOptions 需要 Flock 账密",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function scrapeDetailOrThrow(page: Page): Promise<FlockTierPricingScrape> {
|
|
|
const deadline = Date.now() + 25_000;
|
|
|
let scraped = await scrapeFlexibilityOptions(page);
|
|
|
while (scraped.options.length === 0 && Date.now() < deadline) {
|
|
|
const carriers = await scrapeStandardCarrierOptions(page);
|
|
|
if (carriers.options.length > 0) {
|
|
|
return {
|
|
|
options_type: "carriers",
|
|
|
options: [],
|
|
|
carrier_options: carriers.options,
|
|
|
};
|
|
|
}
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
scraped = await scrapeFlexibilityOptions(page);
|
|
|
}
|
|
|
if (scraped.options.length > 0) {
|
|
|
return {
|
|
|
options_type: "flexibility",
|
|
|
options: scraped.options,
|
|
|
carrier_options: [],
|
|
|
};
|
|
|
}
|
|
|
const carriers = await scrapeStandardCarrierOptions(page);
|
|
|
if (carriers.options.length > 0) {
|
|
|
return {
|
|
|
options_type: "carriers",
|
|
|
options: [],
|
|
|
carrier_options: carriers.options,
|
|
|
};
|
|
|
}
|
|
|
const shot = await captureCheckoutScreenshot(page);
|
|
|
throw new RpaError(
|
|
|
"RPA_DATA_INVALID",
|
|
|
"未刮到档内报价(灵活价/承运商) shot=" + shot,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const sessionId = opts.quoteSessionId?.trim();
|
|
|
if (sessionId) {
|
|
|
const { takeParkedQuoteSession, parkQuoteSession } = await import(
|
|
|
"@/workers/rpa/parked-quote-session"
|
|
|
);
|
|
|
const { FLOCK_HOLD_TOTAL_MS } = await import(
|
|
|
"@/lib/constants/flock-quote-hold"
|
|
|
);
|
|
|
const parked = await takeParkedQuoteSession(sessionId, { waitMs: 2_000 });
|
|
|
if (parked) {
|
|
|
const { page, context } = parked;
|
|
|
console.log(
|
|
|
`[flock-pricing-options] 使用驻留页 session=${sessionId.slice(0, 12)}… tier=${opts.preferredTier}`,
|
|
|
);
|
|
|
try {
|
|
|
let reference = opts.reference?.trim() || null;
|
|
|
if (!reference) {
|
|
|
reference = await ensureOnPricingPage(
|
|
|
page,
|
|
|
input,
|
|
|
resolveFlockQuoteAccount(input),
|
|
|
null,
|
|
|
);
|
|
|
} else {
|
|
|
await returnToFlockTierPage(page);
|
|
|
}
|
|
|
await clickSeePricingForTier(page, opts.preferredTier);
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
|
|
|
const detail = await scrapeDetailOrThrow(page);
|
|
|
await returnToFlockTierPage(page);
|
|
|
await parkQuoteSession(sessionId, page, context, {
|
|
|
ttlMs: FLOCK_HOLD_TOTAL_MS,
|
|
|
});
|
|
|
const shot = await captureCheckoutScreenshot(page);
|
|
|
return {
|
|
|
...detail,
|
|
|
reference,
|
|
|
screenshotPath: shot,
|
|
|
};
|
|
|
} catch (err) {
|
|
|
await returnToFlockTierPage(page).catch(() => undefined);
|
|
|
await parkQuoteSession(sessionId, page, context, {
|
|
|
ttlMs: FLOCK_HOLD_TOTAL_MS,
|
|
|
}).catch(() => undefined);
|
|
|
throw err;
|
|
|
}
|
|
|
}
|
|
|
console.warn(
|
|
|
`[flock-pricing-options] 驻留页不可用,回退冷启动 session=${sessionId.slice(0, 12)}…`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const headless = resolveRpaHeadless();
|
|
|
const slowMo = resolveRpaSlowMoMs();
|
|
|
const statePath = resolveStoragePath();
|
|
|
const account = resolveFlockQuoteAccount(input);
|
|
|
const placeholderLogin = isPlaceholderLogin();
|
|
|
const forceFresh = mustForceFlockAccountLogin() || !placeholderLogin;
|
|
|
const loadStorage = !forceFresh && fs.existsSync(statePath);
|
|
|
|
|
|
console.log(
|
|
|
`[flock-pricing-options] start headed=${!headless} tier=${opts.preferredTier} storage=${statePath}`,
|
|
|
);
|
|
|
|
|
|
const browser = await launchRpaBrowser({
|
|
|
headless,
|
|
|
...(slowMo !== undefined ? { slowMo } : {}),
|
|
|
});
|
|
|
let page: Page | null = null;
|
|
|
try {
|
|
|
const context = await createRpaBrowserContext(
|
|
|
browser,
|
|
|
loadStorage ? { storageState: statePath } : {},
|
|
|
);
|
|
|
page = await context.newPage();
|
|
|
await gotoWithResilience(
|
|
|
page,
|
|
|
"https://app.flockfreight.com/home",
|
|
|
).catch(() => undefined);
|
|
|
|
|
|
let loggedIn = !forceFresh && (await isLoggedInUiReady(page));
|
|
|
if (!loggedIn && !placeholderLogin) {
|
|
|
let login = await ensureFlockLoggedIn(page, context, {
|
|
|
forceFreshLogin: true,
|
|
|
});
|
|
|
if (!login.ok) {
|
|
|
login = await ensureFlockLoggedIn(page, context, {
|
|
|
reloginAttempt: true,
|
|
|
forceFreshLogin: true,
|
|
|
});
|
|
|
}
|
|
|
if (!login.ok) {
|
|
|
throw new RpaError(
|
|
|
"PROVIDER_LOGIN_FAILED",
|
|
|
"Flock 登录失败,无法拉取档内报价",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
loggedIn = true;
|
|
|
}
|
|
|
if (!loggedIn && placeholderLogin) {
|
|
|
const ok = await waitForManualFlockLogin(page, context);
|
|
|
if (!ok) {
|
|
|
throw new RpaError(
|
|
|
"PROVIDER_LOGIN_FAILED",
|
|
|
"等待手动登录超时",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const reference = await ensureOnPricingPage(
|
|
|
page,
|
|
|
input,
|
|
|
account,
|
|
|
opts.reference,
|
|
|
);
|
|
|
await clickSeePricingForTier(page, opts.preferredTier);
|
|
|
await page.waitForTimeout(PAUSE_MD);
|
|
|
|
|
|
const detail = await scrapeDetailOrThrow(page);
|
|
|
const shot = await captureCheckoutScreenshot(page);
|
|
|
return {
|
|
|
...detail,
|
|
|
reference,
|
|
|
screenshotPath: shot,
|
|
|
};
|
|
|
} finally {
|
|
|
await browser.close().catch(() => undefined);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 启动浏览器:登录 → 选档填表 → fillOnly 默认停
|
|
|
*/
|
|
|
export async function runFlockLoggedInCheckoutFill(
|
|
|
input: FlockQuoteInput,
|
|
|
opts: {
|
|
|
preferredTier: FlockCheckoutTier;
|
|
|
preferredFlexibility?: FlockFlexibilityKey | null;
|
|
|
preferredCarrier?: string | null;
|
|
|
fillOnly?: boolean;
|
|
|
reference?: string | null;
|
|
|
flockDetails?: FlockCheckoutDetails;
|
|
|
/** 首价保活会话:优先接管驻留页,避免二次开浏览器 */
|
|
|
quoteSessionId?: string | null;
|
|
|
},
|
|
|
): Promise<FlockCheckoutProbeResult> {
|
|
|
if (!hasEffectiveFlockLogin()) {
|
|
|
throw new RpaError(
|
|
|
"PROVIDER_LOGIN_FAILED",
|
|
|
"runFlockLoggedInCheckoutFill 需要 Flock 账密",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// 默认 fillOnly;opts.fillOnly 优先;探针可用 PROBE_FLOCK_FILL_ONLY=0 放开到 Agree
|
|
|
const fillOnly =
|
|
|
opts.fillOnly !== undefined
|
|
|
? opts.fillOnly
|
|
|
: process.env.PROBE_FLOCK_FILL_ONLY !== "0";
|
|
|
|
|
|
const account = resolveFlockQuoteAccount(input);
|
|
|
const sessionId = opts.quoteSessionId?.trim();
|
|
|
if (sessionId) {
|
|
|
const { takeParkedQuoteSession } = await import(
|
|
|
"@/workers/rpa/parked-quote-session"
|
|
|
);
|
|
|
const parked = await takeParkedQuoteSession(sessionId, { waitMs: 2_000 });
|
|
|
if (parked) {
|
|
|
console.log(
|
|
|
`[flock-checkout] 使用驻留页 session=${sessionId.slice(0, 12)}… tier=${opts.preferredTier}`,
|
|
|
);
|
|
|
try {
|
|
|
const result = await driveCheckoutFill(parked.page, {
|
|
|
preferredTier: opts.preferredTier,
|
|
|
preferredFlexibility: opts.preferredFlexibility,
|
|
|
preferredCarrier: opts.preferredCarrier,
|
|
|
input,
|
|
|
fillOnly,
|
|
|
details: opts.flockDetails,
|
|
|
reference: opts.reference,
|
|
|
account,
|
|
|
});
|
|
|
if (isRpaHeaded() && fillOnly) {
|
|
|
await parked.page.waitForTimeout(8_000).catch(() => undefined);
|
|
|
}
|
|
|
return result;
|
|
|
} finally {
|
|
|
await parked.page.close().catch(() => undefined);
|
|
|
await parked.context.close().catch(() => undefined);
|
|
|
}
|
|
|
}
|
|
|
console.warn(
|
|
|
`[flock-checkout] 驻留页不可用,回退冷启动 session=${sessionId.slice(0, 12)}…`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const headless = resolveRpaHeadless();
|
|
|
const slowMo = resolveRpaSlowMoMs();
|
|
|
const statePath = resolveStoragePath();
|
|
|
const placeholderLogin = isPlaceholderLogin();
|
|
|
const forceFresh = mustForceFlockAccountLogin() || !placeholderLogin;
|
|
|
// 真实账密:不吃过期 logged-in storage,避免伪登录打不开 New quote
|
|
|
const loadStorage = !forceFresh && fs.existsSync(statePath);
|
|
|
|
|
|
console.log(
|
|
|
`[flock-checkout] start headed=${!headless} fillOnly=${fillOnly} tier=${opts.preferredTier} storage=${statePath} manual=${placeholderLogin} forceLogin=${forceFresh}`,
|
|
|
);
|
|
|
|
|
|
const browser = await launchRpaBrowser({
|
|
|
headless,
|
|
|
...(slowMo !== undefined ? { slowMo } : {}),
|
|
|
});
|
|
|
let page: Page | null = null;
|
|
|
try {
|
|
|
const context = await createRpaBrowserContext(
|
|
|
browser,
|
|
|
loadStorage ? { storageState: statePath } : {},
|
|
|
);
|
|
|
page = await context.newPage();
|
|
|
|
|
|
await gotoWithResilience(
|
|
|
page,
|
|
|
"https://app.flockfreight.com/home",
|
|
|
).catch(() => undefined);
|
|
|
|
|
|
let loggedIn = !forceFresh && (await isLoggedInUiReady(page));
|
|
|
if (!loggedIn && !placeholderLogin) {
|
|
|
let login = await ensureFlockLoggedIn(page, context, {
|
|
|
forceFreshLogin: true,
|
|
|
});
|
|
|
if (!login.ok) {
|
|
|
login = await ensureFlockLoggedIn(page, context, {
|
|
|
reloginAttempt: true,
|
|
|
forceFreshLogin: true,
|
|
|
});
|
|
|
}
|
|
|
loggedIn = login.ok;
|
|
|
if (!login.ok) {
|
|
|
throw new RpaError(
|
|
|
"PROVIDER_LOGIN_FAILED",
|
|
|
login.errorMessage || "Flock 登录失败",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!loggedIn) {
|
|
|
if (headless) {
|
|
|
throw new RpaError(
|
|
|
"PROVIDER_LOGIN_FAILED",
|
|
|
"登录会话失效:无头模式无法手动登录,请配置 FLOCK_LOGIN_* 或 DIAG_HEADED=true",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
const ok = await waitForManualFlockLogin(page, context);
|
|
|
if (!ok) {
|
|
|
throw new RpaError(
|
|
|
"PROVIDER_LOGIN_FAILED",
|
|
|
"等待手动登录超时(5 分钟)",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const result = await driveCheckoutFill(page, {
|
|
|
preferredTier: opts.preferredTier,
|
|
|
preferredFlexibility: opts.preferredFlexibility,
|
|
|
preferredCarrier: opts.preferredCarrier,
|
|
|
input,
|
|
|
fillOnly,
|
|
|
details: opts.flockDetails,
|
|
|
reference: opts.reference,
|
|
|
account,
|
|
|
});
|
|
|
|
|
|
if (isRpaHeaded() && fillOnly) {
|
|
|
await page.waitForTimeout(8_000).catch(() => undefined);
|
|
|
}
|
|
|
return result;
|
|
|
} finally {
|
|
|
await browser.close().catch(() => undefined);
|
|
|
}
|
|
|
}
|