import type { Page, Response } from "playwright"; const AXEL_HOST = "services.mothership.com/axel/location"; export type AxelTraceEvent = { at: number; phase: string; method: string; url: string; status: number; kind: "search" | "place" | "reverse-geocode" | "other"; placeId?: string; searchPlaceIds?: string[]; searchDescriptions?: string[]; }; function classifyAxelUrl(url: string): AxelTraceEvent["kind"] { if (url.includes("/axel/location/search")) { return "search"; } if (url.includes("/axel/location/place/reverse-geocode")) { return "reverse-geocode"; } if (url.includes("/axel/location/place/")) { return "place"; } return "other"; } function extractPlaceIdFromUrl(url: string): string | undefined { if (!url.includes("/axel/location/place/") || url.includes("reverse-geocode")) { return undefined; } const raw = url.split("/axel/location/place/")[1]?.split("?")[0]; return raw ? decodeURIComponent(raw) : undefined; } /** 将 axel/location 网络事件规范化为可 diff 的序列(供人工 vs RPA 对照) */ export function normalizeAxelSequence(events: AxelTraceEvent[]): string[] { const out: string[] = []; for (const event of events) { if (event.kind === "search") { const ids = (event.searchPlaceIds ?? []).map((id) => id.slice(0, 24)).join(","); const desc = (event.searchDescriptions ?? []) .map((d) => d.slice(0, 40)) .join(" | "); out.push( `${event.phase} POST search ${event.status}${ids ? ` placeIds=[${ids}]` : ""}${desc ? ` desc=[${desc}]` : ""}`, ); continue; } if (event.kind === "place") { out.push( `${event.phase} GET place/${event.placeId ?? "?"} ${event.status}`, ); continue; } if (event.kind === "reverse-geocode") { out.push(`${event.phase} GET reverse-geocode ${event.status}`); } } return out; } export function diffAxelTraces( manual: AxelTraceEvent[], rpa: AxelTraceEvent[], ): { manualSequence: string[]; rpaSequence: string[]; missingInRpa: string[]; extraInRpa: string[]; manualPlaceCommits: string[]; rpaPlaceCommits: string[]; verdict: string; } { const manualSequence = normalizeAxelSequence(manual); const rpaSequence = normalizeAxelSequence(rpa); const manualPlaceCommits = manual .filter((e) => e.kind === "place" && e.status === 200 && e.placeId) .map((e) => e.placeId!); const rpaPlaceCommits = rpa .filter((e) => e.kind === "place" && e.status === 200 && e.placeId) .map((e) => e.placeId!); const manualSet = new Set(manualSequence); const rpaSet = new Set(rpaSequence); const missingInRpa = manualSequence.filter((line) => !rpaSet.has(line)); const extraInRpa = rpaSequence.filter((line) => !manualSet.has(line)); let verdict: string; if (manualPlaceCommits.length === 0) { verdict = "MANUAL_NO_PLACE_COMMIT"; } else if (rpaPlaceCommits.length >= 2 && manualPlaceCommits.length >= 2) { verdict = "RPA_MATCHES_MANUAL_PLACE_COMMITS"; } else if (rpaPlaceCommits.length === 0 && manualPlaceCommits.length > 0) { verdict = "RPA_MISSING_PLACE_COMMIT"; } else { verdict = "PARTIAL_OR_MISMATCH"; } return { manualSequence, rpaSequence, missingInRpa, extraInRpa, manualPlaceCommits, rpaPlaceCommits, verdict, }; } /** 监听 axel/location 请求/响应,记录 search placeId 与 place commit */ export class AxelEventTracer { private events: AxelTraceEvent[] = []; private phase = "init"; private responseHandler: ((response: Response) => void) | null = null; setPhase(phase: string): void { this.phase = phase; } getPhase(): string { return this.phase; } attach(page: Page): void { this.detach(); this.responseHandler = (response: Response) => { void this.onResponse(response); }; page.on("response", this.responseHandler); } detach(): void { this.responseHandler = null; } getEvents(): readonly AxelTraceEvent[] { return this.events; } private async onResponse(response: Response): Promise { const url = response.url(); if (!url.includes(AXEL_HOST)) { return; } const kind = classifyAxelUrl(url); const event: AxelTraceEvent = { at: Date.now(), phase: this.phase, method: response.request().method(), url, status: response.status(), kind, placeId: extractPlaceIdFromUrl(url), }; if (kind === "search" && response.status() === 200) { try { const body = (await response.json()) as { googlePlacesResults?: Array<{ placeId?: string; description?: string; }>; }; event.searchPlaceIds = (body.googlePlacesResults ?? []) .map((row) => row.placeId?.trim()) .filter((id): id is string => Boolean(id)); event.searchDescriptions = (body.googlePlacesResults ?? []) .map((row) => row.description?.trim()) .filter((d): d is string => Boolean(d)); } catch { // ignore } } this.events.push(event); } }