/** * Flock RPA 可观测进度(入库 quotes_json.__flock_progress,轮询回传前端) */ export const FLOCK_RPA_STAGES = [ "queued", "lock", "login", "open_quote", "fill_form", "submit", "wait_quote", "done", ] as const; export type FlockRpaStage = (typeof FLOCK_RPA_STAGES)[number]; export const FLOCK_RPA_STAGE_LABELS: Record = { queued: "任务已入队,等待 Worker 领取", lock: "获取 Flock 官网互斥锁", login: "登录 Flock 官网(填写账密)", open_quote: "打开登录态查价页", fill_form: "填写 Quick 查价表单", submit: "点击生成报价", wait_quote: "等待并解析官网报价", done: "报价完成", }; export type FlockProgressPayload = { stage: FlockRpaStage; label: string; at: string; }; export type FlockStageReporter = ( stage: FlockRpaStage, ) => void | Promise; export async function reportFlockStage( onStage: FlockStageReporter | undefined, stage: FlockRpaStage, ): Promise { try { await onStage?.(stage); } catch { /* 进度回写失败不阻断查价 */ } } export function buildFlockProgressPayload( stage: FlockRpaStage, ): { __flock_progress: FlockProgressPayload } { return { __flock_progress: { stage, label: FLOCK_RPA_STAGE_LABELS[stage], at: new Date().toISOString(), }, }; } export function parseFlockProgress( quotesJson: unknown, ): FlockProgressPayload | null { if (!quotesJson || typeof quotesJson !== "object") return null; const raw = (quotesJson as Record).__flock_progress; if (!raw || typeof raw !== "object") return null; const o = raw as Record; const stage = o.stage; if (typeof stage !== "string") return null; if (!(FLOCK_RPA_STAGES as readonly string[]).includes(stage)) return null; const label = typeof o.label === "string" && o.label ? o.label : FLOCK_RPA_STAGE_LABELS[stage as FlockRpaStage]; return { stage: stage as FlockRpaStage, label, at: typeof o.at === "string" ? o.at : "", }; } /** 根据失败文案定位卡在哪一步;matched=false 表示未识别,勿用返回 stage 覆盖 Worker 进度 */ export function mapFlockErrorToStage(errorMessage?: string | null): { stage: FlockRpaStage; hint: string; matched: boolean; } { const msg = errorMessage ?? ""; if (/FLOCK_LOGIN_FAILED|无法填写登录表单|账密但解密失败|账号或密码错误|PROVIDER_LOGIN_FAILED/i.test(msg)) { return { stage: "login", hint: "卡在登录:账号或密码可能不正确。请确认承运商账密是否正确,以及修改后是否已在本系统即时保存更新。", matched: true, }; } if (/SESSION_EXPIRED/i.test(msg)) { return { stage: "login", hint: "卡在登录:会话失效且重登失败。请确认承运商账密是否正确,以及修改后是否已在本系统即时保存更新。", matched: true, }; } if (/QUOTE_ENTRY_UNAVAILABLE|STRUCT_CHANGE:.*查价|表单未加载/i.test(msg)) { return { stage: "open_quote", hint: "卡在打开查价页:登录后未进入 Let's build a quote,请确认 quote URL 与账密权限。", matched: true, }; } if (/无法选择|无法填写|无法定位|QUOTE_ENTRY_UNAVAILABLE:无法/i.test(msg)) { return { stage: "fill_form", hint: "卡在填表:某个字段定位失败,请对照官网字段或更新 RPA 选项映射。", matched: true, }; } if (/未找到提交按钮|Generate my quote/i.test(msg)) { return { stage: "submit", hint: "卡在提交:未找到「Generate my quote」按钮。", matched: true, }; } if (/FLOCK_RATES_PENDING|still looking for the best rates|约需\s*15\s*分钟/i.test(msg)) { return { stage: "wait_quote", hint: "官网已出单但尚未给出即时报价(仍在寻价约 15 分钟);本单已结束,请稍后用参考编号回查,勿重复提交。", matched: true, }; } if ( /RPA_DATA_INVALID|未拿到报价|flock-stalls|未返回 Flock|报价档位/i.test(msg) ) { return { stage: "wait_quote", hint: "卡在取价:已提交但未解析到报价,可查看 .rpa/flock-stalls/ 截图。", matched: true, }; } if (/FLOCK_QUOTE_LIMIT|Thank you for your interest/i.test(msg)) { return { stage: "wait_quote", hint: "卡在取价:官网报价次数达上限,请换账号或稍后重试。", matched: true, }; } if ( /Target (page|closed)|browser.*(closed|disconnected)|Page crashed|Execution context was destroyed|locator\.count|has been closed|BROWSER_CLOSED/i.test( msg, ) ) { return { stage: "wait_quote", hint: "卡在取价:浏览器会话中断(关窗竞态/页跳转销毁上下文),请勿手动关窗并重试。", matched: true, }; } if (/询价超时|请稍后重试(可在进度条|POLL|timeout/i.test(msg)) { return { stage: "wait_quote", hint: "卡在取价:前端轮询超时,Worker 可能仍在跑;请稍候或刷新后查看历史询价。", matched: true, }; } if (/互斥|exclusive|busy|占锁|防互踩/i.test(msg)) { return { stage: "lock", hint: "卡在互斥锁:其他 Flock 任务正在占用浏览器,请稍候。", matched: true, }; } // 未识别:禁止默认钉在「互斥锁」(会误导);保留 wait_quote 作展示用,matched=false 不覆盖 Worker 阶段 return { stage: "wait_quote", hint: "查价失败,请根据下方错误明细排查 Worker 与官网状态。", matched: false, }; } /** 失败时应否把 progress 写成 mapped.stage(禁止把已走过的阶段回退到 lock) */ export function shouldOverwriteFlockFailureStage( currentStage: string | null | undefined, mapped: { stage: FlockRpaStage; matched: boolean }, ): boolean { if (!mapped.matched) { return false; } if ( !currentStage || !(FLOCK_RPA_STAGES as readonly string[]).includes(currentStage) ) { return true; } return ( flockStageIndex(mapped.stage) >= flockStageIndex(currentStage as FlockRpaStage) ); } export function flockStageIndex(stage: FlockRpaStage): number { return FLOCK_RPA_STAGES.indexOf(stage); }