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.

1536 lines
52 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.

/**
* 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 Services4 项):");
for (let i = 0; i < 4; i += 1) {
console.log(` ${i + 1}. ${P08_LTL_ACCESSORIAL_LABELS[i]}`);
}
console.log("\n 派送 Delivery Services7 项):");
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,
};
}
}