|
|
/**
|
|
|
* Flock RPA 卡顿侦测 — 单步超过 FLOCK_STALL_MS(默认 20s)自动截图留证
|
|
|
*/
|
|
|
import fs from "node:fs";
|
|
|
import path from "node:path";
|
|
|
import type { Page } from "playwright";
|
|
|
import {
|
|
|
isFlockQuoteLimitReached,
|
|
|
describeFlockPagePhase,
|
|
|
} from "@/workers/rpa/flock/page-state";
|
|
|
import { isFlockPageAlive } from "@/workers/rpa/flock/page-alive";
|
|
|
|
|
|
export type FlockStallCapture = {
|
|
|
step: string;
|
|
|
screenshotPath: string;
|
|
|
bodyPath: string;
|
|
|
url: string;
|
|
|
phase: string;
|
|
|
quoteLimit: boolean;
|
|
|
elapsedMs: number;
|
|
|
at: string;
|
|
|
skipped?: boolean;
|
|
|
};
|
|
|
|
|
|
export function readFlockStallMs(): number {
|
|
|
const raw = process.env.FLOCK_STALL_MS?.trim();
|
|
|
const n = raw ? Number(raw) : 20_000;
|
|
|
return Number.isFinite(n) && n >= 5_000 ? n : 20_000;
|
|
|
}
|
|
|
|
|
|
function stallOutputDir(): string {
|
|
|
const dir = path.join(process.cwd(), ".rpa", "flock-stalls");
|
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
return dir;
|
|
|
}
|
|
|
|
|
|
function safeStepName(step: string): string {
|
|
|
return step.replace(/[^\w\u4e00-\u9fa5-]+/g, "_").slice(0, 48);
|
|
|
}
|
|
|
|
|
|
/** 卡顿快照:截图 + 页面正文;页面已关则跳过(防 locator.count Target closed) */
|
|
|
export async function captureFlockStall(
|
|
|
page: Page,
|
|
|
step: string,
|
|
|
elapsedMs: number,
|
|
|
): Promise<FlockStallCapture> {
|
|
|
if (!isFlockPageAlive(page)) {
|
|
|
return {
|
|
|
step,
|
|
|
screenshotPath: "",
|
|
|
bodyPath: "",
|
|
|
url: "",
|
|
|
phase: "closed",
|
|
|
quoteLimit: false,
|
|
|
elapsedMs,
|
|
|
at: new Date().toISOString(),
|
|
|
skipped: true,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const dir = stallOutputDir();
|
|
|
const stamp = `${Date.now()}-${safeStepName(step)}`;
|
|
|
const screenshotPath = path.join(dir, `${stamp}.png`);
|
|
|
const bodyPath = path.join(dir, `${stamp}.txt`);
|
|
|
|
|
|
const body = ((await page.locator("body").innerText().catch(() => "")) || "")
|
|
|
.slice(0, 10_000);
|
|
|
if (!isFlockPageAlive(page)) {
|
|
|
return {
|
|
|
step,
|
|
|
screenshotPath: "",
|
|
|
bodyPath: "",
|
|
|
url: "",
|
|
|
phase: "closed",
|
|
|
quoteLimit: false,
|
|
|
elapsedMs,
|
|
|
at: new Date().toISOString(),
|
|
|
skipped: true,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const quoteLimit = await isFlockQuoteLimitReached(page).catch(() => false);
|
|
|
const phase = describeFlockPagePhase(body);
|
|
|
const url = (() => {
|
|
|
try {
|
|
|
return page.url();
|
|
|
} catch {
|
|
|
return "";
|
|
|
}
|
|
|
})();
|
|
|
|
|
|
await page
|
|
|
.screenshot({ path: screenshotPath, fullPage: true })
|
|
|
.catch(() => undefined);
|
|
|
fs.writeFileSync(
|
|
|
bodyPath,
|
|
|
[`url: ${url}`, `phase: ${phase}`, `quoteLimit: ${quoteLimit}`, "", body].join(
|
|
|
"\n",
|
|
|
),
|
|
|
"utf8",
|
|
|
);
|
|
|
|
|
|
const capture: FlockStallCapture = {
|
|
|
step,
|
|
|
screenshotPath,
|
|
|
bodyPath,
|
|
|
url,
|
|
|
phase,
|
|
|
quoteLimit,
|
|
|
elapsedMs,
|
|
|
at: new Date().toISOString(),
|
|
|
};
|
|
|
|
|
|
console.warn(
|
|
|
`[flock-stall] 步骤「${step}」已等待 ${elapsedMs}ms | 页面阶段=${phase} | 报价上限=${quoteLimit ? "是" : "否"}`,
|
|
|
);
|
|
|
console.warn(`[flock-stall] 截图: ${screenshotPath}`);
|
|
|
console.warn(`[flock-stall] 正文: ${bodyPath}`);
|
|
|
|
|
|
return capture;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 轮询等待条件,超过 stallMs 无进展则截图一次(每轮 wait 最多一张)
|
|
|
*/
|
|
|
export async function waitWithFlockStallWatch(
|
|
|
page: Page,
|
|
|
step: string,
|
|
|
predicate: () => Promise<boolean>,
|
|
|
options?: { timeoutMs?: number; pollMs?: number },
|
|
|
): Promise<{ ok: boolean; stall?: FlockStallCapture }> {
|
|
|
const timeoutMs = options?.timeoutMs ?? 90_000;
|
|
|
const pollMs = options?.pollMs ?? 500;
|
|
|
const stallMs = readFlockStallMs();
|
|
|
const started = Date.now();
|
|
|
let stallCaptured = false;
|
|
|
let lastStallMark = started;
|
|
|
|
|
|
while (Date.now() - started < timeoutMs) {
|
|
|
if (!isFlockPageAlive(page)) {
|
|
|
return { ok: false };
|
|
|
}
|
|
|
if (await predicate()) {
|
|
|
return { ok: true };
|
|
|
}
|
|
|
|
|
|
const now = Date.now();
|
|
|
if (!stallCaptured && now - lastStallMark >= stallMs) {
|
|
|
const stall = await captureFlockStall(page, step, now - started);
|
|
|
stallCaptured = true;
|
|
|
lastStallMark = now;
|
|
|
if (stall.quoteLimit) {
|
|
|
return { ok: false, stall };
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!isFlockPageAlive(page)) {
|
|
|
return { ok: false };
|
|
|
}
|
|
|
await page.waitForTimeout(pollMs).catch(() => undefined);
|
|
|
}
|
|
|
|
|
|
return { ok: false };
|
|
|
}
|
|
|
|
|
|
/** 包装耗时步骤:整体超过 stallMs 则截图(步骤结束后禁止再碰 page) */
|
|
|
export async function withFlockStallWatch<T>(
|
|
|
page: Page,
|
|
|
step: string,
|
|
|
fn: () => Promise<T>,
|
|
|
): Promise<T> {
|
|
|
const stallMs = readFlockStallMs();
|
|
|
const started = Date.now();
|
|
|
let captured = false;
|
|
|
let active = true;
|
|
|
|
|
|
const timer = setInterval(() => {
|
|
|
if (!active || captured) return;
|
|
|
if (Date.now() - started >= stallMs) {
|
|
|
captured = true;
|
|
|
void captureFlockStall(page, step, Date.now() - started).catch(
|
|
|
() => undefined,
|
|
|
);
|
|
|
}
|
|
|
}, 2_000);
|
|
|
|
|
|
try {
|
|
|
return await fn();
|
|
|
} finally {
|
|
|
active = false;
|
|
|
clearInterval(timer);
|
|
|
}
|
|
|
}
|