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