|
|
/**
|
|
|
* Priority1 可视全链回放 — 供 worker 生产与本地脚本共用
|
|
|
*/
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
import { join } from "node:path";
|
|
|
import { chromium, type Locator, type Page } from "playwright";
|
|
|
import { launchRpaBrowser } from "@/lib/rpa/browser-launch";
|
|
|
import { shouldKeepBrowserOpen } from "@/lib/rpa/env";
|
|
|
import {
|
|
|
extractPriority1VisibleQuotes,
|
|
|
formatPriority1QuoteOutcomeMessage,
|
|
|
formatPriority1QuotesTable,
|
|
|
isPriority1ManualFollowupPage,
|
|
|
PRIORITY1_MANUAL_FOLLOWUP_MESSAGE,
|
|
|
resolvePriority1QuoteBundles,
|
|
|
resolvePriority1SubmitOutcome,
|
|
|
type Priority1QuoteLine,
|
|
|
type Priority1QuoteOutcome,
|
|
|
} from "@/workers/rpa/priority1/quote-extract";
|
|
|
|
|
|
import {
|
|
|
parsePriority1DemoFromEnv,
|
|
|
PRIORITY1_CANONICAL_PATHS,
|
|
|
type Priority1DemoInput,
|
|
|
} from "@/workers/rpa/priority1/demo-input";
|
|
|
import {
|
|
|
EXPEDITED_TRAILER,
|
|
|
FTL_ADDITIONAL,
|
|
|
FTL_TRAILER,
|
|
|
expeditedLiftgateAvailable,
|
|
|
getShipmentConfig,
|
|
|
type ShipmentTypeConfig,
|
|
|
} from "@/workers/rpa/priority1/shipment-types";
|
|
|
import {
|
|
|
fillTemperatureRange,
|
|
|
forceFeatheryCheckbox,
|
|
|
freightClassOptionMatches,
|
|
|
openDeckSelectionOk,
|
|
|
readSelectVisibleText,
|
|
|
selectExpeditedLiftgate,
|
|
|
selectFreightClass,
|
|
|
selectFtlAdditionalService,
|
|
|
selectOpenDeckSubtype,
|
|
|
toggleLtlAccessorial,
|
|
|
waitForFeatheryCheckbox,
|
|
|
} from "@/workers/rpa/priority1/form-interactions";
|
|
|
import {
|
|
|
drainHumanAssistEvents,
|
|
|
formatHumanEventsAsPlaywright,
|
|
|
installHumanAssistRecorder,
|
|
|
type HumanAssistEvent,
|
|
|
type HumanAccessorialRecording,
|
|
|
} from "@/workers/rpa/priority1/human-assist-recorder";
|
|
|
import {
|
|
|
P08_LTL_ACCESSORIAL_LABELS,
|
|
|
} from "@/workers/rpa/priority1/demo-input";
|
|
|
|
|
|
const ENTRY_URL =
|
|
|
"https://www.priority1.com/instant-freight-quotes/#New%20Step%201";
|
|
|
|
|
|
function envHeadless(): boolean {
|
|
|
return process.env.RPA_HEADLESS === "true";
|
|
|
}
|
|
|
function envDwellMs(): number {
|
|
|
if (process.env.RPA_DWELL_MS != null) {
|
|
|
return Number(process.env.RPA_DWELL_MS);
|
|
|
}
|
|
|
return envHeadless() ? 0 : 2_000;
|
|
|
}
|
|
|
function envSlowMo(): number | undefined {
|
|
|
if (process.env.RPA_SLOW_MO_MS != null) {
|
|
|
const n = Number(process.env.RPA_SLOW_MO_MS);
|
|
|
return n > 0 ? n : undefined;
|
|
|
}
|
|
|
return envHeadless() ? undefined : 150;
|
|
|
}
|
|
|
const OBSERVE_POLL_MS = Number(process.env.RPA_OBSERVE_POLL_MS ?? 400);
|
|
|
const OBSERVE_MAX_MS = Number(process.env.RPA_OBSERVE_MAX_MS ?? 20_000);
|
|
|
|
|
|
const QUOTE_WAIT_MS = Number(process.env.RPA_QUOTE_WAIT_MS ?? 90_000);
|
|
|
const QUOTE_POLL_MS = Number(process.env.RPA_QUOTE_POLL_MS ?? 800);
|
|
|
const FEATHERY_CLICK = { noWaitAfter: true as const };
|
|
|
|
|
|
function shipmentCheckboxSelectors(cfg: ShipmentTypeConfig): string[] {
|
|
|
if (cfg.id === "ftl") return ["#cb_st_ftl", "#cb_st_ftl_sel"];
|
|
|
if (cfg.id === "expedited") return ["#cb_st_exp"];
|
|
|
return [cfg.checkboxSelector];
|
|
|
}
|
|
|
|
|
|
async function isShipmentCardSelected(
|
|
|
page: Page,
|
|
|
cfg: ShipmentTypeConfig,
|
|
|
): Promise<boolean> {
|
|
|
return page.evaluate(
|
|
|
function (args) {
|
|
|
for (let s = 0; s < args.selectors.length; s += 1) {
|
|
|
const cb = document.querySelector(
|
|
|
`#container-mh ${args.selectors[s]}`,
|
|
|
) as HTMLInputElement | null;
|
|
|
if (cb?.checked) return true;
|
|
|
}
|
|
|
|
|
|
const pattern = new RegExp(args.cardTextSource, "i");
|
|
|
const nodes = document.querySelectorAll("#container-mh *");
|
|
|
for (let i = 0; i < nodes.length; i += 1) {
|
|
|
const el = nodes[i];
|
|
|
const text = (el.textContent || "").trim();
|
|
|
if (!pattern.test(text) || text.length > 120) continue;
|
|
|
let cur: Element | null = el;
|
|
|
for (let d = 0; d < 5 && cur; d += 1, cur = cur.parentElement) {
|
|
|
const bg = getComputedStyle(cur).backgroundColor;
|
|
|
if (bg === "rgb(0, 0, 0)" || bg === "black") return true;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
},
|
|
|
{
|
|
|
selectors: shipmentCheckboxSelectors(cfg),
|
|
|
cardTextSource: cfg.cardText.source,
|
|
|
},
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function fillPhoneUs(ctx: VisualCtx, locator: Locator): Promise<void> {
|
|
|
await locator.scrollIntoViewIfNeeded();
|
|
|
await locator.click(FEATHERY_CLICK);
|
|
|
await ctx.page.waitForTimeout(400);
|
|
|
|
|
|
const countryBtn = ctx.page.locator(
|
|
|
".iti__selected-country, .iti__selected-flag, [aria-label*='country' i]",
|
|
|
);
|
|
|
if ((await countryBtn.count()) > 0) {
|
|
|
await countryBtn.first().click(FEATHERY_CLICK).catch(() => undefined);
|
|
|
await ctx.page.waitForTimeout(300);
|
|
|
await ctx.page
|
|
|
.locator(".iti__country-list, [role='listbox']")
|
|
|
.getByText(/United States|USA/i)
|
|
|
.first()
|
|
|
.click(FEATHERY_CLICK)
|
|
|
.catch(() => undefined);
|
|
|
await ctx.page.waitForTimeout(300);
|
|
|
}
|
|
|
|
|
|
await locator.fill("");
|
|
|
await locator.pressSequentially(ctx.demo.phone, {
|
|
|
delay: envHeadless() ? 0 : 80,
|
|
|
});
|
|
|
await locator.evaluate((el) => {
|
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
el.dispatchEvent(new Event("blur", { bubbles: true }));
|
|
|
});
|
|
|
}
|
|
|
|
|
|
type Observation = {
|
|
|
step: string;
|
|
|
label: string;
|
|
|
expected: string;
|
|
|
observed: string;
|
|
|
ok: boolean;
|
|
|
screenshot: string;
|
|
|
ts: number;
|
|
|
};
|
|
|
|
|
|
type VisualCtx = {
|
|
|
page: Page;
|
|
|
outDir: string;
|
|
|
demo: Priority1DemoInput;
|
|
|
observations: Observation[];
|
|
|
snapSeq: number;
|
|
|
networkBodies: string[];
|
|
|
quotes: Priority1QuoteLine[];
|
|
|
workerFast: boolean;
|
|
|
};
|
|
|
|
|
|
function attachPriority1QuoteNetworkCapture(
|
|
|
page: Page,
|
|
|
sink: string[],
|
|
|
): void {
|
|
|
page.on("response", async (res) => {
|
|
|
const url = res.url();
|
|
|
const isFeatheryQuote =
|
|
|
/feathery\.io/i.test(url) &&
|
|
|
/custom_request|panel\/custom|submit|form\/submit/i.test(url);
|
|
|
const isPriority1Api =
|
|
|
/priority1\.com/i.test(url) && /quote|rate|pricing|ltl/i.test(url);
|
|
|
if (!isFeatheryQuote && !isPriority1Api) {
|
|
|
return;
|
|
|
}
|
|
|
try {
|
|
|
const body = await res.text();
|
|
|
if (body.length > 20) sink.push(body);
|
|
|
} catch {
|
|
|
/* ignore */
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
type Priority1QuoteWaitResult = {
|
|
|
quotes: Priority1QuoteLine[];
|
|
|
outcome: Priority1QuoteOutcome;
|
|
|
message?: string;
|
|
|
};
|
|
|
|
|
|
async function extractAndSaveQuotes(
|
|
|
ctx: VisualCtx,
|
|
|
outcome: Priority1QuoteOutcome = "instant_quote",
|
|
|
message?: string,
|
|
|
): Promise<Priority1QuoteLine[]> {
|
|
|
const domText = await ctx.page.locator("body").innerText();
|
|
|
const bundles = resolvePriority1QuoteBundles(ctx.networkBodies, domText);
|
|
|
const quotes = bundles.visible;
|
|
|
ctx.quotes = quotes;
|
|
|
|
|
|
const path = join(ctx.outDir, "quotes.json");
|
|
|
writeFileSync(
|
|
|
path,
|
|
|
`${JSON.stringify(
|
|
|
{
|
|
|
count: quotes.length,
|
|
|
outcome,
|
|
|
message: message ?? null,
|
|
|
note: "visible=页面可见报价卡片;allApi=后台返回的全量承运商(仅供调试)",
|
|
|
quotes,
|
|
|
allApi: bundles.allApi,
|
|
|
},
|
|
|
null,
|
|
|
2,
|
|
|
)}\n`,
|
|
|
"utf8",
|
|
|
);
|
|
|
|
|
|
console.log("\n════════ 提取报价(页面可见)════════");
|
|
|
if (outcome === "manual_followup") {
|
|
|
console.log(formatPriority1QuoteOutcomeMessage(outcome, message));
|
|
|
} else {
|
|
|
console.log(formatPriority1QuotesTable(quotes));
|
|
|
}
|
|
|
if (bundles.allApi.length > quotes.length) {
|
|
|
console.log(
|
|
|
`(后台另有 ${bundles.allApi.length} 家承运商报价,未在页面展示,见 quotes.json → allApi)`,
|
|
|
);
|
|
|
}
|
|
|
console.log(`报价 JSON: ${path}`);
|
|
|
return quotes;
|
|
|
}
|
|
|
|
|
|
function digits(s: string): string {
|
|
|
return s.replace(/\D/g, "");
|
|
|
}
|
|
|
|
|
|
function normalizeNumeric(s: string): string {
|
|
|
return s.replace(/,/g, "").trim();
|
|
|
}
|
|
|
|
|
|
function valueMatches(
|
|
|
observed: string,
|
|
|
expected: string,
|
|
|
mode: "exact" | "contains" | "digits",
|
|
|
): boolean {
|
|
|
if (mode === "exact") {
|
|
|
if (observed === expected) return true;
|
|
|
return normalizeNumeric(observed) === normalizeNumeric(expected);
|
|
|
}
|
|
|
if (mode === "contains") return observed.includes(expected);
|
|
|
return digits(observed).includes(digits(expected));
|
|
|
}
|
|
|
|
|
|
async function dwell(page: Page, label: string, workerFast = false): Promise<void> {
|
|
|
if (workerFast || envDwellMs() <= 0) return;
|
|
|
console.log(`[观测] 停留 ${envDwellMs()}ms — ${label}`);
|
|
|
await page.waitForTimeout(envDwellMs());
|
|
|
}
|
|
|
|
|
|
async function highlight(locator: Locator): Promise<void> {
|
|
|
await locator.evaluate((el) => {
|
|
|
el.style.outline = "3px solid #f5c518";
|
|
|
el.style.outlineOffset = "2px";
|
|
|
}).catch(() => undefined);
|
|
|
}
|
|
|
|
|
|
async function snapLocator(
|
|
|
ctx: VisualCtx,
|
|
|
locator: Locator,
|
|
|
slug: string,
|
|
|
): Promise<string> {
|
|
|
if (ctx.workerFast) return "";
|
|
|
const n = String(++ctx.snapSeq).padStart(2, "0");
|
|
|
const path = join(ctx.outDir, `${n}-${slug}.png`);
|
|
|
await locator.screenshot({ path });
|
|
|
return path;
|
|
|
}
|
|
|
|
|
|
/** 轮询直到可见输入框出现期望值,期间对输入框截图 */
|
|
|
async function observeInput(
|
|
|
ctx: VisualCtx,
|
|
|
step: string,
|
|
|
label: string,
|
|
|
locator: Locator,
|
|
|
expected: string,
|
|
|
mode: "exact" | "contains" | "digits" = "exact",
|
|
|
): Promise<void> {
|
|
|
await locator.waitFor({ state: "visible" });
|
|
|
await locator.scrollIntoViewIfNeeded();
|
|
|
if (!ctx.workerFast) {
|
|
|
await highlight(locator);
|
|
|
}
|
|
|
|
|
|
const started = Date.now();
|
|
|
let observed = "";
|
|
|
let shot = "";
|
|
|
const observeMaxMs = ctx.workerFast
|
|
|
? Math.min(OBSERVE_MAX_MS, 6_000)
|
|
|
: OBSERVE_MAX_MS;
|
|
|
const observePollMs = ctx.workerFast ? 120 : OBSERVE_POLL_MS;
|
|
|
|
|
|
while (Date.now() - started < observeMaxMs) {
|
|
|
observed = await locator.inputValue().catch(() => "");
|
|
|
if (!ctx.workerFast) {
|
|
|
shot = await snapLocator(ctx, locator, `${slugify(label)}-observe`);
|
|
|
}
|
|
|
if (valueMatches(observed, expected, mode)) {
|
|
|
break;
|
|
|
}
|
|
|
await ctx.page.waitForTimeout(observePollMs);
|
|
|
}
|
|
|
|
|
|
const ok = valueMatches(observed, expected, mode);
|
|
|
ctx.observations.push({
|
|
|
step,
|
|
|
label,
|
|
|
expected,
|
|
|
observed: observed || "(空)",
|
|
|
ok,
|
|
|
screenshot: shot,
|
|
|
ts: Date.now(),
|
|
|
});
|
|
|
|
|
|
if (!ctx.workerFast) {
|
|
|
console.log(
|
|
|
`[观测] ${ok ? "✓" : "✗"} ${label} → 输入框可见值: "${observed || "(空)"}" (期望 ${mode}: "${expected}")`,
|
|
|
);
|
|
|
if (shot) {
|
|
|
console.log(`[观测] 截图: ${shot}`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!ok) {
|
|
|
throw new Error(
|
|
|
`${label} 输入框未出现期望值(可见值 "${observed || "(空)"}")— 请查看截图`,
|
|
|
);
|
|
|
}
|
|
|
await dwell(ctx.page, `${label} 已写入输入框`, ctx.workerFast);
|
|
|
}
|
|
|
|
|
|
async function typeIntoField(
|
|
|
ctx: VisualCtx,
|
|
|
locator: Locator,
|
|
|
value: string,
|
|
|
): Promise<void> {
|
|
|
await locator.scrollIntoViewIfNeeded();
|
|
|
await locator.click(FEATHERY_CLICK);
|
|
|
await locator.fill("");
|
|
|
await locator.pressSequentially(value, { delay: envHeadless() ? 0 : 60 });
|
|
|
await locator.evaluate((el, v) => {
|
|
|
const input = el as HTMLInputElement;
|
|
|
const setter = Object.getOwnPropertyDescriptor(
|
|
|
HTMLInputElement.prototype,
|
|
|
"value",
|
|
|
)?.set;
|
|
|
setter?.call(input, v);
|
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
input.dispatchEvent(new Event("blur", { bubbles: true }));
|
|
|
}, value);
|
|
|
}
|
|
|
|
|
|
async function observeSelect(
|
|
|
ctx: VisualCtx,
|
|
|
step: string,
|
|
|
label: string,
|
|
|
locator: Locator,
|
|
|
expected: string,
|
|
|
): Promise<void> {
|
|
|
await locator.waitFor({ state: "visible" });
|
|
|
await locator.scrollIntoViewIfNeeded();
|
|
|
await highlight(locator);
|
|
|
const shot = await snapLocator(ctx, locator, `${slugify(label)}-observe`);
|
|
|
const observed = await readSelectVisibleText(locator);
|
|
|
const ok =
|
|
|
observed === expected ||
|
|
|
observed.includes(expected) ||
|
|
|
expected.includes(observed) ||
|
|
|
(label === "Freight Class" && freightClassOptionMatches(observed, expected));
|
|
|
ctx.observations.push({
|
|
|
step,
|
|
|
label,
|
|
|
expected,
|
|
|
observed: observed || "(空)",
|
|
|
ok,
|
|
|
screenshot: shot,
|
|
|
ts: Date.now(),
|
|
|
});
|
|
|
console.log(`[观测] ${ok ? "✓" : "✗"} ${label} → 下拉可见: "${observed}"`);
|
|
|
console.log(`[观测] 截图: ${shot}`);
|
|
|
if (!ok) {
|
|
|
throw new Error(`${label} 下拉未选中期望值`);
|
|
|
}
|
|
|
await dwell(ctx.page, `${label} 已选中`);
|
|
|
}
|
|
|
|
|
|
async function observeShipmentType(ctx: VisualCtx): Promise<void> {
|
|
|
const cfg = getShipmentConfig(ctx.demo.shipmentType);
|
|
|
const area = ctx.page
|
|
|
.locator("#container-mh")
|
|
|
.filter({ hasText: /Shipment Type/i })
|
|
|
.first();
|
|
|
await area.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await snapLocator(ctx, area, "shipment-type-before");
|
|
|
|
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
|
if (await isShipmentCardSelected(ctx.page, cfg)) break;
|
|
|
for (const sel of shipmentCheckboxSelectors(cfg)) {
|
|
|
await forceFeatheryCheckbox(ctx.page, sel);
|
|
|
const cb = ctx.page.locator(`#container-mh ${sel}`).first();
|
|
|
if ((await cb.count()) > 0) {
|
|
|
await cb.click({ force: true, ...FEATHERY_CLICK }).catch(() => undefined);
|
|
|
}
|
|
|
}
|
|
|
await ctx.page.waitForTimeout(500);
|
|
|
await ctx.page
|
|
|
.locator("#container-mh")
|
|
|
.getByText(cfg.cardText)
|
|
|
.first()
|
|
|
.click(FEATHERY_CLICK)
|
|
|
.catch(() => undefined);
|
|
|
await ctx.page.waitForTimeout(600);
|
|
|
}
|
|
|
|
|
|
const ok = await isShipmentCardSelected(ctx.page, cfg);
|
|
|
const shot = await snapLocator(ctx, area, "shipment-type-after");
|
|
|
ctx.observations.push({
|
|
|
step: "step1",
|
|
|
label: `Shipment Type (${cfg.id})`,
|
|
|
expected: `${cfg.displayName} selected`,
|
|
|
observed: ok ? "selected" : "not selected",
|
|
|
ok,
|
|
|
screenshot: shot,
|
|
|
ts: Date.now(),
|
|
|
});
|
|
|
console.log(
|
|
|
`[观测] ${ok ? "✓" : "✗"} Shipment Type → ${cfg.displayName}${ok ? " 已选中" : " 未选中"}`,
|
|
|
);
|
|
|
if (!ok) {
|
|
|
throw new Error(`Shipment Type ${cfg.id} 未选中`);
|
|
|
}
|
|
|
await dwell(ctx.page, "Shipment Type 已选中");
|
|
|
}
|
|
|
|
|
|
async function isFeatheryCardSelected(page: Page, label: string): Promise<boolean> {
|
|
|
return page.evaluate(function (labelText) {
|
|
|
const nodes = document.querySelectorAll("#container-mh *");
|
|
|
for (let i = 0; i < nodes.length; i += 1) {
|
|
|
const el = nodes[i];
|
|
|
const text = (el.textContent || "").trim();
|
|
|
if (!text.startsWith(labelText) || text.length > 220) continue;
|
|
|
let cur: Element | null = el;
|
|
|
for (let d = 0; d < 6 && cur; d += 1, cur = cur.parentElement) {
|
|
|
const bg = getComputedStyle(cur).backgroundColor;
|
|
|
if (bg === "rgb(0, 0, 0)" || bg === "black") return true;
|
|
|
}
|
|
|
}
|
|
|
return false;
|
|
|
}, label);
|
|
|
}
|
|
|
|
|
|
/** 点击 Feathery 卡片(隐藏 checkbox + 黑底高亮观测) */
|
|
|
async function selectFeatheryCard(
|
|
|
ctx: VisualCtx,
|
|
|
step: string,
|
|
|
label: string,
|
|
|
selector: string,
|
|
|
sectionHint?: RegExp,
|
|
|
): Promise<void> {
|
|
|
const root = ctx.page.locator("#container-mh");
|
|
|
if (sectionHint) {
|
|
|
await root
|
|
|
.filter({ hasText: sectionHint })
|
|
|
.first()
|
|
|
.scrollIntoViewIfNeeded()
|
|
|
.catch(() => undefined);
|
|
|
}
|
|
|
|
|
|
const cb = root.locator(selector).first();
|
|
|
await cb.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
const labelRe = new RegExp(
|
|
|
`^${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
|
|
|
"i",
|
|
|
);
|
|
|
|
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
|
if ((await cb.count()) > 0) {
|
|
|
await forceFeatheryCheckbox(ctx.page, selector);
|
|
|
await cb.click({ force: true, ...FEATHERY_CLICK });
|
|
|
}
|
|
|
await root
|
|
|
.getByText(labelRe)
|
|
|
.first()
|
|
|
.click(FEATHERY_CLICK)
|
|
|
.catch(() => undefined);
|
|
|
await ctx.page.waitForTimeout(600);
|
|
|
|
|
|
const checked =
|
|
|
(await cb.count()) > 0 ? await cb.isChecked().catch(() => false) : false;
|
|
|
const visual = await isFeatheryCardSelected(ctx.page, label);
|
|
|
if (checked || visual) {
|
|
|
const shot = await snapLocator(ctx, root, slugify(label));
|
|
|
ctx.observations.push({
|
|
|
step,
|
|
|
label,
|
|
|
expected: "selected (dark card)",
|
|
|
observed: checked ? "checked" : "visual selected",
|
|
|
ok: true,
|
|
|
screenshot: shot,
|
|
|
ts: Date.now(),
|
|
|
});
|
|
|
console.log(`[观测] ✓ ${label} → 卡片已选中(黑底高亮)`);
|
|
|
await dwell(ctx.page, `${label} 已选中`);
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
throw new Error(`${label} 卡片未选中 — 请查看 Step 2 截图`);
|
|
|
}
|
|
|
|
|
|
async function fillFtlTrailerConditionals(ctx: VisualCtx, step: string): Promise<void> {
|
|
|
const demo = ctx.demo;
|
|
|
|
|
|
if (demo.ftlTrailer === "open_deck") {
|
|
|
const { locator: sel, selectedText } = await selectOpenDeckSubtype(
|
|
|
ctx.page,
|
|
|
demo.openDeckSubtype,
|
|
|
);
|
|
|
await sel.scrollIntoViewIfNeeded();
|
|
|
await highlight(sel);
|
|
|
const shot = await snapLocator(ctx, sel, "Open-Deck-Type-observe");
|
|
|
const observed = (await readSelectVisibleText(sel).catch(() => "")) || selectedText;
|
|
|
const ok = openDeckSelectionOk(observed, demo.openDeckSubtype);
|
|
|
ctx.observations.push({
|
|
|
step,
|
|
|
label: "Open Deck Type",
|
|
|
expected: demo.openDeckSubtype,
|
|
|
observed: observed || demo.openDeckSubtype,
|
|
|
ok,
|
|
|
screenshot: shot,
|
|
|
ts: Date.now(),
|
|
|
});
|
|
|
console.log(
|
|
|
`[观测] ${ok ? "✓" : "✗"} Open Deck Type → ${observed || demo.openDeckSubtype}`,
|
|
|
);
|
|
|
if (!ok) {
|
|
|
throw new Error("Open Deck Type 未选中 — 见 form-logic-map / form-interactions");
|
|
|
}
|
|
|
await dwell(ctx.page, "Open Deck Type 已选中");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (demo.ftlTrailer === "temperature_controlled") {
|
|
|
const { min: minLoc, max: maxLoc } = await fillTemperatureRange(
|
|
|
ctx.page,
|
|
|
demo.tempMinF,
|
|
|
demo.tempMaxF,
|
|
|
);
|
|
|
await observeInput(ctx, step, "Minimum Temperature", minLoc, demo.tempMinF);
|
|
|
await observeInput(ctx, step, "Maximum Temperature", maxLoc, demo.tempMaxF);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (demo.ftlTrailer === "ftl_expedited") {
|
|
|
const sub = EXPEDITED_TRAILER[demo.ftlExpeditedTrailer];
|
|
|
await ctx.page
|
|
|
.locator("#container-mh")
|
|
|
.getByText(/Select Expedited Trailer Type/i)
|
|
|
.first()
|
|
|
.waitFor({ state: "visible", timeout: 20_000 })
|
|
|
.catch(() => undefined);
|
|
|
await ctx.page.waitForTimeout(1500);
|
|
|
await waitForFeatheryCheckbox(ctx.page, sub.selector, 15_000).catch(() => undefined);
|
|
|
await selectFeatheryCard(
|
|
|
ctx,
|
|
|
step,
|
|
|
sub.label,
|
|
|
sub.selector,
|
|
|
/Select Expedited Trailer Type/i,
|
|
|
);
|
|
|
if (
|
|
|
demo.expeditedLiftgate &&
|
|
|
expeditedLiftgateAvailable(demo.ftlExpeditedTrailer)
|
|
|
) {
|
|
|
await selectExpeditedLiftgate(ctx.page);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function slugify(s: string): string {
|
|
|
return s.replace(/[^\w]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
|
}
|
|
|
|
|
|
function formLocator(ctx: VisualCtx, selector: string): Locator {
|
|
|
return ctx.page.locator(`#container-mh ${selector}`).first();
|
|
|
}
|
|
|
|
|
|
async function fillStep1(ctx: VisualCtx): Promise<void> {
|
|
|
const step = "step1";
|
|
|
await observeShipmentType(ctx);
|
|
|
|
|
|
const origin = formLocator(ctx, "#origin-zip");
|
|
|
await typeIntoField(ctx, origin, ctx.demo.originZip);
|
|
|
await observeInput(ctx, step, "Pickup ZIP Code", origin, ctx.demo.originZip);
|
|
|
|
|
|
const dest = formLocator(ctx, "#destination-zip");
|
|
|
await typeIntoField(ctx, dest, ctx.demo.destinationZip);
|
|
|
await observeInput(ctx, step, "Delivery ZIP Code", dest, ctx.demo.destinationZip);
|
|
|
|
|
|
const freq = formLocator(ctx, "#freight-shipment");
|
|
|
await freq.selectOption(ctx.demo.shipmentFrequency);
|
|
|
await observeSelect(ctx, step, "Shipments Per Month", freq, ctx.demo.shipmentFrequency);
|
|
|
|
|
|
const date = formLocator(ctx, "#pickup-date");
|
|
|
await typeIntoField(ctx, date, ctx.demo.pickupDate);
|
|
|
await observeInput(ctx, step, "Pickup Date", date, ctx.demo.pickupDate);
|
|
|
|
|
|
const email = formLocator(ctx, "#business-email");
|
|
|
await typeIntoField(ctx, email, ctx.demo.email);
|
|
|
await observeInput(ctx, step, "Business Email", email, ctx.demo.email);
|
|
|
|
|
|
const phone = formLocator(ctx, "#client-phone");
|
|
|
await fillPhoneUs(ctx, phone);
|
|
|
await observeInput(ctx, step, "Phone", phone, ctx.demo.phone, "digits");
|
|
|
}
|
|
|
|
|
|
async function waitForStep2Ready(
|
|
|
ctx: VisualCtx,
|
|
|
timeoutMs: number,
|
|
|
): Promise<boolean> {
|
|
|
const cfg = getShipmentConfig(ctx.demo.shipmentType);
|
|
|
const ready = formLocator(ctx, cfg.step2ReadySelector);
|
|
|
const started = Date.now();
|
|
|
while (Date.now() - started < timeoutMs) {
|
|
|
if (await ready.isVisible().catch(() => false)) {
|
|
|
await highlight(ready);
|
|
|
await snapLocator(ctx, ready, "step2-ready-visible");
|
|
|
console.log(`[观测] ✓ Step 2 ${cfg.step2ReadySelector} 已出现`);
|
|
|
await dwell(ctx.page, "已进入 Step 2");
|
|
|
return true;
|
|
|
}
|
|
|
await ctx.page.waitForTimeout(OBSERVE_POLL_MS);
|
|
|
}
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
async function submitStep1(ctx: VisualCtx): Promise<void> {
|
|
|
const cfg = getShipmentConfig(ctx.demo.shipmentType);
|
|
|
await ctx.page
|
|
|
.locator("#container-mh")
|
|
|
.click({ position: { x: 10, y: 10 } })
|
|
|
.catch(() => undefined);
|
|
|
await ctx.page.waitForTimeout(400);
|
|
|
|
|
|
const btn = ctx.page
|
|
|
.locator("#container-mh")
|
|
|
.getByRole("button", { name: /GET INSTANT QUOTES/i })
|
|
|
.first();
|
|
|
await highlight(btn);
|
|
|
await snapLocator(ctx, btn, "step1-submit-btn");
|
|
|
await btn.click(FEATHERY_CLICK);
|
|
|
await ctx.page.waitForTimeout(1_500);
|
|
|
await btn.click(FEATHERY_CLICK).catch(() => undefined);
|
|
|
|
|
|
if (await waitForStep2Ready(ctx, 30_000)) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
console.log("[观测] ⚠ 按钮提交未进入 Step 2,使用类型哈希回退");
|
|
|
await ctx.page.goto(
|
|
|
`https://www.priority1.com/instant-freight-quotes/${cfg.step2HashPath}`,
|
|
|
{ waitUntil: "domcontentloaded" },
|
|
|
);
|
|
|
await ctx.page.waitForTimeout(1500);
|
|
|
|
|
|
if (!(await waitForStep2Ready(ctx, 30_000))) {
|
|
|
throw new Error(`Step 2 ${cfg.step2ReadySelector} 未出现`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function fillStep2Ltl(
|
|
|
ctx: VisualCtx,
|
|
|
opts: { skipAccessorials?: boolean } = {},
|
|
|
): Promise<void> {
|
|
|
const step = "step2";
|
|
|
const fields: Array<{ label: string; sel: string; val: string }> = [
|
|
|
{ label: "Length", sel: "#length", val: ctx.demo.lengthIn },
|
|
|
{ label: "Width", sel: "#width", val: ctx.demo.widthIn },
|
|
|
{ label: "Height", sel: "#height", val: ctx.demo.heightIn },
|
|
|
{ label: "Total Weight", sel: "#weight", val: ctx.demo.weightLb },
|
|
|
{ label: "Number Of Pallets", sel: "#number-of-pallets", val: ctx.demo.palletCount },
|
|
|
];
|
|
|
|
|
|
for (const f of fields) {
|
|
|
const loc = formLocator(ctx, f.sel);
|
|
|
await typeIntoField(ctx, loc, f.val);
|
|
|
await observeInput(ctx, step, f.label, loc, f.val);
|
|
|
}
|
|
|
|
|
|
const packaging = formLocator(ctx, "#freight-package, #packaging-type, #packaging").filter({
|
|
|
has: ctx.page.locator('option:has-text("Pallet")'),
|
|
|
});
|
|
|
if ((await packaging.count()) > 0) {
|
|
|
await packaging.first().selectOption({ label: ctx.demo.packaging }).catch(() =>
|
|
|
packaging.first().selectOption(ctx.demo.packaging),
|
|
|
);
|
|
|
await observeSelect(ctx, step, "Packaging Type", packaging.first(), ctx.demo.packaging);
|
|
|
}
|
|
|
|
|
|
const freightClass = formLocator(ctx, "#freight-class, #freight-type");
|
|
|
if (await freightClass.count()) {
|
|
|
await ctx.page.waitForTimeout(300);
|
|
|
const fc = await selectFreightClass(ctx.page, ctx.demo.freightClass);
|
|
|
await observeSelect(ctx, step, "Freight Class", fc, ctx.demo.freightClass);
|
|
|
}
|
|
|
|
|
|
const pickupLoc = formLocator(ctx, "#PickupLocation");
|
|
|
if (await pickupLoc.count()) {
|
|
|
await pickupLoc.selectOption(ctx.demo.pickupLocation);
|
|
|
await observeSelect(ctx, step, "Pickup Location Type", pickupLoc, ctx.demo.pickupLocation);
|
|
|
}
|
|
|
|
|
|
const dropLoc = formLocator(ctx, "#DropoffLocation");
|
|
|
if (await dropLoc.count()) {
|
|
|
await dropLoc.selectOption(ctx.demo.deliveryLocation);
|
|
|
await observeSelect(ctx, step, "Delivery Location Type", dropLoc, ctx.demo.deliveryLocation);
|
|
|
}
|
|
|
|
|
|
if (!opts.skipAccessorials) {
|
|
|
await ctx.page.waitForTimeout(600);
|
|
|
for (const label of [...ctx.demo.pickupServices, ...ctx.demo.deliveryServices]) {
|
|
|
const checked = await toggleLtlAccessorial(ctx.page, label);
|
|
|
const root = ctx.page.locator("#container-mh");
|
|
|
const shot = await snapLocator(ctx, root, slugify(label)).catch(() =>
|
|
|
join(ctx.outDir, `${slugify(label)}.png`),
|
|
|
);
|
|
|
ctx.observations.push({
|
|
|
step,
|
|
|
label,
|
|
|
expected: "checked",
|
|
|
observed: checked ? "checked" : "unchecked",
|
|
|
ok: checked,
|
|
|
screenshot: shot,
|
|
|
ts: Date.now(),
|
|
|
});
|
|
|
console.log(`[观测] ${checked ? "✓" : "✗"} ${label} → ${checked ? "已勾选" : "未勾选"}`);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function fillStep2Ftl(
|
|
|
ctx: VisualCtx,
|
|
|
soft = false,
|
|
|
opts: { skipAdditionalServices?: boolean; stopAfterTrailer?: boolean } = {},
|
|
|
): Promise<void> {
|
|
|
const step = "step2";
|
|
|
const trailer = FTL_TRAILER[ctx.demo.ftlTrailer];
|
|
|
|
|
|
const runSoft = async (label: string, fn: () => Promise<void>) => {
|
|
|
try {
|
|
|
await fn();
|
|
|
} catch (e) {
|
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
|
console.log(`[human-assist] ⚠ 自动步骤未成功: ${label} — ${msg}`);
|
|
|
if (!soft) throw e;
|
|
|
}
|
|
|
};
|
|
|
|
|
|
await runSoft(`拖车 ${trailer.label}`, () =>
|
|
|
selectFeatheryCard(ctx, step, trailer.label, trailer.selector, /Select Trailer Type/i),
|
|
|
);
|
|
|
await runSoft("拖车条件字段", () => fillFtlTrailerConditionals(ctx, step));
|
|
|
|
|
|
if (opts.stopAfterTrailer) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (!opts.skipAdditionalServices) {
|
|
|
for (const svc of ctx.demo.ftlAdditionalServices) {
|
|
|
const meta = FTL_ADDITIONAL[svc];
|
|
|
await runSoft(`附加服务 ${meta.label}`, async () => {
|
|
|
const ok = await selectFtlAdditionalService(ctx.page, meta.selector, meta.label);
|
|
|
const shot = await snapLocator(ctx, ctx.page.locator("#container-mh"), slugify(meta.label));
|
|
|
ctx.observations.push({
|
|
|
step,
|
|
|
label: meta.label,
|
|
|
expected: "selected (Additional Services)",
|
|
|
observed: ok ? "checked" : "unchecked",
|
|
|
ok,
|
|
|
screenshot: shot,
|
|
|
ts: Date.now(),
|
|
|
});
|
|
|
console.log(`[观测] ${ok ? "✓" : "✗"} ${meta.label} → ${ok ? "附加服务已勾选" : "未勾选"}`);
|
|
|
if (!ok) throw new Error(`${meta.label} 未勾选`);
|
|
|
await dwell(ctx.page, `${meta.label} 已选中`);
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
await runSoft("What are you shipping?", async () => {
|
|
|
const commodity = formLocator(ctx, "#Commodity");
|
|
|
await typeIntoField(ctx, commodity, ctx.demo.commodity);
|
|
|
await observeInput(ctx, step, "What are you shipping?", commodity, ctx.demo.commodity);
|
|
|
});
|
|
|
|
|
|
await runSoft("Total Weight", async () => {
|
|
|
const weight = formLocator(ctx, "#weight");
|
|
|
await typeIntoField(ctx, weight, ctx.demo.weightLb);
|
|
|
await observeInput(ctx, step, "Total Weight", weight, ctx.demo.weightLb);
|
|
|
});
|
|
|
|
|
|
if (ctx.demo.ftlTrailer !== "ftl_expedited") {
|
|
|
await runSoft("Number Of Pallets", async () => {
|
|
|
const pallets = formLocator(ctx, "#Pallets");
|
|
|
if (!(await pallets.count())) return;
|
|
|
await typeIntoField(ctx, pallets, ctx.demo.palletCount);
|
|
|
await observeInput(ctx, step, "Number Of Pallets", pallets, ctx.demo.palletCount);
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function fillStep2Expedited(ctx: VisualCtx): Promise<void> {
|
|
|
const step = "step2";
|
|
|
const demo = ctx.demo;
|
|
|
const trailer = EXPEDITED_TRAILER[demo.expeditedTrailer];
|
|
|
await selectFeatheryCard(
|
|
|
ctx,
|
|
|
step,
|
|
|
trailer.label,
|
|
|
trailer.selector,
|
|
|
/Select Trailer Type|Sprinter Van/i,
|
|
|
);
|
|
|
|
|
|
if (demo.expeditedLiftgate && expeditedLiftgateAvailable(demo.expeditedTrailer)) {
|
|
|
await selectExpeditedLiftgate(ctx.page);
|
|
|
}
|
|
|
|
|
|
const commodity = formLocator(ctx, "#Commodity");
|
|
|
await typeIntoField(ctx, commodity, ctx.demo.commodity);
|
|
|
await observeInput(ctx, step, "Commodity", commodity, ctx.demo.commodity);
|
|
|
|
|
|
const weight = formLocator(ctx, "#weight");
|
|
|
await typeIntoField(ctx, weight, ctx.demo.weightLb);
|
|
|
await observeInput(ctx, step, "Total Weight", weight, ctx.demo.weightLb);
|
|
|
}
|
|
|
|
|
|
async function fillStep2(
|
|
|
ctx: VisualCtx,
|
|
|
soft = false,
|
|
|
stepOpts: { skipAccessorials?: boolean; skipFtlAdditionalServices?: boolean } = {},
|
|
|
): Promise<void> {
|
|
|
if (ctx.demo.shipmentType === "ftl") {
|
|
|
await fillStep2Ftl(ctx, soft, {
|
|
|
skipAdditionalServices: stepOpts.skipFtlAdditionalServices,
|
|
|
stopAfterTrailer: stepOpts.skipFtlAdditionalServices,
|
|
|
});
|
|
|
return;
|
|
|
}
|
|
|
if (ctx.demo.shipmentType === "expedited") {
|
|
|
await fillStep2Expedited(ctx);
|
|
|
return;
|
|
|
}
|
|
|
await fillStep2Ltl(ctx, stepOpts);
|
|
|
}
|
|
|
|
|
|
async function waitForQuotes(ctx: VisualCtx): Promise<Priority1QuoteWaitResult> {
|
|
|
const pollMs = ctx.workerFast ? Math.min(QUOTE_POLL_MS, 600) : QUOTE_POLL_MS;
|
|
|
console.log(`[观测] 等待报价加载(最长 ${QUOTE_WAIT_MS / 1000}s,轮询 ${pollMs}ms)…`);
|
|
|
const started = Date.now();
|
|
|
while (Date.now() - started < QUOTE_WAIT_MS) {
|
|
|
const domText = await ctx.page.locator("body").innerText().catch(() => "");
|
|
|
if (isPriority1ManualFollowupPage(domText)) break;
|
|
|
const visible = extractPriority1VisibleQuotes(ctx.networkBodies, domText);
|
|
|
if (visible.length > 0) break;
|
|
|
await ctx.page.waitForTimeout(pollMs);
|
|
|
}
|
|
|
if (!ctx.workerFast) {
|
|
|
await ctx.page.screenshot({
|
|
|
path: join(ctx.outDir, "quote-result-full.png"),
|
|
|
fullPage: true,
|
|
|
});
|
|
|
}
|
|
|
const domText = await ctx.page.locator("body").innerText().catch(() => "");
|
|
|
const resolved = resolvePriority1SubmitOutcome(ctx.networkBodies, domText);
|
|
|
await extractAndSaveQuotes(ctx, resolved.outcome, resolved.message);
|
|
|
return resolved;
|
|
|
}
|
|
|
|
|
|
function printP08AccessorialHandoff(): void {
|
|
|
console.log("\n════════ P08 人工录制 — 请勾选全部 11 项附加服务 ════════");
|
|
|
console.log("[record-p08] 脚本已自动完成 Step1 + Step2(至地点类型),附加服务留空。");
|
|
|
console.log("[record-p08] 请在浏览器中依次勾选:\n");
|
|
|
console.log(" 提货 Pickup Services(4 项):");
|
|
|
for (let i = 0; i < 4; i += 1) {
|
|
|
console.log(` ${i + 1}. ${P08_LTL_ACCESSORIAL_LABELS[i]}`);
|
|
|
}
|
|
|
console.log("\n 派送 Delivery Services(7 项):");
|
|
|
for (let i = 4; i < P08_LTL_ACCESSORIAL_LABELS.length; i += 1) {
|
|
|
console.log(` ${i + 1}. ${P08_LTL_ACCESSORIAL_LABELS[i]}`);
|
|
|
}
|
|
|
console.log("\n[record-p08] 全部勾选后点击 GET INSTANT QUOTES;脚本记录操作直至报价出现。\n");
|
|
|
}
|
|
|
|
|
|
function printFtlAdditionalHandoff(demo: Priority1DemoInput): void {
|
|
|
console.log("\n════════ FTL 人工录制 — 请完成 Step 2 剩余项 ════════");
|
|
|
console.log("[record-ftl] 脚本已自动完成 Step1 + 拖车类型(及 Open Deck/温控等条件字段)。");
|
|
|
console.log("[record-ftl] Additional Services 及后续字段留空,请人工操作:\n");
|
|
|
let n = 1;
|
|
|
for (const svc of demo.ftlAdditionalServices) {
|
|
|
const meta = FTL_ADDITIONAL[svc];
|
|
|
console.log(` ${n}. 勾选 Additional Services → ${meta.label}`);
|
|
|
n += 1;
|
|
|
}
|
|
|
console.log(` ${n}. What are you shipping? → ${demo.commodity}`);
|
|
|
n += 1;
|
|
|
console.log(` ${n}. Total Weight → ${demo.weightLb} lbs`);
|
|
|
n += 1;
|
|
|
if (demo.ftlTrailer !== "ftl_expedited") {
|
|
|
console.log(` ${n}. Number Of Pallets → ${demo.palletCount}`);
|
|
|
n += 1;
|
|
|
}
|
|
|
console.log(` ${n}. 点击 GET INSTANT QUOTES 提交`);
|
|
|
console.log("\n[record-ftl] 操作会被持续记录;出现报价后自动保存,或 Ctrl+C 结束保存。\n");
|
|
|
}
|
|
|
|
|
|
function printHumanAssistHandoff(ctx: VisualCtx, demo: Priority1DemoInput): void {
|
|
|
const failed = ctx.observations.filter((o) => !o.ok).map((o) => o.label);
|
|
|
console.log("\n════════ 人机协作 — 请人工继续 ════════");
|
|
|
console.log("[human-assist] 脚本已自动完成 Step 1 与 Step 2 可执行部分。");
|
|
|
if (failed.length) {
|
|
|
console.log(`[human-assist] 以下字段自动填写未确认,请人工核对:${failed.join("、")}`);
|
|
|
}
|
|
|
console.log("[human-assist] 请在浏览器中完成:");
|
|
|
if (demo.ftlAdditionalServices.includes("full_ftl")) {
|
|
|
console.log(" · 勾选 Additional Services → Full FTL(若未选中)");
|
|
|
}
|
|
|
console.log(` · What are you shipping? → ${demo.commodity}`);
|
|
|
console.log(` · Total Weight → ${demo.weightLb} lbs`);
|
|
|
if (demo.ftlTrailer !== "ftl_expedited") {
|
|
|
console.log(` · Pallets → ${demo.palletCount}`);
|
|
|
}
|
|
|
console.log(" · 点击 GET INSTANT QUOTES 提交");
|
|
|
console.log("[human-assist] 脚本正在记录您的操作,出现报价后自动保存录制。\n");
|
|
|
}
|
|
|
|
|
|
function saveHumanAssistRecording(
|
|
|
outDir: string,
|
|
|
caseId: string,
|
|
|
events: HumanAssistEvent[],
|
|
|
opts: {
|
|
|
recordingBase?: string;
|
|
|
manifest?: Omit<HumanAccessorialRecording, "events" | "playwrightScript">;
|
|
|
} = {},
|
|
|
): { jsonPath: string; jsPath: string } {
|
|
|
const ts = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
|
|
|
const recordingsDir = join(process.cwd(), ".rpa", "recordings");
|
|
|
mkdirSync(recordingsDir, { recursive: true });
|
|
|
const base = opts.recordingBase ?? `priority1-human-${caseId}-${ts}`;
|
|
|
const jsonPath = join(recordingsDir, `${base}.json`);
|
|
|
const jsPath = join(recordingsDir, `${base}.js`);
|
|
|
const playwrightScript = formatHumanEventsAsPlaywright(events);
|
|
|
const payload: HumanAccessorialRecording | { events: HumanAssistEvent[] } = opts.manifest
|
|
|
? { ...opts.manifest, events, playwrightScript, recordedAt: opts.manifest.recordedAt }
|
|
|
: { events };
|
|
|
writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
|
writeFileSync(jsPath, `${playwrightScript}\n`, "utf8");
|
|
|
writeFileSync(join(outDir, "human-recording.json"), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
|
writeFileSync(join(outDir, "human-recording.js"), `${playwrightScript}\n`, "utf8");
|
|
|
return { jsonPath, jsPath };
|
|
|
}
|
|
|
|
|
|
async function runHumanAssistPhase(
|
|
|
ctx: VisualCtx,
|
|
|
opts: RunPriority1VisualChainOptions,
|
|
|
browser: Awaited<ReturnType<typeof chromium.launch>>,
|
|
|
context: Awaited<ReturnType<typeof browser.newContext>>,
|
|
|
page: Page,
|
|
|
startedAt: number,
|
|
|
runId: string,
|
|
|
caseId: string,
|
|
|
outDir: string,
|
|
|
demo: Priority1DemoInput,
|
|
|
): Promise<Priority1VisualChainResult> {
|
|
|
const skipAccessorials = opts.humanAssistHandoff === "ltlAccessorials";
|
|
|
const skipFtlAdditional = opts.humanAssistHandoff === "ftlAdditionalServices";
|
|
|
console.log(
|
|
|
skipAccessorials
|
|
|
? "[human-assist] ▶ Step 2 自动填写(跳过 LTL 附加服务,交人工勾选)"
|
|
|
: skipFtlAdditional
|
|
|
? "[human-assist] ▶ Step 2 自动填写(跳过 FTL Additional Services,交人工)"
|
|
|
: "[human-assist] ▶ Step 2 软自动填写(失败不中断)",
|
|
|
);
|
|
|
await fillStep2(ctx, true, { skipAccessorials, skipFtlAdditionalServices: skipFtlAdditional });
|
|
|
await page.screenshot({
|
|
|
path: join(outDir, "step2-handoff-full.png"),
|
|
|
fullPage: true,
|
|
|
});
|
|
|
|
|
|
await installHumanAssistRecorder(page);
|
|
|
if (skipAccessorials) {
|
|
|
printP08AccessorialHandoff();
|
|
|
} else if (skipFtlAdditional) {
|
|
|
printFtlAdditionalHandoff(demo);
|
|
|
} else {
|
|
|
printHumanAssistHandoff(ctx, demo);
|
|
|
}
|
|
|
|
|
|
const maxWait = opts.humanAssistMaxWaitMs ?? 30 * 60 * 1000;
|
|
|
const pollMs = 2_000;
|
|
|
const started = Date.now();
|
|
|
let lastEventCount = 0;
|
|
|
let accumulatedEvents: HumanAssistEvent[] = [];
|
|
|
let quoteDetected = false;
|
|
|
let shuttingDown = false;
|
|
|
|
|
|
const canonical = PRIORITY1_CANONICAL_PATHS.find((p) => p.id === caseId);
|
|
|
|
|
|
const persistRecording = (events: HumanAssistEvent[]) => {
|
|
|
const ts = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
|
|
|
const recordingBase =
|
|
|
opts.humanAssistHandoff === "ltlAccessorials"
|
|
|
? `priority1-P08-accessorials-human-${ts}`
|
|
|
: skipFtlAdditional
|
|
|
? `priority1-${caseId}-human-${ts}`
|
|
|
: undefined;
|
|
|
return saveHumanAssistRecording(outDir, caseId, events, {
|
|
|
recordingBase,
|
|
|
manifest: skipAccessorials
|
|
|
? {
|
|
|
pathId: "P08-ltl-all-accessorials",
|
|
|
title: "LTL 全附加服务 — 人工勾选 11 项",
|
|
|
recordedAt: new Date().toISOString(),
|
|
|
expectedAccessorials: P08_LTL_ACCESSORIAL_LABELS,
|
|
|
}
|
|
|
: skipFtlAdditional && canonical
|
|
|
? {
|
|
|
pathId: canonical.id,
|
|
|
title: `${canonical.title} — 人工补录 Step2`,
|
|
|
recordedAt: new Date().toISOString(),
|
|
|
expectedAccessorials: demo.ftlAdditionalServices.map(
|
|
|
(s) => FTL_ADDITIONAL[s].label,
|
|
|
),
|
|
|
}
|
|
|
: undefined,
|
|
|
});
|
|
|
};
|
|
|
|
|
|
const finishAssist = async (reason: string, withQuotes: boolean) => {
|
|
|
if (shuttingDown) return null;
|
|
|
shuttingDown = true;
|
|
|
const drained = await drainHumanAssistEvents(page).catch(() => [] as HumanAssistEvent[]);
|
|
|
const events =
|
|
|
drained.length >= accumulatedEvents.length ? drained : accumulatedEvents;
|
|
|
let jsonPath = "";
|
|
|
let jsPath = "";
|
|
|
if (events.length > 0 || quoteDetected || withQuotes) {
|
|
|
({ jsonPath, jsPath } = persistRecording(events));
|
|
|
console.log(`\n[human-assist] ${reason}`);
|
|
|
console.log(`[human-assist] 记录事件 ${events.length} 条`);
|
|
|
console.log(`[human-assist] 人工录制: ${jsonPath}`);
|
|
|
console.log(`[human-assist] Playwright 脚本: ${jsPath}`);
|
|
|
}
|
|
|
return { events, jsonPath, jsPath };
|
|
|
};
|
|
|
|
|
|
process.once("SIGINT", () => {
|
|
|
void (async () => {
|
|
|
await finishAssist("用户 Ctrl+C — 已保存录制", quoteDetected);
|
|
|
await context.close().catch(() => undefined);
|
|
|
await browser.close().catch(() => undefined);
|
|
|
process.exit(0);
|
|
|
})();
|
|
|
});
|
|
|
|
|
|
while (Date.now() - started < maxWait && !shuttingDown) {
|
|
|
if (page.isClosed()) {
|
|
|
await finishAssist("浏览器已关闭 — 已保存已记录操作", quoteDetected);
|
|
|
throw new Error("浏览器已关闭");
|
|
|
}
|
|
|
|
|
|
const drained = await drainHumanAssistEvents(page).catch(() => [] as HumanAssistEvent[]);
|
|
|
if (drained.length > accumulatedEvents.length) {
|
|
|
accumulatedEvents = drained;
|
|
|
}
|
|
|
if (accumulatedEvents.length > lastEventCount) {
|
|
|
const latest = accumulatedEvents[accumulatedEvents.length - 1]!;
|
|
|
console.log(
|
|
|
`[human-assist] 记录 #${accumulatedEvents.length}: ${latest.kind} ${latest.label || latest.selector || latest.text.slice(0, 30)}`,
|
|
|
);
|
|
|
lastEventCount = accumulatedEvents.length;
|
|
|
}
|
|
|
|
|
|
const domText = await page.locator("body").innerText().catch(() => "");
|
|
|
const manualFollowup = isPriority1ManualFollowupPage(domText);
|
|
|
const visible = extractPriority1VisibleQuotes(ctx.networkBodies, domText);
|
|
|
if (manualFollowup || visible.length > 0) {
|
|
|
quoteDetected = true;
|
|
|
const quoteWait = manualFollowup
|
|
|
? resolvePriority1SubmitOutcome(ctx.networkBodies, domText)
|
|
|
: await waitForQuotes(ctx);
|
|
|
if (manualFollowup) {
|
|
|
await extractAndSaveQuotes(ctx, quoteWait.outcome, quoteWait.message);
|
|
|
}
|
|
|
await finishAssist(
|
|
|
manualFollowup
|
|
|
? `✓ ${quoteWait.message ?? PRIORITY1_MANUAL_FOLLOWUP_MESSAGE}`
|
|
|
: "✓ 已捕获报价",
|
|
|
true,
|
|
|
);
|
|
|
|
|
|
const allObservedOk = ctx.observations.every((o) => o.ok);
|
|
|
const report: Priority1VisualChainResult = {
|
|
|
runId,
|
|
|
caseId,
|
|
|
outDir,
|
|
|
demo,
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
durationMs: Date.now() - startedAt,
|
|
|
finalUrl: page.url(),
|
|
|
observations: ctx.observations,
|
|
|
quotes: quoteWait.quotes,
|
|
|
quoteCount: quoteWait.quotes.length,
|
|
|
quoteOutcome: quoteWait.outcome,
|
|
|
message: quoteWait.message,
|
|
|
allObservedOk,
|
|
|
ok:
|
|
|
quoteWait.outcome === "instant_quote"
|
|
|
? quoteWait.quotes.length > 0
|
|
|
: quoteWait.outcome !== "no_result" &&
|
|
|
(quoteWait.outcome === "manual_followup" || allObservedOk),
|
|
|
};
|
|
|
writeFileSync(join(outDir, "observations.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
|
if (quoteWait.outcome === "manual_followup") {
|
|
|
console.log(formatPriority1QuoteOutcomeMessage(quoteWait.outcome, quoteWait.message));
|
|
|
} else {
|
|
|
console.log(formatPriority1QuotesTable(quoteWait.quotes));
|
|
|
}
|
|
|
|
|
|
const keepOpen = opts.keepBrowserOpen ?? shouldKeepBrowserOpen();
|
|
|
if (keepOpen) {
|
|
|
console.log("\n[human-assist] 浏览器保持打开核对报价。Ctrl+C 结束。");
|
|
|
await new Promise<void>((resolve) => {
|
|
|
process.once("SIGINT", () => resolve());
|
|
|
process.once("SIGTERM", () => resolve());
|
|
|
});
|
|
|
}
|
|
|
await context.close();
|
|
|
await browser.close();
|
|
|
return report;
|
|
|
}
|
|
|
|
|
|
await page.waitForTimeout(pollMs);
|
|
|
}
|
|
|
|
|
|
await finishAssist("人机协作超时 — 已保存已记录操作", quoteDetected);
|
|
|
throw new Error(`人机协作超时(${maxWait / 60000} 分钟)未出现报价`);
|
|
|
}
|
|
|
|
|
|
export async function runPriority1HumanAssistChain(
|
|
|
opts: Omit<RunPriority1VisualChainOptions, "humanAssistMode"> = {},
|
|
|
): Promise<Priority1VisualChainResult> {
|
|
|
return runPriority1VisualChain({
|
|
|
...opts,
|
|
|
humanAssistMode: true,
|
|
|
keepBrowserOpen: opts.keepBrowserOpen ?? true,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/** FTL canonical — 自动填至拖车/条件字段,人工录 Additional Services + 货描/重量/提交 */
|
|
|
export async function runPriority1FtlPathRecording(
|
|
|
opts: Omit<RunPriority1VisualChainOptions, "humanAssistMode" | "humanAssistHandoff"> = {},
|
|
|
): Promise<Priority1VisualChainResult> {
|
|
|
return runPriority1VisualChain({
|
|
|
...opts,
|
|
|
humanAssistMode: true,
|
|
|
humanAssistHandoff: "ftlAdditionalServices",
|
|
|
keepBrowserOpen: opts.keepBrowserOpen ?? true,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/** P08 — 自动填至附加服务前,人工勾选 11 项并录制至报价 */
|
|
|
export async function runPriority1P08AccessorialRecording(
|
|
|
opts: Omit<RunPriority1VisualChainOptions, "humanAssistMode" | "humanAssistHandoff"> = {},
|
|
|
): Promise<Priority1VisualChainResult> {
|
|
|
const canonical = PRIORITY1_CANONICAL_PATHS.find((p) => p.id === "P08-ltl-all-accessorials");
|
|
|
return runPriority1VisualChain({
|
|
|
...opts,
|
|
|
demo: canonical?.input,
|
|
|
caseId: "P08-ltl-all-accessorials",
|
|
|
humanAssistMode: true,
|
|
|
humanAssistHandoff: "ltlAccessorials",
|
|
|
keepBrowserOpen: opts.keepBrowserOpen ?? true,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function submitStep2(ctx: VisualCtx): Promise<Priority1QuoteWaitResult> {
|
|
|
await ctx.page
|
|
|
.locator("#container-mh")
|
|
|
.click({ position: { x: 10, y: 10 } })
|
|
|
.catch(() => undefined);
|
|
|
await ctx.page.waitForTimeout(400);
|
|
|
|
|
|
const btn = ctx.page
|
|
|
.locator("#container-mh")
|
|
|
.getByRole("button", { name: /GET INSTANT QUOTES/i })
|
|
|
.first();
|
|
|
await highlight(btn);
|
|
|
await snapLocator(ctx, btn, "step2-submit-btn");
|
|
|
await btn.scrollIntoViewIfNeeded().catch(() => undefined);
|
|
|
await btn.click(FEATHERY_CLICK);
|
|
|
await ctx.page.waitForTimeout(1_500);
|
|
|
await btn.click(FEATHERY_CLICK).catch(() => undefined);
|
|
|
|
|
|
const result = await waitForQuotes(ctx);
|
|
|
if (result.outcome === "manual_followup") {
|
|
|
console.log(`[观测] ✓ ${result.message ?? PRIORITY1_MANUAL_FOLLOWUP_MESSAGE}`);
|
|
|
return result;
|
|
|
}
|
|
|
if (!result.quotes.length) {
|
|
|
await ctx.page.screenshot({
|
|
|
path: join(ctx.outDir, "quote-result-no-price.png"),
|
|
|
fullPage: true,
|
|
|
});
|
|
|
throw new Error("提交 Step 2 后未能提取报价(网络与页面均无金额,且非人工跟进页)");
|
|
|
}
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
async function waitFeatheryStep1(page: Page): Promise<void> {
|
|
|
await page.locator("#container-mh").waitFor({ state: "visible", timeout: 60_000 });
|
|
|
await page
|
|
|
.locator("#container-mh")
|
|
|
.getByText(/Shipment Type/i)
|
|
|
.first()
|
|
|
.waitFor({ state: "visible" });
|
|
|
await page.waitForTimeout(800);
|
|
|
}
|
|
|
|
|
|
export type Priority1VisualChainResult = {
|
|
|
runId: string;
|
|
|
caseId: string;
|
|
|
outDir: string;
|
|
|
demo: Priority1DemoInput;
|
|
|
finishedAt: string;
|
|
|
durationMs: number;
|
|
|
finalUrl: string;
|
|
|
observations: Observation[];
|
|
|
quotes: Priority1QuoteLine[];
|
|
|
quoteCount: number;
|
|
|
quoteOutcome?: Priority1QuoteOutcome;
|
|
|
message?: string;
|
|
|
allObservedOk: boolean;
|
|
|
ok: boolean;
|
|
|
error?: string;
|
|
|
};
|
|
|
|
|
|
export type RunPriority1VisualChainOptions = {
|
|
|
demo?: Priority1DemoInput;
|
|
|
caseId?: string;
|
|
|
outDir?: string;
|
|
|
runId?: string;
|
|
|
keepBrowserOpen?: boolean;
|
|
|
/** 仅核对 Step 2 填写,不提交报价(inspect 模式) */
|
|
|
stopAfterStep2?: boolean;
|
|
|
/** 自动填至卡住后交人工,记录人工操作直至报价 */
|
|
|
humanAssistMode?: boolean;
|
|
|
/** ltlAccessorials = Step2 跳过 LTL 附加;ftlAdditionalServices = 跳过 FTL Additional Services */
|
|
|
humanAssistHandoff?: "ltlAccessorials" | "ftlAdditionalServices";
|
|
|
humanAssistMaxWaitMs?: number;
|
|
|
/** Worker 生产询价:跳过截图/停留,缩短观测与轮询 */
|
|
|
workerFast?: boolean;
|
|
|
};
|
|
|
|
|
|
export async function runPriority1VisualChain(
|
|
|
opts: RunPriority1VisualChainOptions = {},
|
|
|
): Promise<Priority1VisualChainResult> {
|
|
|
const demo = opts.demo ?? parsePriority1DemoFromEnv(process.env.PRIORITY1_DEMO_JSON);
|
|
|
const caseId = opts.caseId ?? process.env.PRIORITY1_CASE_ID?.trim() ?? "single";
|
|
|
const workerFast =
|
|
|
typeof opts.workerFast === "boolean"
|
|
|
? opts.workerFast
|
|
|
: caseId.startsWith("worker-") ||
|
|
|
process.env.RPA_PRIORITY1_WORKER_FAST === "true";
|
|
|
const runId = opts.runId ?? randomUUID().slice(0, 8);
|
|
|
const outDir =
|
|
|
opts.outDir ??
|
|
|
(process.env.PRIORITY1_OUT_DIR?.trim()
|
|
|
? process.env.PRIORITY1_OUT_DIR.trim()
|
|
|
: join(
|
|
|
process.cwd(),
|
|
|
".rpa",
|
|
|
"visual-priority1",
|
|
|
process.env.PRIORITY1_BATCH_DIR?.trim()
|
|
|
? join(process.env.PRIORITY1_BATCH_DIR.trim(), caseId)
|
|
|
: runId,
|
|
|
));
|
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
|
|
|
console.log(`\n[visual-p1] Priority1 可视回放 ${runId} / ${caseId}`);
|
|
|
console.log("[visual-p1] 推进规则:仅以输入框/下拉/复选框可见状态 + 元素截图为据");
|
|
|
const forceHeaded = Boolean(opts.humanAssistMode);
|
|
|
console.log(`[visual-p1] 类型=${demo.shipmentType} headed=${forceHeaded || !envHeadless()} slowMo=${envSlowMo() ?? 0} dwell=${envDwellMs()}ms`);
|
|
|
console.log(`[visual-p1] 观测截图目录: ${outDir}\n`);
|
|
|
|
|
|
const browser = await launchRpaBrowser({
|
|
|
headless: forceHeaded ? false : envHeadless(),
|
|
|
...(envSlowMo() !== undefined ? { slowMo: envSlowMo() } : {}),
|
|
|
});
|
|
|
|
|
|
const context = await browser.newContext({
|
|
|
viewport: { width: 1400, height: 900 },
|
|
|
locale: "en-US",
|
|
|
});
|
|
|
const page = await context.newPage();
|
|
|
page.setDefaultTimeout(90_000);
|
|
|
|
|
|
const ctx: VisualCtx = {
|
|
|
page,
|
|
|
outDir,
|
|
|
demo,
|
|
|
observations: [],
|
|
|
snapSeq: 0,
|
|
|
networkBodies: [],
|
|
|
quotes: [],
|
|
|
workerFast,
|
|
|
};
|
|
|
attachPriority1QuoteNetworkCapture(page, ctx.networkBodies);
|
|
|
const startedAt = Date.now();
|
|
|
|
|
|
try {
|
|
|
console.log("[visual-p1] ▶ 打开询价页");
|
|
|
await page.goto(ENTRY_URL, { waitUntil: "domcontentloaded" });
|
|
|
await waitFeatheryStep1(page);
|
|
|
await page.screenshot({
|
|
|
path: join(outDir, "00-step1-empty-full.png"),
|
|
|
fullPage: true,
|
|
|
});
|
|
|
|
|
|
console.log("[visual-p1] ▶ Step 1 逐字段填写 + 观测");
|
|
|
await fillStep1(ctx);
|
|
|
await page.screenshot({
|
|
|
path: join(outDir, "step1-all-fields-full.png"),
|
|
|
fullPage: true,
|
|
|
});
|
|
|
|
|
|
console.log("[visual-p1] ▶ 提交 Step 1");
|
|
|
await submitStep1(ctx);
|
|
|
|
|
|
if (opts.humanAssistMode) {
|
|
|
return runHumanAssistPhase(
|
|
|
ctx,
|
|
|
opts,
|
|
|
browser,
|
|
|
context,
|
|
|
page,
|
|
|
startedAt,
|
|
|
runId,
|
|
|
caseId,
|
|
|
outDir,
|
|
|
demo,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
console.log("[visual-p1] ▶ Step 2 逐字段填写 + 观测");
|
|
|
await fillStep2(ctx);
|
|
|
await page.screenshot({
|
|
|
path: join(outDir, "step2-all-fields-full.png"),
|
|
|
fullPage: true,
|
|
|
});
|
|
|
|
|
|
if (opts.stopAfterStep2) {
|
|
|
console.log("[visual-p1] ▶ Step 2 填写完成(inspect 模式,跳过报价提交)");
|
|
|
const report: Priority1VisualChainResult = {
|
|
|
runId,
|
|
|
caseId,
|
|
|
outDir,
|
|
|
demo,
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
durationMs: Date.now() - startedAt,
|
|
|
finalUrl: page.url(),
|
|
|
observations: ctx.observations,
|
|
|
quotes: [],
|
|
|
quoteCount: 0,
|
|
|
allObservedOk: ctx.observations.every((o) => o.ok),
|
|
|
ok: ctx.observations.every((o) => o.ok),
|
|
|
};
|
|
|
const reportPath = join(outDir, "observations.json");
|
|
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
|
console.log("\n════════ 观测汇总 ════════");
|
|
|
for (const o of ctx.observations) {
|
|
|
console.log(
|
|
|
` ${o.ok ? "✓" : "✗"} ${o.label}: "${o.observed}" ← ${o.screenshot}`,
|
|
|
);
|
|
|
}
|
|
|
console.log(`\n观测报告: ${reportPath}`);
|
|
|
const keepOpen =
|
|
|
opts.keepBrowserOpen ?? shouldKeepBrowserOpen();
|
|
|
if (keepOpen) {
|
|
|
console.log("\n[visual-p1] 浏览器保持打开,请肉眼核对 Step 2。Ctrl+C 结束。");
|
|
|
await new Promise<void>((resolve) => {
|
|
|
process.once("SIGINT", () => resolve());
|
|
|
process.once("SIGTERM", () => resolve());
|
|
|
});
|
|
|
} else if (envDwellMs() > 0) {
|
|
|
await page.waitForTimeout(envDwellMs());
|
|
|
}
|
|
|
await context.close();
|
|
|
await browser.close();
|
|
|
return report;
|
|
|
}
|
|
|
|
|
|
console.log("[visual-p1] ▶ 提交 Step 2 → 报价");
|
|
|
const quoteWait = await submitStep2(ctx);
|
|
|
const allObservedOk = ctx.observations.every((o) => o.ok);
|
|
|
|
|
|
const report: Priority1VisualChainResult = {
|
|
|
runId,
|
|
|
caseId,
|
|
|
outDir,
|
|
|
demo,
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
durationMs: Date.now() - startedAt,
|
|
|
finalUrl: page.url(),
|
|
|
observations: ctx.observations,
|
|
|
quotes: quoteWait.quotes,
|
|
|
quoteCount: quoteWait.quotes.length,
|
|
|
quoteOutcome: quoteWait.outcome,
|
|
|
message: quoteWait.message,
|
|
|
allObservedOk,
|
|
|
ok:
|
|
|
quoteWait.outcome === "instant_quote"
|
|
|
? quoteWait.quotes.length > 0
|
|
|
: quoteWait.outcome !== "no_result" &&
|
|
|
(quoteWait.outcome === "manual_followup" || allObservedOk),
|
|
|
};
|
|
|
|
|
|
const reportPath = join(outDir, "observations.json");
|
|
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
|
await context.storageState({
|
|
|
path: join(process.cwd(), ".rpa", "priority1-storage.json"),
|
|
|
});
|
|
|
|
|
|
console.log("\n════════ 观测汇总 ════════");
|
|
|
for (const o of ctx.observations) {
|
|
|
console.log(
|
|
|
` ${o.ok ? "✓" : "✗"} ${o.label}: "${o.observed}" ← ${o.screenshot}`,
|
|
|
);
|
|
|
}
|
|
|
console.log(`\n观测报告: ${reportPath}`);
|
|
|
console.log(`截图目录: ${outDir}`);
|
|
|
|
|
|
const keepOpen =
|
|
|
opts.keepBrowserOpen ?? shouldKeepBrowserOpen();
|
|
|
if (keepOpen) {
|
|
|
console.log("\n[visual-p1] 浏览器保持打开,请肉眼核对各输入框。Ctrl+C 结束。");
|
|
|
await new Promise<void>((resolve) => {
|
|
|
process.once("SIGINT", () => resolve());
|
|
|
process.once("SIGTERM", () => resolve());
|
|
|
});
|
|
|
} else if (envDwellMs() > 0) {
|
|
|
await page.waitForTimeout(envDwellMs());
|
|
|
}
|
|
|
|
|
|
await context.close();
|
|
|
await browser.close();
|
|
|
return report;
|
|
|
} catch (error) {
|
|
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
|
writeFileSync(
|
|
|
join(outDir, "observations-partial.json"),
|
|
|
`${JSON.stringify({ runId, caseId, error: errMsg, observations: ctx.observations }, null, 2)}\n`,
|
|
|
"utf8",
|
|
|
);
|
|
|
console.error(`[visual-p1] 中断: ${errMsg}`);
|
|
|
|
|
|
const failedObs = ctx.observations.filter((o) => !o.ok);
|
|
|
if (failedObs.length) {
|
|
|
console.log("\n════════ 中断点(最后失败观测)════════");
|
|
|
for (const o of failedObs) {
|
|
|
console.log(` ✗ [${o.step}] ${o.label}`);
|
|
|
console.log(` 期望: ${o.expected} | 实际: ${o.observed}`);
|
|
|
console.log(` 截图: ${o.screenshot}`);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const keepOpen =
|
|
|
opts.keepBrowserOpen ?? shouldKeepBrowserOpen();
|
|
|
if (keepOpen) {
|
|
|
console.log(
|
|
|
"\n[visual-p1] 浏览器保持打开 — 请肉眼核对中断字段。Ctrl+C 关闭并继续。",
|
|
|
);
|
|
|
await new Promise<void>((resolve) => {
|
|
|
process.once("SIGINT", () => resolve());
|
|
|
process.once("SIGTERM", () => resolve());
|
|
|
});
|
|
|
} else if (!envHeadless() && envDwellMs() > 0) {
|
|
|
await page.waitForTimeout(envDwellMs());
|
|
|
}
|
|
|
|
|
|
await context.close().catch(() => undefined);
|
|
|
await browser.close().catch(() => undefined);
|
|
|
return {
|
|
|
runId,
|
|
|
caseId,
|
|
|
outDir,
|
|
|
demo,
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
durationMs: Date.now() - startedAt,
|
|
|
finalUrl: page.url(),
|
|
|
observations: ctx.observations,
|
|
|
quotes: ctx.quotes,
|
|
|
quoteCount: ctx.quotes.length,
|
|
|
quoteOutcome: "no_result",
|
|
|
allObservedOk: ctx.observations.every((o) => o.ok),
|
|
|
ok: false,
|
|
|
error: errMsg,
|
|
|
};
|
|
|
}
|
|
|
}
|