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.

194 lines
4.8 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.

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