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.

210 lines
7.3 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.

/**
* 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("等待超时 — 已保存录制");
}