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, 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 { 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 { 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 { 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 { 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 { 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 { 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 { for (const root of browseRoots(page)) { const price = await readDominantQuotePrice(root); if (price != null) { return price; } } return null; } async function waitAxelAfterClick(page: Page): Promise { 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 { 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 { if (!(await isInlineQuotePanelVisible(page))) { return items; } const merged = new Map(); 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 { 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 []; }