|
|
#!/usr/bin/env tsx
|
|
|
/**
|
|
|
* 对比人工录制 vs 自动化:联想 DOM 定位诊断
|
|
|
* 输出:.rpa/suggestion-compare/report-{id}.json + 截图
|
|
|
*/
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
import { join } from "node:path";
|
|
|
import type { Page, Locator } from "playwright";
|
|
|
import { loadDevEnv } from "@/lib/dev-env";
|
|
|
import { COLLECT_SUGGESTION_MOUSE_TARGETS, COLLECT_WIDGET_SUGGESTION_LABELS } from "@/workers/rpa/kernel/browser-scripts";
|
|
|
import { collectSuggestionMouseTargets } from "@/workers/rpa/address-suggestion-mouse";
|
|
|
import {
|
|
|
collectAddressSuggestionLabels,
|
|
|
isAddressSuggestionDropdownOpen,
|
|
|
} from "@/workers/rpa/address-suggestions";
|
|
|
import {
|
|
|
isAddressPresentInWidget,
|
|
|
readWidgetCombinedText,
|
|
|
} from "@/workers/rpa/address-commit";
|
|
|
import { RPAContext } from "@/workers/rpa/kernel/context";
|
|
|
import {
|
|
|
stabilizeQuoteLandingPage,
|
|
|
waitForQuoterWidgetHydrated,
|
|
|
} from "@/workers/rpa/page-prep";
|
|
|
import { closeBrowser, openQuotePage } from "@/workers/rpa/session-manager";
|
|
|
import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter";
|
|
|
import { openAddressSide, visibleStreetInput } from "@/workers/rpa/address-side";
|
|
|
|
|
|
loadDevEnv();
|
|
|
|
|
|
process.env.RPA_HEADLESS = process.env.RPA_HEADLESS ?? "false";
|
|
|
process.env.RPA_KEEP_BROWSER_OPEN = "false";
|
|
|
|
|
|
const PICKUP = {
|
|
|
street: "1234 Warehouse Street",
|
|
|
city: "Los Angeles",
|
|
|
state: "CA",
|
|
|
mothershipDisplayLabel: "1234 Warehouse Street, Los Angeles, California, USA",
|
|
|
};
|
|
|
|
|
|
/** 人工录制用的 partial text(codegen 生成) */
|
|
|
const MANUAL_PROBES = {
|
|
|
pickupZh: "1234 Warehouse StreetLos",
|
|
|
pickupEn: "1234 Warehouse StreetLos",
|
|
|
deliveryZh: "600 East 7th StreetPomona,",
|
|
|
};
|
|
|
|
|
|
const DOM_PROBE_SCRIPT = `(streetNum) => {
|
|
|
var skipRe = /use my location|pick up from|deliver to|where to|freight details|search by address/i;
|
|
|
var out = [];
|
|
|
document.querySelectorAll("div, li, span, [role='option'], .pac-item").forEach(function(el) {
|
|
|
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") return;
|
|
|
var text = (el.innerText || "").replace(/\\s+/g, " ").trim();
|
|
|
if (!text || text.length < 8 || skipRe.test(text)) return;
|
|
|
if (streetNum && text.indexOf(streetNum) < 0) return;
|
|
|
var rect = el.getBoundingClientRect();
|
|
|
if (!rect.width || !rect.height || rect.width < 80 || rect.height < 16) return;
|
|
|
out.push({
|
|
|
tag: el.tagName,
|
|
|
className: (el.className || "").toString().slice(0, 60),
|
|
|
role: el.getAttribute("role"),
|
|
|
text: text.slice(0, 100),
|
|
|
x: Math.round(rect.left + rect.width / 2),
|
|
|
y: Math.round(rect.top + rect.height / 2),
|
|
|
w: Math.round(rect.width),
|
|
|
h: Math.round(rect.height),
|
|
|
});
|
|
|
});
|
|
|
out.sort(function(a, b) { return a.y - b.y || a.w * a.h - b.w * b.h; });
|
|
|
return out.slice(0, 30);
|
|
|
}`;
|
|
|
|
|
|
async function probeGetByText(page: Page, probe: string) {
|
|
|
const loc = page.getByText(probe, { exact: false }).first();
|
|
|
const visible = await loc.isVisible().catch(() => false);
|
|
|
const box = visible ? await loc.boundingBox().catch(() => null) : null;
|
|
|
const count = await page.getByText(probe, { exact: false }).count().catch(() => 0);
|
|
|
return { probe, visible, count, box };
|
|
|
}
|
|
|
|
|
|
async function snapshotPhase(
|
|
|
page: Page,
|
|
|
input: Locator,
|
|
|
widgetSelector: string,
|
|
|
outDir: string,
|
|
|
phase: string,
|
|
|
) {
|
|
|
const shot = join(outDir, `${phase}.png`);
|
|
|
await page.screenshot({ path: shot, fullPage: false }).catch(() => undefined);
|
|
|
|
|
|
const streetNum = PICKUP.street.match(/^\d+/)?.[0] ?? "";
|
|
|
const domHits = (await page.evaluate(DOM_PROBE_SCRIPT, streetNum)) as unknown[];
|
|
|
const widgetLabels = (await page.evaluate(COLLECT_WIDGET_SUGGESTION_LABELS).catch(
|
|
|
() => [],
|
|
|
)) as string[];
|
|
|
const mouseTargets = await collectSuggestionMouseTargets(page, input);
|
|
|
const autoLabels = await collectAddressSuggestionLabels(page, {
|
|
|
street: PICKUP.street,
|
|
|
city: PICKUP.city,
|
|
|
state: PICKUP.state,
|
|
|
});
|
|
|
const dropdownOpen = await isAddressSuggestionDropdownOpen(page);
|
|
|
const visualPresent = await isAddressPresentInWidget(page, widgetSelector, PICKUP);
|
|
|
const committedVisually = await import("@/workers/rpa/address-suggestion-mouse").then(
|
|
|
(m) => m.isAddressCommittedVisually(page, widgetSelector, PICKUP, input),
|
|
|
);
|
|
|
const widgetText = await readWidgetCombinedText(page, widgetSelector);
|
|
|
const inputValue = await input.inputValue().catch(() => "");
|
|
|
|
|
|
const manualProbes = await Promise.all([
|
|
|
probeGetByText(page, MANUAL_PROBES.pickupZh),
|
|
|
probeGetByText(page, MANUAL_PROBES.pickupEn),
|
|
|
]);
|
|
|
|
|
|
return {
|
|
|
phase,
|
|
|
screenshot: shot,
|
|
|
inputValue: inputValue.slice(0, 120),
|
|
|
dropdownOpen,
|
|
|
visualPresent,
|
|
|
committedVisually,
|
|
|
widgetTextPreview: widgetText.slice(0, 200),
|
|
|
collectWidgetLabels: widgetLabels,
|
|
|
collectAutoLabels: autoLabels,
|
|
|
mouseTargets,
|
|
|
domHits,
|
|
|
manualProbes,
|
|
|
listboxCount: await page.locator('[role="listbox"]').count(),
|
|
|
pacItemCount: await page.locator(".pac-item").count(),
|
|
|
pacVisible: await page.locator(".pac-container").first().isVisible().catch(() => false),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
async function main(): Promise<void> {
|
|
|
const runId = randomUUID().slice(0, 8);
|
|
|
const outDir = join(process.cwd(), ".rpa", "suggestion-compare", runId);
|
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
|
|
|
console.log(`[suggestion-compare] run=${runId} out=${outDir}`);
|
|
|
|
|
|
const opened = await openQuotePage(quotePageAdapter, false, undefined, {
|
|
|
freshCargoContext: true,
|
|
|
});
|
|
|
const ctx = RPAContext.fromSession(opened.page, opened.context);
|
|
|
const page = ctx.page;
|
|
|
const widgetSelector = ctx.selectors.resolve("RPA_SELECTOR_QUOTE_WIDGET");
|
|
|
|
|
|
await stabilizeQuoteLandingPage(ctx);
|
|
|
await waitForQuoterWidgetHydrated(page, 90_000);
|
|
|
|
|
|
const phases: unknown[] = [];
|
|
|
|
|
|
await openAddressSide(ctx, "pickup", widgetSelector);
|
|
|
const input = await visibleStreetInput(ctx, "pickup");
|
|
|
if (!input) {
|
|
|
throw new Error("提货输入框未找到");
|
|
|
}
|
|
|
|
|
|
phases.push(
|
|
|
await snapshotPhase(page, input, widgetSelector, outDir, "01-after-open-pickup"),
|
|
|
);
|
|
|
|
|
|
const query = `${PICKUP.street}, ${PICKUP.city}, California`;
|
|
|
await input.click({ force: true });
|
|
|
await input.fill(query);
|
|
|
await page.waitForTimeout(800);
|
|
|
|
|
|
phases.push(
|
|
|
await snapshotPhase(page, input, widgetSelector, outDir, "02-after-fill-800ms"),
|
|
|
);
|
|
|
|
|
|
await page.waitForTimeout(1200);
|
|
|
phases.push(
|
|
|
await snapshotPhase(page, input, widgetSelector, outDir, "03-after-fill-2s"),
|
|
|
);
|
|
|
|
|
|
// 尝试人工录制同款 getByText 点击
|
|
|
const manualProbe = MANUAL_PROBES.pickupEn;
|
|
|
const manualLoc = page.getByText(manualProbe, { exact: false }).first();
|
|
|
let manualClickResult: Record<string, unknown> = { attempted: false };
|
|
|
if (await manualLoc.isVisible().catch(() => false)) {
|
|
|
manualClickResult = { attempted: true, probe: manualProbe };
|
|
|
await manualLoc.click({ force: true, timeout: 3_000 }).catch((e) => {
|
|
|
manualClickResult.error = String(e);
|
|
|
});
|
|
|
await page.waitForTimeout(600);
|
|
|
phases.push(
|
|
|
await snapshotPhase(page, input, widgetSelector, outDir, "04-after-manual-getByText-click"),
|
|
|
);
|
|
|
manualClickResult.afterClick = phases[phases.length - 1];
|
|
|
} else {
|
|
|
manualClickResult = {
|
|
|
attempted: false,
|
|
|
probe: manualProbe,
|
|
|
reason: "getByText 不可见",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const report = {
|
|
|
runId,
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
manualRecording: {
|
|
|
file: ".rpa/recorded-mothership.ts",
|
|
|
pickupClick: "page.getByText('1234 Warehouse StreetLos').click()",
|
|
|
uiLocale: "zh-CN(用户系统浏览器)",
|
|
|
note: "RPA 使用 en-US,placeholder=Search by address",
|
|
|
},
|
|
|
automationEnv: {
|
|
|
locale: process.env.RPA_BROWSER_LOCALE ?? "en-US",
|
|
|
headless: process.env.RPA_HEADLESS,
|
|
|
},
|
|
|
phases,
|
|
|
manualClickResult,
|
|
|
diagnosis: [] as string[],
|
|
|
};
|
|
|
|
|
|
const p2 = phases[2] as { visualPresent?: boolean; dropdownOpen?: boolean; mouseTargets?: unknown[]; manualProbes?: { visible: boolean }[]; collectWidgetLabels?: string[] };
|
|
|
if (p2.visualPresent && p2.dropdownOpen) {
|
|
|
report.diagnosis.push(
|
|
|
"误判:isAddressPresentInWidget=true 但 dropdown 仍打开 → 输入框文字被当成已确认",
|
|
|
);
|
|
|
}
|
|
|
if ((p2.mouseTargets?.length ?? 0) === 0 && (p2.collectWidgetLabels?.length ?? 0) === 0) {
|
|
|
report.diagnosis.push(
|
|
|
"定位失败:COLLECT_WIDGET_SUGGESTION_LABELS 与 mouseTargets 均为空;listbox/PAC 不存在,styled-components 阈值或时机问题",
|
|
|
);
|
|
|
}
|
|
|
if (p2.manualProbes?.some((p) => p.visible)) {
|
|
|
report.diagnosis.push(
|
|
|
"人工录制 getByText 探针可见 → 应优先用 partial text 点击,而非 listbox/PAC 选择器",
|
|
|
);
|
|
|
} else {
|
|
|
report.diagnosis.push(
|
|
|
"人工录制 getByText 探针也不可见 → 联想尚未渲染或 DOM 结构与录制时不一致",
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const reportPath = join(outDir, "report.json");
|
|
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
|
|
|
|
console.log("\n════════ 联想定位对比 ════════");
|
|
|
for (const line of report.diagnosis) {
|
|
|
console.log(` • ${line}`);
|
|
|
}
|
|
|
console.log(`报告: ${reportPath}`);
|
|
|
|
|
|
await closeBrowser();
|
|
|
}
|
|
|
|
|
|
main().catch(async (err) => {
|
|
|
console.error("[suggestion-compare] 失败:", err);
|
|
|
await closeBrowser().catch(() => undefined);
|
|
|
process.exitCode = 1;
|
|
|
});
|