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.

2161 lines
73 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 登录态结账:选档 → 最低价 → 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);
}
}