/** * 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 { createRpaBrowserContext } from "@/lib/rpa/browser-context"; import { formatExternalSiteNavigationError, gotoWithResilience, isTransientNavigationError, } from "@/lib/rpa/page-goto"; 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 { 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 { 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 { 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 { if (workerFast || envDwellMs() <= 0) return; console.log(`[观测] 停留 ${envDwellMs()}ms — ${label}`); await page.waitForTimeout(envDwellMs()); } async function highlight(locator: Locator): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 gotoWithResilience( ctx.page, `https://www.priority1.com/instant-freight-quotes/${cfg.step2HashPath}`, ); 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 { 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 { const step = "step2"; const trailer = FTL_TRAILER[ctx.demo.ftlTrailer]; const runSoft = async (label: string, fn: () => Promise) => { 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 { 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 { 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 { 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; } = {}, ): { 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>, context: Awaited>, page: Page, startedAt: number, runId: string, caseId: string, outDir: string, demo: Priority1DemoInput, ): Promise { 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((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 = {}, ): Promise { return runPriority1VisualChain({ ...opts, humanAssistMode: true, keepBrowserOpen: opts.keepBrowserOpen ?? true, }); } /** FTL canonical — 自动填至拖车/条件字段,人工录 Additional Services + 货描/重量/提交 */ export async function runPriority1FtlPathRecording( opts: Omit = {}, ): Promise { return runPriority1VisualChain({ ...opts, humanAssistMode: true, humanAssistHandoff: "ftlAdditionalServices", keepBrowserOpen: opts.keepBrowserOpen ?? true, }); } /** P08 — 自动填至附加服务前,人工勾选 11 项并录制至报价 */ export async function runPriority1P08AccessorialRecording( opts: Omit = {}, ): Promise { 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 { 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 { 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 { 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 createRpaBrowserContext(browser); 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 gotoWithResilience(page, ENTRY_URL); 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((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((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 raw = error instanceof Error ? error.message : String(error); const errMsg = isTransientNavigationError(error) ? formatExternalSiteNavigationError("Priority1 官网", error) : raw; 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((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, }; } }