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.

230 lines
7.4 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 结果页报价提取 — 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 };
}