|
|
/**
|
|
|
* 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<number> {
|
|
|
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<boolean> {
|
|
|
const directSpecs = getFlockSelectorSpecs("FLOCK_SELECTOR_CARD_DIRECT");
|
|
|
const standardSpecs = getFlockSelectorSpecs("FLOCK_SELECTOR_CARD_STANDARD");
|
|
|
|
|
|
async function anyQuoteReady(): Promise<boolean> {
|
|
|
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 };
|
|
|
}
|