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.

279 lines
8.2 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.

#!/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 =
"提货+派送地址均已点选联想并锁定。确认后触发检查点 AEnter 或 trigger 文件)。";
const PROMPT_QUOTE_DONE =
"货物已填、已提交、四档报价可见。确认后触发检查点 BEnter 或 trigger 文件)。";
async function captureStorageSnapshot(
page: Page,
context: BrowserContext,
): Promise<Record<string, unknown>> {
const storageState = await context.storageState();
const pageUrl = page.url();
const sessionStorageByOrigin = await page.evaluate(() => {
const result: Record<string, Record<string, string>> = {};
try {
const entries: Record<string, string> = {};
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, unknown>): 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<void> {
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);
});