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.

479 lines
13 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.

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 点 Tabbutton / 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 点 TabPlaywright 失败时回退) */
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 首包常仅 standardTab 切换价为权威展示价。
*/
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 [];
}