|
|
/**
|
|
|
* Priority1 纯人工录制 — 仅打开页面 + 记录操作,不自动填表
|
|
|
*/
|
|
|
|
|
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
import { dirname, join } from "node:path";
|
|
|
import { fileURLToPath } from "node:url";
|
|
|
import { chromium, type Page } from "playwright";
|
|
|
import { PRIORITY1_FORM_META } from "@/workers/rpa/priority1/form-logic-map";
|
|
|
import {
|
|
|
buildAccessorialLabelPlaywright,
|
|
|
drainHumanAssistEvents,
|
|
|
formatHumanEventsAsPlaywright,
|
|
|
type HumanAccessorialRecording,
|
|
|
type HumanAssistEvent,
|
|
|
} from "@/workers/rpa/priority1/human-assist-recorder";
|
|
|
import { extractPriority1VisibleQuotes } from "@/workers/rpa/priority1/quote-extract";
|
|
|
|
|
|
const BROWSER_RECORDER_JS = join(
|
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
|
"human-assist-recorder.browser.js",
|
|
|
);
|
|
|
|
|
|
export type ManualRecordingOptions = {
|
|
|
pathId: string;
|
|
|
title: string;
|
|
|
outDir?: string;
|
|
|
entryUrl?: string;
|
|
|
checklist?: readonly string[];
|
|
|
maxWaitMs?: number;
|
|
|
};
|
|
|
|
|
|
function loadRecorderScript(): string {
|
|
|
return readFileSync(BROWSER_RECORDER_JS, "utf8");
|
|
|
}
|
|
|
|
|
|
function attachQuoteNetworkCapture(page: Page, bodies: string[]): void {
|
|
|
page.on("response", async (res) => {
|
|
|
const url = res.url();
|
|
|
if (!PRIORITY1_FORM_META.quoteApiPattern.test(url)) return;
|
|
|
try {
|
|
|
const json = await res.json();
|
|
|
bodies.push(JSON.stringify(json));
|
|
|
} catch {
|
|
|
// ignore
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function saveRecording(
|
|
|
outDir: string,
|
|
|
opts: ManualRecordingOptions,
|
|
|
events: HumanAssistEvent[],
|
|
|
quoteDetected: boolean,
|
|
|
): { 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 = `priority1-${opts.pathId}-manual-${ts}`;
|
|
|
const checklist = opts.checklist ?? [];
|
|
|
const usedChecklistFallback =
|
|
|
events.length === 0 && quoteDetected && checklist.length > 0;
|
|
|
const playwrightScript = usedChecklistFallback
|
|
|
? buildAccessorialLabelPlaywright(checklist)
|
|
|
: formatHumanEventsAsPlaywright(events);
|
|
|
const payload: HumanAccessorialRecording & {
|
|
|
quoteDetected: boolean;
|
|
|
mode: string;
|
|
|
humanVerified?: boolean;
|
|
|
note?: string;
|
|
|
} = {
|
|
|
pathId: opts.pathId,
|
|
|
title: opts.title,
|
|
|
recordedAt: new Date().toISOString(),
|
|
|
expectedAccessorials: checklist,
|
|
|
events,
|
|
|
playwrightScript,
|
|
|
quoteDetected,
|
|
|
mode: "manual-only",
|
|
|
...(usedChecklistFallback
|
|
|
? {
|
|
|
humanVerified: true,
|
|
|
note: "人工实机勾选并出报价;DOM 事件未捕获,回放脚本由 11 项清单生成",
|
|
|
}
|
|
|
: {}),
|
|
|
};
|
|
|
const jsonPath = join(recordingsDir, `${base}.json`);
|
|
|
const jsPath = join(recordingsDir, `${base}.js`);
|
|
|
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 };
|
|
|
}
|
|
|
|
|
|
function printChecklist(opts: ManualRecordingOptions): void {
|
|
|
console.log("\n════════ 纯人工操作 — 脚本不自动填表 ════════");
|
|
|
console.log(`[manual] 路径: ${opts.pathId} — ${opts.title}`);
|
|
|
console.log("[manual] 请在浏览器中从头完成全部字段,直至出现报价。");
|
|
|
if (opts.checklist?.length) {
|
|
|
console.log("\n[manual] 参考清单(P08 附加服务 11 项):");
|
|
|
for (let i = 0; i < opts.checklist.length; i += 1) {
|
|
|
console.log(` ${i + 1}. ${opts.checklist[i]}`);
|
|
|
}
|
|
|
}
|
|
|
console.log("\n[manual] 操作会被持续记录。");
|
|
|
console.log("[manual] 出现报价后可继续核对;按 Ctrl+C 结束并保存录制。\n");
|
|
|
}
|
|
|
|
|
|
export async function runPriority1ManualRecordingSession(
|
|
|
opts: ManualRecordingOptions,
|
|
|
): Promise<void> {
|
|
|
const outDir =
|
|
|
opts.outDir ?? join(process.cwd(), ".rpa", "recordings", `${opts.pathId}-manual-session`);
|
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
|
|
|
const entryUrl = opts.entryUrl ?? PRIORITY1_FORM_META.entryUrl;
|
|
|
const script = loadRecorderScript();
|
|
|
const networkBodies: string[] = [];
|
|
|
|
|
|
const browser = await chromium.launch({
|
|
|
headless: false,
|
|
|
channel: (process.env.RPA_BROWSER_CHANNEL?.trim() || "chrome") as "chrome",
|
|
|
});
|
|
|
const context = await browser.newContext({
|
|
|
viewport: { width: 1400, height: 900 },
|
|
|
locale: "en-US",
|
|
|
});
|
|
|
const page = await context.newPage();
|
|
|
page.setDefaultTimeout(120_000);
|
|
|
|
|
|
attachQuoteNetworkCapture(page, networkBodies);
|
|
|
await page.addInitScript({ content: script });
|
|
|
|
|
|
console.log(`[manual] 打开: ${entryUrl}`);
|
|
|
await page.goto(entryUrl, { waitUntil: "domcontentloaded" });
|
|
|
await page.waitForSelector("#container-mh", { timeout: 60_000 }).catch(() => undefined);
|
|
|
await page.evaluate(script).catch(() => undefined);
|
|
|
|
|
|
printChecklist(opts);
|
|
|
|
|
|
const maxWait = opts.maxWaitMs ?? 2 * 60 * 60 * 1000;
|
|
|
const pollMs = 2_000;
|
|
|
const started = Date.now();
|
|
|
let lastEventCount = 0;
|
|
|
let quoteDetected = false;
|
|
|
let accumulatedEvents: HumanAssistEvent[] = [];
|
|
|
|
|
|
let shuttingDown = false;
|
|
|
|
|
|
const finish = async (reason: string) => {
|
|
|
if (shuttingDown) return;
|
|
|
shuttingDown = true;
|
|
|
const drained = await drainHumanAssistEvents(page).catch(() => [] as HumanAssistEvent[]);
|
|
|
const events =
|
|
|
drained.length >= accumulatedEvents.length ? drained : accumulatedEvents;
|
|
|
const { jsonPath, jsPath } = saveRecording(outDir, opts, events, quoteDetected);
|
|
|
console.log(`\n[manual] ${reason}`);
|
|
|
console.log(`[manual] 记录事件 ${events.length} 条`);
|
|
|
console.log(`[manual] JSON: ${jsonPath}`);
|
|
|
console.log(`[manual] JS: ${jsPath}`);
|
|
|
await context.close().catch(() => undefined);
|
|
|
await browser.close().catch(() => undefined);
|
|
|
process.exit(0);
|
|
|
};
|
|
|
|
|
|
process.once("SIGINT", () => {
|
|
|
void finish("用户 Ctrl+C — 已保存录制");
|
|
|
});
|
|
|
process.once("SIGTERM", () => {
|
|
|
void finish("收到终止信号 — 已保存录制");
|
|
|
});
|
|
|
|
|
|
while (Date.now() - started < maxWait && !shuttingDown) {
|
|
|
if (page.isClosed()) {
|
|
|
await finish("浏览器已关闭 — 已保存已记录操作");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
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(
|
|
|
`[manual] 记录 #${accumulatedEvents.length}: ${latest.kind} ${latest.label || latest.selector || latest.text.slice(0, 40)}`,
|
|
|
);
|
|
|
lastEventCount = accumulatedEvents.length;
|
|
|
}
|
|
|
|
|
|
const domText = await page.locator("body").innerText().catch(() => "");
|
|
|
const visible = extractPriority1VisibleQuotes(networkBodies, domText);
|
|
|
if (visible.length > 0 && !quoteDetected) {
|
|
|
quoteDetected = true;
|
|
|
console.log(`\n[manual] ✓ 已检测到报价 ${visible.length} 条(可继续核对,Ctrl+C 保存)`);
|
|
|
await page.screenshot({
|
|
|
path: join(outDir, "quote-result-full.png"),
|
|
|
fullPage: true,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
await page.waitForTimeout(pollMs).catch(async () => {
|
|
|
await finish("浏览器已关闭 — 已保存已记录操作");
|
|
|
});
|
|
|
}
|
|
|
|
|
|
await finish("等待超时 — 已保存录制");
|
|
|
}
|