#!/usr/bin/env tsx /** * 阶段0 Network-First 录证(独立脚本,不含业务状态机) * * 用法: * npm run record:phase0 * npx tsx scripts/phase0/record-baseline.ts (推荐:stdin 更可靠) * * 非 TTY / Enter 无效时: * set PHASE0_USE_FILE_TRIGGER=1 * 检查点 A: New-Item -Force .rpa\phase0\trigger-checkpoint-a * 检查点 B: New-Item -Force .rpa\phase0\trigger-checkpoint-b */ import "dotenv/config"; import fs from "node:fs"; import path from "node:path"; import { chromium, type BrowserContext, type Page } from "playwright"; import { createSharedReadline, TRIGGER_CHECKPOINT_A, TRIGGER_CHECKPOINT_B, waitForCheckpoint, writeRecordingStatus, } from "./lib/checkpoint"; import { resolveBrowserChannel, resolveEntryUrl } from "./lib/env"; import { pickBestQuoteEntry, readHarFile } from "./lib/har-parse"; import { BASELINE_HAR, PHASE0_DIR, QUOTE_CONTRACT_SCHEMA, SESSION_STORAGE_SNAPSHOT, } from "./lib/paths"; import { buildQuoteContractSchema } from "./lib/schema-infer"; const PROMPT_ADDRESS_LOCKED = "提货+派送地址均已点选联想并锁定。确认后触发检查点 A(Enter 或 trigger 文件)。"; const PROMPT_QUOTE_DONE = "货物已填、已提交、四档报价可见。确认后触发检查点 B(Enter 或 trigger 文件)。"; async function captureStorageSnapshot( page: Page, context: BrowserContext, ): Promise> { const storageState = await context.storageState(); const pageUrl = page.url(); const sessionStorageByOrigin = await page.evaluate(() => { const result: Record> = {}; try { const entries: Record = {}; for (let i = 0; i < sessionStorage.length; i += 1) { const key = sessionStorage.key(i); if (key) { entries[key] = sessionStorage.getItem(key) ?? ""; } } result[window.location.origin] = entries; } catch { /* ignore */ } return result; }); const documentCookies = await page .evaluate(() => document.cookie) .catch(() => ""); return { capturedAt: new Date().toISOString(), checkpoint: "address_locked", pageUrl, storageState, sessionStorageByOrigin, documentCookies, }; } function writeSnapshotFile(snapshot: Record): string { const absPath = path.resolve(SESSION_STORAGE_SNAPSHOT); fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(absPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8"); if (!fs.existsSync(absPath)) { throw new Error(`写入后文件仍不存在: ${absPath}`); } const stat = fs.statSync(absPath); if (stat.size < 10) { throw new Error(`写入文件过小 (${stat.size} bytes): ${absPath}`); } return absPath; } function extractSchemaFromHar(harPath: string): void { const har = readHarFile(harPath); const entries = har.log?.entries ?? []; const picked = pickBestQuoteEntry(entries); if (!picked) { console.warn( "[phase0] 警告: HAR 中未识别到 quote JSON 响应,quote-contract.schema.json 将写入占位说明。", ); fs.writeFileSync( QUOTE_CONTRACT_SCHEMA, `${JSON.stringify( { $schema: "https://json-schema.org/draft/2020-12/schema", title: "MotherShip Quote Response Contract (placeholder)", description: "录证未完成或 HAR 无 quote 响应。请重新执行录证并确保提交后捕获到 axel/quote 等接口。", generatedAt: new Date().toISOString(), body: { type: "object" }, incomplete: true, }, null, 2, )}\n`, "utf8", ); return; } const doc = buildQuoteContractSchema({ body: picked.body, harEntry: { url: picked.entry.request.url, method: picked.entry.request.method, status: picked.entry.response.status, }, }); fs.writeFileSync(QUOTE_CONTRACT_SCHEMA, `${JSON.stringify(doc, null, 2)}\n`, "utf8"); console.log( `[phase0] quote-contract.schema.json ← ${picked.entry.request.method} ${picked.entry.request.url}`, ); } function printBanner(entryUrl: string): void { const absOut = path.resolve(PHASE0_DIR); const tty = process.stdin.isTTY ? "是" : "否"; const mode = process.env.PHASE0_USE_FILE_TRIGGER === "1" || !process.stdin.isTTY ? "文件触发" : "Enter 键"; console.log(` ╔══════════════════════════════════════════════════════════════╗ ║ 阶段0 Network-First 录证(headed 人工操作) ║ ╚══════════════════════════════════════════════════════════════╝ 入口 URL: ${entryUrl} 产出目录: ${absOut} stdin TTY: ${tty} → 检查点模式: ${mode} 操作流程: 1. 浏览器打开后,完成提货/派送地址点选锁定 2. 检查点 A → 写入 session-storage-snapshot.json 3. 填货并提交,等待四档报价 4. 检查点 B → 写入 baseline.har + quote-contract.schema.json Enter 无效时请用: $env:PHASE0_USE_FILE_TRIGGER="1"; npx tsx scripts/phase0/record-baseline.ts 检查点 A: New-Item -ItemType File -Force -Path ".rpa\\phase0\\trigger-checkpoint-a" `); } async function main(): Promise { const entryUrl = resolveEntryUrl(); fs.mkdirSync(PHASE0_DIR, { recursive: true }); writeRecordingStatus({ phase: "started", entryUrl, cwd: process.cwd(), stdinIsTTY: process.stdin.isTTY, snapshotTarget: path.resolve(SESSION_STORAGE_SNAPSHOT), }); printBanner(entryUrl); const channel = resolveBrowserChannel(); const browser = await chromium.launch({ headless: false, channel: channel as "chrome" | "msedge" | undefined, slowMo: Number(process.env.RPA_SLOW_MO_MS ?? 0) || undefined, }); const context = await browser.newContext({ locale: process.env.RPA_BROWSER_LOCALE?.trim() || undefined, recordHar: { path: BASELINE_HAR, mode: "full", content: "embed", }, }); const page = await context.newPage(); page.setDefaultTimeout(120_000); console.log(`[phase0] 导航至 ${entryUrl}`); await page.goto(entryUrl, { waitUntil: "domcontentloaded", timeout: 60_000 }); writeRecordingStatus({ phase: "waiting_checkpoint_a", pageUrl: page.url() }); const rl = process.stdin.isTTY && process.env.PHASE0_USE_FILE_TRIGGER !== "1" ? createSharedReadline() : null; await waitForCheckpoint({ label: "检查点 A", message: PROMPT_ADDRESS_LOCKED, triggerPath: TRIGGER_CHECKPOINT_A, rl, }); console.log("[phase0] 正在捕获存储快照…"); let snapshotPath: string; try { const snapshot = await captureStorageSnapshot(page, context); snapshotPath = writeSnapshotFile(snapshot); } catch (error) { writeRecordingStatus({ phase: "checkpoint_a_failed", error: error instanceof Error ? error.message : String(error), }); throw error; } writeRecordingStatus({ phase: "checkpoint_a_done", snapshotPath, snapshotBytes: fs.statSync(snapshotPath).size, }); console.log(`[phase0] ✓ 已写入 ${snapshotPath}`); console.log(`[phase0] 大小 ${fs.statSync(snapshotPath).size} bytes`); writeRecordingStatus({ phase: "waiting_checkpoint_b", pageUrl: page.url() }); await waitForCheckpoint({ label: "检查点 B", message: PROMPT_QUOTE_DONE, triggerPath: TRIGGER_CHECKPOINT_B, rl, }); if (rl) { rl.close(); } await context.close(); await browser.close(); if (!fs.existsSync(BASELINE_HAR)) { throw new Error(`HAR 未生成: ${path.resolve(BASELINE_HAR)}`); } extractSchemaFromHar(BASELINE_HAR); writeRecordingStatus({ phase: "completed", harPath: path.resolve(BASELINE_HAR), schemaPath: path.resolve(QUOTE_CONTRACT_SCHEMA), }); console.log(` [phase0] 录证完成。请执行验收: npm run verify:phase0 `); } main().catch((error) => { writeRecordingStatus({ phase: "failed", error: error instanceof Error ? error.message : String(error), }); console.error("[phase0] 录证失败:", error instanceof Error ? error.message : error); process.exit(1); });