|
|
import { mkdirSync } from "node:fs";
|
|
|
import { join } from "node:path";
|
|
|
import type { Frame, Page, Response } from "playwright";
|
|
|
import type { QuoteItem } from "@/modules/providers/quote-provider";
|
|
|
import { compareUiTierSortOrder } from "@/lib/constants/quote";
|
|
|
import { isAxelQuoteResponseUrl } from "@/workers/rpa/quote-capture/axel-contract";
|
|
|
import { decodeAxelQuoteBodyLenient } from "@/workers/rpa/quote-capture/payload-decode";
|
|
|
import { isInlineQuotePanelVisible } from "@/workers/rpa/quote-capture/post-submit-navigation";
|
|
|
|
|
|
type BrowseRoot = Page | Frame;
|
|
|
|
|
|
type TruckTabSpec = {
|
|
|
serviceLevel: string;
|
|
|
tabLabels: string[];
|
|
|
};
|
|
|
|
|
|
const TRUCK_TABS: TruckTabSpec[] = [
|
|
|
{
|
|
|
serviceLevel: "standard",
|
|
|
tabLabels: ["Shared truck", "共享卡车"],
|
|
|
},
|
|
|
{
|
|
|
serviceLevel: "dedicated",
|
|
|
tabLabels: ["Dedicated truck", "专属卡车"],
|
|
|
},
|
|
|
];
|
|
|
|
|
|
const QUOTE_SCREENSHOT_DIR = join(process.cwd(), ".rpa", "probe-widget-la-pomona");
|
|
|
|
|
|
function tierKey(item: QuoteItem): string {
|
|
|
return `${item.serviceLevel}/${item.rateOption}`;
|
|
|
}
|
|
|
|
|
|
function sortItems(items: QuoteItem[]): QuoteItem[] {
|
|
|
return [...items].sort((a, b) =>
|
|
|
compareUiTierSortOrder(
|
|
|
a.serviceLevel,
|
|
|
a.rateOption,
|
|
|
b.serviceLevel,
|
|
|
b.rateOption,
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function toQuoteItem(serviceLevel: string, price: number): QuoteItem {
|
|
|
return {
|
|
|
serviceLevel,
|
|
|
rateOption: "bestValue",
|
|
|
carrier: "Mothership",
|
|
|
transitDays: "待确认",
|
|
|
transitDescription: "Widget Tab 读价",
|
|
|
rawFreight: price,
|
|
|
surcharges: 0,
|
|
|
rawTotal: price,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function isTransitEmpty(value: string | undefined): boolean {
|
|
|
return !value?.trim() || value.trim() === "—";
|
|
|
}
|
|
|
|
|
|
/** Tab 展示价覆盖金额,保留 axel 时效/承运商等元数据 */
|
|
|
export function mergeWidgetTabQuotePrice(
|
|
|
existing: QuoteItem | undefined,
|
|
|
tabItem: QuoteItem,
|
|
|
): QuoteItem {
|
|
|
if (!existing) {
|
|
|
return {
|
|
|
...tabItem,
|
|
|
transitDays: isTransitEmpty(tabItem.transitDays)
|
|
|
? "待确认"
|
|
|
: tabItem.transitDays,
|
|
|
transitDescription: isTransitEmpty(tabItem.transitDescription)
|
|
|
? "Widget Tab 读价"
|
|
|
: tabItem.transitDescription,
|
|
|
};
|
|
|
}
|
|
|
return {
|
|
|
...existing,
|
|
|
rawFreight: tabItem.rawFreight,
|
|
|
rawTotal: tabItem.rawTotal,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function tabNamePattern(label: string): RegExp {
|
|
|
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
return new RegExp(`^\\s*${escaped}\\s*$`, "i");
|
|
|
}
|
|
|
|
|
|
/** 探针/日志:档位 + 官网 Tab 文案 */
|
|
|
export function formatWidgetQuoteTierLabel(item: QuoteItem): string {
|
|
|
const base = `${item.serviceLevel}/${item.rateOption}`;
|
|
|
if (item.serviceLevel === "dedicated") {
|
|
|
return `${base} (Dedicated truck)`;
|
|
|
}
|
|
|
if (item.serviceLevel === "standard" && item.rateOption === "bestValue") {
|
|
|
return `${base} (Shared truck · Best value)`;
|
|
|
}
|
|
|
return base;
|
|
|
}
|
|
|
|
|
|
function browseRoots(page: Page): BrowseRoot[] {
|
|
|
const frames = page.frames();
|
|
|
const ordered: BrowseRoot[] = [];
|
|
|
for (const frame of frames) {
|
|
|
const url = frame.url();
|
|
|
if (/dashboard\.mothership|quoter|sign-up|mothership\.com/i.test(url)) {
|
|
|
ordered.push(frame);
|
|
|
}
|
|
|
}
|
|
|
if (!ordered.includes(page)) {
|
|
|
ordered.push(page);
|
|
|
}
|
|
|
for (const frame of frames) {
|
|
|
if (!ordered.includes(frame)) {
|
|
|
ordered.push(frame);
|
|
|
}
|
|
|
}
|
|
|
return ordered;
|
|
|
}
|
|
|
|
|
|
function mergeAxelBodies(
|
|
|
merged: Map<string, QuoteItem>,
|
|
|
bodies: unknown[],
|
|
|
): void {
|
|
|
for (const body of bodies) {
|
|
|
const decoded = decodeAxelQuoteBodyLenient(body);
|
|
|
if (!decoded) {
|
|
|
continue;
|
|
|
}
|
|
|
for (const item of decoded) {
|
|
|
merged.set(tierKey(item), item);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function captureWidgetQuoteScreenshot(
|
|
|
page: Page,
|
|
|
serviceLevel: string,
|
|
|
tag: string,
|
|
|
): Promise<string | null> {
|
|
|
try {
|
|
|
mkdirSync(QUOTE_SCREENSHOT_DIR, { recursive: true });
|
|
|
const file = join(
|
|
|
QUOTE_SCREENSHOT_DIR,
|
|
|
`widget-quote-${serviceLevel}-${tag}-${Date.now()}.png`,
|
|
|
);
|
|
|
await page.screenshot({ path: file, fullPage: false });
|
|
|
console.log(`[rpa] widget-quote: 截图 ${file}`);
|
|
|
return file;
|
|
|
} catch {
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** Playwright 点 Tab(button / tab / pill div) */
|
|
|
async function clickTruckTabPlaywright(
|
|
|
root: BrowseRoot,
|
|
|
label: string,
|
|
|
): Promise<boolean> {
|
|
|
const pattern = tabNamePattern(label);
|
|
|
const locators = [
|
|
|
root.getByRole("button", { name: pattern }),
|
|
|
root.getByRole("tab", { name: pattern }),
|
|
|
root
|
|
|
.locator('button, [role="button"], [role="tab"], div, span')
|
|
|
.filter({ hasText: pattern }),
|
|
|
root.getByText(pattern),
|
|
|
];
|
|
|
|
|
|
for (const loc of locators) {
|
|
|
const el = loc.first();
|
|
|
try {
|
|
|
if (!(await el.isVisible({ timeout: 1_000 }))) {
|
|
|
continue;
|
|
|
}
|
|
|
await el.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await el.click({ timeout: 8_000, force: true });
|
|
|
return true;
|
|
|
} catch {
|
|
|
/* 尝试下一选择器 */
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** evaluate 深搜 shadow DOM 点 Tab(Playwright 失败时回退) */
|
|
|
async function clickTruckTabEvaluate(
|
|
|
root: BrowseRoot,
|
|
|
label: string,
|
|
|
): Promise<boolean> {
|
|
|
return root
|
|
|
.evaluate((tabLabel) => {
|
|
|
const norm = (s: string) => s.replace(/\s+/g, " ").trim();
|
|
|
const target = norm(tabLabel);
|
|
|
const out: Element[] = [];
|
|
|
const visit = (node: Element) => {
|
|
|
out.push(node);
|
|
|
if (node.shadowRoot) {
|
|
|
for (const c of node.shadowRoot.querySelectorAll("*")) {
|
|
|
visit(c);
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
for (const el of document.querySelectorAll("*")) {
|
|
|
visit(el);
|
|
|
}
|
|
|
const candidates = out.filter((el) => norm(el.textContent || "") === target);
|
|
|
candidates.sort(
|
|
|
(a, b) => (a.textContent || "").length - (b.textContent || "").length,
|
|
|
);
|
|
|
for (const el of candidates) {
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
if (rect.width < 4 || rect.height < 4) {
|
|
|
continue;
|
|
|
}
|
|
|
(el as HTMLElement).click();
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}, label)
|
|
|
.catch(() => false);
|
|
|
}
|
|
|
|
|
|
async function clickTruckTabByMouse(page: Page, label: string): Promise<boolean> {
|
|
|
for (const root of browseRoots(page)) {
|
|
|
const coords = await root
|
|
|
.evaluate((tabLabel) => {
|
|
|
const norm = (s: string) => s.replace(/\s+/g, " ").trim();
|
|
|
const target = norm(tabLabel);
|
|
|
const out: Element[] = [];
|
|
|
const visit = (node: Element) => {
|
|
|
out.push(node);
|
|
|
if (node.shadowRoot) {
|
|
|
for (const c of node.shadowRoot.querySelectorAll("*")) {
|
|
|
visit(c);
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
for (const el of document.querySelectorAll("*")) {
|
|
|
visit(el);
|
|
|
}
|
|
|
const candidates = out.filter((el) => norm(el.textContent || "") === target);
|
|
|
candidates.sort(
|
|
|
(a, b) => (a.textContent || "").length - (b.textContent || "").length,
|
|
|
);
|
|
|
for (const el of candidates) {
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
if (rect.width < 4 || rect.height < 4) {
|
|
|
continue;
|
|
|
}
|
|
|
return {
|
|
|
x: rect.left + rect.width / 2,
|
|
|
y: rect.top + rect.height / 2,
|
|
|
};
|
|
|
}
|
|
|
return null;
|
|
|
}, label)
|
|
|
.catch(() => null);
|
|
|
if (!coords) {
|
|
|
continue;
|
|
|
}
|
|
|
await page.mouse.click(coords.x, coords.y);
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function clickTruckTab(page: Page, label: string): Promise<boolean> {
|
|
|
for (const root of browseRoots(page)) {
|
|
|
if (await clickTruckTabPlaywright(root, label)) {
|
|
|
return true;
|
|
|
}
|
|
|
if (await clickTruckTabEvaluate(root, label)) {
|
|
|
return true;
|
|
|
}
|
|
|
}
|
|
|
if (await clickTruckTabByMouse(page, label)) {
|
|
|
return true;
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
/** 报价卡片上最醒目 $ 金额(按可见区域面积) */
|
|
|
async function readDominantQuotePrice(root: BrowseRoot): Promise<number | null> {
|
|
|
const text = await root.locator("body").innerText().catch(() => "");
|
|
|
if (!/Choose your quote|选择您的报价/i.test(text)) {
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
const priceLoc = root.locator("text=/\\$\\s*[\\d,]+\\.\\d{2}/");
|
|
|
const count = await priceLoc.count().catch(() => 0);
|
|
|
let bestPrice: number | null = null;
|
|
|
let bestArea = 0;
|
|
|
|
|
|
for (let i = 0; i < count; i += 1) {
|
|
|
const el = priceLoc.nth(i);
|
|
|
if (!(await el.isVisible().catch(() => false))) {
|
|
|
continue;
|
|
|
}
|
|
|
const raw = await el.innerText().catch(() => "");
|
|
|
const m = raw.match(/\$\s*([\d,]+\.\d{2})/);
|
|
|
if (!m) {
|
|
|
continue;
|
|
|
}
|
|
|
const price = Number(m[1]!.replace(/,/g, ""));
|
|
|
if (!Number.isFinite(price) || price < 15) {
|
|
|
continue;
|
|
|
}
|
|
|
const box = await el.boundingBox();
|
|
|
if (!box) {
|
|
|
continue;
|
|
|
}
|
|
|
const area = box.width * box.height;
|
|
|
if (area > bestArea) {
|
|
|
bestArea = area;
|
|
|
bestPrice = price;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (bestPrice != null) {
|
|
|
return bestPrice;
|
|
|
}
|
|
|
|
|
|
const panelMatch = text.match(
|
|
|
/Choose your quote[\s\S]{0,800}?\$\s*([\d,]+\.\d{2})/i,
|
|
|
);
|
|
|
if (panelMatch) {
|
|
|
return Number(panelMatch[1]!.replace(/,/g, ""));
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
async function readQuotePriceFromAnyRoot(page: Page): Promise<number | null> {
|
|
|
for (const root of browseRoots(page)) {
|
|
|
const price = await readDominantQuotePrice(root);
|
|
|
if (price != null) {
|
|
|
return price;
|
|
|
}
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
async function waitAxelAfterClick(page: Page): Promise<QuoteItem[] | null> {
|
|
|
if (typeof page.waitForResponse !== "function") {
|
|
|
return null;
|
|
|
}
|
|
|
const resp: Response | null = await page
|
|
|
.waitForResponse(
|
|
|
(r) =>
|
|
|
isAxelQuoteResponseUrl(r.url()) &&
|
|
|
r.status() >= 200 &&
|
|
|
r.status() < 300,
|
|
|
{ timeout: 2_500 },
|
|
|
)
|
|
|
.catch(() => null);
|
|
|
if (!resp) {
|
|
|
return null;
|
|
|
}
|
|
|
const body = await resp.json().catch(() => null);
|
|
|
if (!body) {
|
|
|
return null;
|
|
|
}
|
|
|
return decodeAxelQuoteBodyLenient(body);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 依次点击 Shared truck / Dedicated truck,切换后读当前卡片价(+ 可选 axel)。
|
|
|
*/
|
|
|
async function captureAllTabsByClick(page: Page): Promise<QuoteItem[]> {
|
|
|
const captured: QuoteItem[] = [];
|
|
|
|
|
|
for (const tab of TRUCK_TABS) {
|
|
|
let clicked = false;
|
|
|
let clickedLabel = "";
|
|
|
|
|
|
for (const label of tab.tabLabels) {
|
|
|
const axelPromise = waitAxelAfterClick(page);
|
|
|
if (await clickTruckTab(page, label)) {
|
|
|
clicked = true;
|
|
|
clickedLabel = label;
|
|
|
console.log(`[rpa] widget-quote: 已点击 Tab «${label}»`);
|
|
|
void axelPromise.then((items) => {
|
|
|
if (items?.length) {
|
|
|
console.log(
|
|
|
`[rpa] widget-quote: Tab «${label}» 触发 axel tiers=${items.length}`,
|
|
|
);
|
|
|
}
|
|
|
});
|
|
|
break;
|
|
|
}
|
|
|
void axelPromise.catch(() => null);
|
|
|
}
|
|
|
|
|
|
if (!clicked) {
|
|
|
console.log(
|
|
|
`[rpa] widget-quote: 未能点击 ${tab.serviceLevel}(${tab.tabLabels.join("/")})`,
|
|
|
);
|
|
|
await captureWidgetQuoteScreenshot(page, tab.serviceLevel, "click-fail");
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
await page.waitForTimeout(900);
|
|
|
|
|
|
const price = await readQuotePriceFromAnyRoot(page);
|
|
|
if (price == null) {
|
|
|
console.log(
|
|
|
`[rpa] widget-quote: Tab «${clickedLabel}» 未读到 $ 金额,尝试截图`,
|
|
|
);
|
|
|
await captureWidgetQuoteScreenshot(page, tab.serviceLevel, "price-fail");
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
const item = toQuoteItem(tab.serviceLevel, price);
|
|
|
captured.push(item);
|
|
|
console.log(
|
|
|
`[rpa] widget-quote: Tab 读价 ${formatWidgetQuoteTierLabel(item)}=$${price}`,
|
|
|
);
|
|
|
await captureWidgetQuoteScreenshot(
|
|
|
page,
|
|
|
tab.serviceLevel,
|
|
|
`ok-${price.toFixed(2)}`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
return captured;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* sign-up-quote:必须点击 Shared / Dedicated truck 切换后读价。
|
|
|
* axel 首包常仅 standard;Tab 切换价为权威展示价。
|
|
|
*/
|
|
|
export async function supplementWidgetTruckTabQuotes(
|
|
|
page: Page,
|
|
|
items: QuoteItem[],
|
|
|
axelBodies: unknown[] = [],
|
|
|
): Promise<QuoteItem[]> {
|
|
|
if (!(await isInlineQuotePanelVisible(page))) {
|
|
|
return items;
|
|
|
}
|
|
|
|
|
|
const merged = new Map<string, QuoteItem>();
|
|
|
for (const item of items) {
|
|
|
merged.set(tierKey(item), item);
|
|
|
}
|
|
|
mergeAxelBodies(merged, axelBodies);
|
|
|
|
|
|
console.log(
|
|
|
"[rpa] widget-quote: 依次点击 Shared truck → Dedicated truck 读取报价",
|
|
|
);
|
|
|
|
|
|
const fromTabs = await captureAllTabsByClick(page);
|
|
|
for (const item of fromTabs) {
|
|
|
const key = tierKey(item);
|
|
|
merged.set(key, mergeWidgetTabQuotePrice(merged.get(key), item));
|
|
|
}
|
|
|
|
|
|
return sortItems([...merged.values()]);
|
|
|
}
|
|
|
|
|
|
/** @deprecated 测试兼容 */
|
|
|
export async function scrapeVisibleQuoteCardsForServiceLevel(
|
|
|
page: Page,
|
|
|
serviceLevel: string,
|
|
|
): Promise<QuoteItem[]> {
|
|
|
const tab = TRUCK_TABS.find((t) => t.serviceLevel === serviceLevel);
|
|
|
if (!tab) {
|
|
|
return [];
|
|
|
}
|
|
|
for (const label of tab.tabLabels) {
|
|
|
if (await clickTruckTab(page, label)) {
|
|
|
await page.waitForTimeout(600);
|
|
|
const price = await readQuotePriceFromAnyRoot(page);
|
|
|
return price != null ? [toQuoteItem(serviceLevel, price)] : [];
|
|
|
}
|
|
|
}
|
|
|
return [];
|
|
|
}
|