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.

185 lines
5.0 KiB

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<void> {
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);
}
}