/** * 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 { 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, 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( page: Page, step: string, fn: () => Promise, ): Promise { 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); } }