/** * 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 = { "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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(); 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(); 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const result = await scrapeOneTierPricingOptions(page, tier, opts); return result.options; } /** * 刮取单档档内价:灵活价 或 Standard 承运商列表 */ export async function scrapeOneTierPricingOptions( page: Page, tier: FlockCheckoutTier, opts?: { scrapeDeadlineMs?: number }, ): Promise { 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 >; carriersByTier: Partial>; }> { const flexibilityByTier: Partial< Record > = {}; const carriersByTier: Partial< Record > = {}; 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>> { const { flexibilityByTier } = await scrapeBothTiersPricingOptions(page); return flexibilityByTier; } async function ensureOnPricingPage( page: Page, input: FlockQuoteInput, account: FlockTestAccount, reference?: string | null, ): Promise { 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 { 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 { 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 { 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 { if (!hasEffectiveFlockLogin()) { throw new RpaError( "PROVIDER_LOGIN_FAILED", "runFlockScrapePricingOptions 需要 Flock 账密", { retryable: false }, ); } async function scrapeDetailOrThrow(page: Page): Promise { 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 { 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); } }