/** * Flock 结果页报价提取 — testId 来自 flock-manual-20260713-152051.js */ import type { Page } from "playwright"; import { getFlockSelectorSpecs } from "@/lib/rpa/flock-selector-specs"; import { firstVisibleFromSpecs } from "@/lib/rpa/selector-specs"; import { waitWithFlockStallWatch } from "@/workers/rpa/flock/stall-watch"; import { flockLocatorCount } from "@/workers/rpa/flock/page-alive"; import type { FlockQuoteLine } from "@/workers/rpa/flock/types"; export function parseFlockMoney(text: string): number | null { const m = text.replace(/,/g, "").match(/\$\s*([\d]+(?:\.\d{1,2})?)/); if (!m) return null; const n = Number(m[1]); return Number.isFinite(n) && n > 0 ? n : null; } export function parseFlockTransit(text: string): { transitDays: string; transitDescription: string; } { const weekend = text.match(/(\d+)\s*days?/i) || text.match(/(\d+\s*[-–]\s*\d+)\s*(?:business\s*)?days?/i) || text.match(/(\d+)\s*天/); const transitDays = weekend?.[1]?.replace(/\s+/g, "") ?? "—"; const transitDescription = weekend?.[0] ?? (/(business days only)/i.test(text) ? "工作日(预估)" : /incl\.\s*weekends/i.test(text) ? "含周末" : transitDays); return { transitDays, transitDescription }; } /** 官网「仍在找价」页(Hold tight…约 15 分钟)— 有参考号但无即时报价卡 */ export function isFlockRatesPendingText(body: string): boolean { return ( /Hold tight[\s\S]{0,80}still looking for the best rates/i.test(body) || /still looking for the best rates/i.test(body) || /rate for you in about\s*15\s*minutes/i.test(body) ); } export function extractFlockPendingReference(body: string): string | null { const fromTitle = body.match( /#?\s*([A-Z0-9]{2,}-[A-Z0-9]{2,})\s+pricing options/i, ); if (fromTitle?.[1]) return fromTitle[1]; const fromRef = body.match( /Reference\s*#?\s*([A-Z0-9]+-[A-Z0-9]+|FRG-[A-Z0-9]+)/i, ); return fromRef?.[1] ?? null; } export function formatFlockRatesPendingMessage(reference: string | null): string { const refPart = reference ? `,参考编号 ${reference}` : ""; return ( `FLOCK_RATES_PENDING:官网未给出即时报价,提示仍在寻找最优价格(约需 15 分钟后回查)${refPart}。` + "本单已结束,不会自动重跑查价。" ); } export async function detectFlockRatesPending(page: Page): Promise<{ pending: boolean; reference: string | null; errorMessage: string | null; }> { const body = await page.locator("body").innerText().catch(() => ""); if (!isFlockRatesPendingText(body)) { return { pending: false, reference: null, errorMessage: null }; } const reference = extractFlockPendingReference(body); return { pending: true, reference, errorMessage: formatFlockRatesPendingMessage(reference), }; } function lineFromCardText( tier: "flock_direct" | "standard", text: string, reference: string | null, ): FlockQuoteLine | null { const price = parseFlockMoney(text); if (price == null) return null; const { transitDays, transitDescription } = parseFlockTransit(text); const isDirect = tier === "flock_direct"; return { tier, serviceLevel: isDirect ? "guaranteed" : "standard", rateOption: isDirect ? "fastest" : "lowest", carrier: "Flock Freight", label: isDirect ? "FlockDirect®" : "Standard", totalUsd: price, transitDays, transitDescription, reference, }; } async function readQuotesGeneratedCount(page: Page): Promise { const body = await page.locator("body").innerText().catch(() => ""); const m = body.match(/Quotes generated:\s*(\d+)\s*of\s*(\d+)/i); return m ? Number(m[1]) : 0; } export async function waitForFlockQuoteResults( page: Page, timeoutMs = 20_000, ): Promise { const directSpecs = getFlockSelectorSpecs("FLOCK_SELECTOR_CARD_DIRECT"); const standardSpecs = getFlockSelectorSpecs("FLOCK_SELECTOR_CARD_STANDARD"); async function anyQuoteReady(): Promise { const generated = await readQuotesGeneratedCount(page); if (generated >= 1) { return true; } const direct = await firstVisibleFromSpecs(page, directSpecs); if (direct) { return true; } const standard = await firstVisibleFromSpecs(page, standardSpecs); if (standard) { return true; } const body = await page.locator("body").innerText().catch(() => ""); if (isFlockRatesPendingText(body)) { return true; } return /Explore your custom quote|Prices from\s*\$/i.test(body); } const { ok } = await waitWithFlockStallWatch( page, "等待任意报价结果", anyQuoteReady, { timeoutMs, pollMs: 500 }, ); return ok; } export async function extractFlockQuoteCards(page: Page): Promise<{ quotes: FlockQuoteLine[]; reference: string | null; }> { const bodyText = (await page.locator("body").innerText()).slice(0, 25_000); const refMatch = bodyText.match( /Reference\s*#?\s*([A-Z0-9]+-[A-Z0-9]+|FRG-[A-Z0-9]+)/i, ); const reference = refMatch?.[1] ?? null; const quotes: FlockQuoteLine[] = []; const directLoc = page.getByTestId("FlockDirect®-fulfillment-option").first(); const standardLoc = page.getByTestId("Standard-fulfillment-option").first(); if ((await flockLocatorCount(directLoc)) > 0) { await directLoc .getByText(/\$/) .first() .waitFor({ timeout: 15_000 }) .catch(() => undefined); const text = ((await directLoc.innerText().catch(() => "")) || "").trim(); const line = lineFromCardText("flock_direct", text, reference); if (line) quotes.push(line); } if ((await flockLocatorCount(standardLoc)) > 0) { await standardLoc .getByText(/\$/) .first() .waitFor({ timeout: 15_000 }) .catch(() => undefined); const text = ((await standardLoc.innerText().catch(() => "")) || "").trim(); const line = lineFromCardText("standard", text, reference); if (line) quotes.push(line); } if (quotes.length >= 2) { return { quotes, reference }; } const directFromSpecs = await firstVisibleFromSpecs( page, getFlockSelectorSpecs("FLOCK_SELECTOR_CARD_DIRECT"), ); if (directFromSpecs) { const text = ((await directFromSpecs.innerText().catch(() => "")) || "").trim(); const line = lineFromCardText("flock_direct", text, reference); if (line && !quotes.some((q) => q.tier === "flock_direct")) quotes.push(line); } const standardFromSpecs = await firstVisibleFromSpecs( page, getFlockSelectorSpecs("FLOCK_SELECTOR_CARD_STANDARD"), ); if (standardFromSpecs) { const text = ((await standardFromSpecs.innerText().catch(() => "")) || "").trim(); const line = lineFromCardText("standard", text, reference); if (line && !quotes.some((q) => q.tier === "standard")) quotes.push(line); } if (quotes.length >= 2) { return { quotes, reference }; } if (quotes.length < 2) { const chunks = bodyText.split(/\n{1,}/); for (const chunk of chunks) { if (/FlockDirect/i.test(chunk) && !quotes.some((q) => q.tier === "flock_direct")) { const line = lineFromCardText("flock_direct", chunk, reference); if (line) quotes.push(line); } if ( /\bStandard\b/i.test(chunk) && !quotes.some((q) => q.tier === "standard") ) { const line = lineFromCardText("standard", chunk, reference); if (line) quotes.push(line); } } } return { quotes, reference }; }