|
|
#!/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<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);
|
|
|
});
|