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.
chajia/workers/rpa/axel-selection-bridge.ts

531 lines
15 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import type { Page, Response } from "playwright";
import type { AddressLookupInput } from "@/modules/address/types";
import { scoreSuggestionLabelMatch } from "@/workers/rpa/address-suggestions";
const AXEL_SEARCH_URL = "services.mothership.com/axel/location/search";
const AXEL_PLACE_PREFIX = "services.mothership.com/axel/location/place/";
const MIN_LABEL_MATCH_SCORE = 50;
const GOOGLE_PLACE_ID_STD_LEN = 27;
const GOOGLE_PLACE_ID_STD_RE = /^ChIJ[A-Za-z0-9_-]{23}$/;
const GOOGLE_PLACE_ID_LOOSE_RE = /ChIJ[A-Za-z0-9_-]+/;
function extractStandardPlaceId(text: string): string | null {
const idx = text.indexOf("ChIJ");
if (idx < 0) {
return null;
}
const slice = text.slice(idx, idx + GOOGLE_PLACE_ID_STD_LEN);
return GOOGLE_PLACE_ID_STD_RE.test(slice) ? slice : null;
}
function decodeEmbeddedPlaceId(trimmed: string): string | null {
const fromText = extractStandardPlaceId(trimmed);
if (fromText) {
return fromText;
}
try {
const b64 = trimmed.replace(/-/g, "+").replace(/_/g, "/");
const pad = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const decoded = Buffer.from(pad, "base64").toString("latin1");
return extractStandardPlaceId(decoded);
} catch {
return null;
}
}
/** MotherShip search 有时返回 base64 复合 placeIdcommit URL 使用内嵌 27 字符 ChIJ… */
export function normalizeAxelPlaceId(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return trimmed;
}
const embedded = decodeEmbeddedPlaceId(trimmed);
if (embedded) {
return embedded;
}
const loose = trimmed.match(GOOGLE_PLACE_ID_LOOSE_RE);
if (loose) {
const trunc = loose[0]!.slice(0, GOOGLE_PLACE_ID_STD_LEN);
if (GOOGLE_PLACE_ID_STD_RE.test(trunc)) {
return trunc;
}
return loose[0]!;
}
return trimmed;
}
/** GET place 候选:先 ChIJ 标准 id再原始复合 id */
export function axelPlaceFetchCandidates(raw: string): string[] {
const trimmed = raw.trim();
if (!trimmed) {
return [];
}
const normalized = normalizeAxelPlaceId(trimmed);
return normalized === trimmed ? [trimmed] : [normalized, trimmed];
}
export type AxelPlaceCandidate = {
placeId: string;
description: string;
mainText?: string;
secondaryText?: string;
};
export type AxelSelectionPayload = {
label: string;
placeId: string;
description: string;
};
type SearchResponseBody = {
googlePlacesResults?: Array<{
placeId?: string;
description?: string;
mainText?: string;
secondaryText?: string;
}>;
};
export type AxelPlaceNetworkEvent = {
placeId: string;
status: number;
url: string;
at: number;
};
// #region agent log
function debugAxelLog(
hypothesisId: string,
location: string,
message: string,
data: Record<string, unknown>,
): void {
fetch("http://127.0.0.1:7489/ingest/ff976922-7b71-4f91-ad84-5521d8ba1420", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "efb842",
},
body: JSON.stringify({
sessionId: "efb842",
hypothesisId,
location,
message,
data,
timestamp: Date.now(),
}),
}).catch(() => undefined);
}
// #endregion
export function formatPlaceCommitFailure(
expectedPlaceId: string,
attemptPaths: string[],
diag: {
committedPlaceIds: string[];
placeNetworkLog: AxelPlaceNetworkEvent[];
},
): string {
const paths = attemptPaths.join(" → ") || "none";
const { placeNetworkLog, committedPlaceIds } = diag;
if (placeNetworkLog.length === 0) {
return [
`place/${expectedPlaceId} commit 失败`,
"原因widget 未发起 GET place 请求",
"说明:点选未触发 MotherShip selection handler可能仅更新了 input 文本)",
`路径:${paths}`,
].join("");
}
const forExpected = placeNetworkLog.filter(
(event) => event.placeId === expectedPlaceId,
);
if (forExpected.length === 0) {
const actual = placeNetworkLog
.map((event) => `${event.placeId.slice(0, 20)}:${event.status}`)
.join(", ");
return [
`place/${expectedPlaceId} commit 失败`,
"原因placeId 不匹配",
`widget 实际请求:${actual}`,
`路径:${paths}`,
].join("");
}
const nonOk = forExpected.filter((event) => event.status !== 200);
if (nonOk.length > 0) {
return [
`place/${expectedPlaceId} commit 失败`,
`原因HTTP ${nonOk.map((event) => event.status).join(",")}`,
`路径:${paths}`,
].join("");
}
if (!committedPlaceIds.includes(expectedPlaceId)) {
return [
`place/${expectedPlaceId} commit 失败`,
"原因:等待 place 200 超时",
`路径:${paths}`,
].join("");
}
return `place/${expectedPlaceId} commit 失败;路径:${paths}`;
}
/** search response → placeId 绑定 → commit 网络验签 */
export class AxelSelectionBridge {
private candidates: AxelPlaceCandidate[] = [];
private committedPlaceIds = new Set<string>();
private responseHandler: ((response: Response) => void) | null = null;
private attachedPage: Page | null = null;
private placeNetworkLog: AxelPlaceNetworkEvent[] = [];
private searchIngestCount = 0;
attach(page: Page): void {
if (this.attachedPage === page) {
return;
}
this.detach();
this.attachedPage = page;
this.responseHandler = (response: Response) => {
void this.handleResponse(response);
};
page.on("response", this.responseHandler);
// #region agent log
debugAxelLog("H1", "axel-selection-bridge.ts:attach", "bridge-attached", {
pageUrl: page.url(),
});
// #endregion
}
detach(): void {
if (this.attachedPage && this.responseHandler) {
this.attachedPage.off("response", this.responseHandler);
}
this.attachedPage = null;
this.responseHandler = null;
}
clearCandidates(): void {
this.candidates = [];
this.placeNetworkLog = [];
this.searchIngestCount = 0;
}
getPlaceCommitDiagnostics(expectedPlaceId: string): {
expectedPlaceId: string;
committedPlaceIds: string[];
placeNetworkLog: AxelPlaceNetworkEvent[];
searchCandidateCount: number;
searchIngestCount: number;
} {
return {
expectedPlaceId,
committedPlaceIds: [...this.committedPlaceIds],
placeNetworkLog: [...this.placeNetworkLog],
searchCandidateCount: this.candidates.length,
searchIngestCount: this.searchIngestCount,
};
}
/** 供单测与录证脚本注入 search 结果 */
ingestSearchResults(body: SearchResponseBody): void {
const rows = body.googlePlacesResults ?? [];
for (const row of rows) {
const placeId = row.placeId?.trim();
const description = row.description?.trim();
if (!placeId || !description) {
continue;
}
this.candidates.push({
placeId: normalizeAxelPlaceId(placeId),
description,
mainText: row.mainText?.trim(),
secondaryText: row.secondaryText?.trim(),
});
}
}
private placeDetailsById = new Map<string, Record<string, unknown>>();
setPlaceDetails(placeId: string, location: Record<string, unknown>): void {
const normalized = normalizeAxelPlaceId(placeId);
if (normalized) {
this.placeDetailsById.set(normalized, location);
}
}
getPlaceDetails(placeId: string): Record<string, unknown> | undefined {
return this.placeDetailsById.get(normalizeAxelPlaceId(placeId));
}
/** 供单测注入 place commit */
recordPlaceCommit(placeId: string): void {
const normalized = normalizeAxelPlaceId(placeId);
if (normalized) {
this.committedPlaceIds.add(normalized);
}
}
/** Node 侧 GET place 成功但 widget 未触发 commit 时合成网络验签 */
recordSyntheticPlaceCommit(placeId: string, status = 200): void {
const normalized = normalizeAxelPlaceId(placeId);
if (!normalized) {
return;
}
this.committedPlaceIds.add(normalized);
this.placeNetworkLog.push({
placeId: normalized,
status,
url: `${AXEL_PLACE_PREFIX}${normalized}?synthetic=1`,
at: Date.now(),
});
}
getCandidates(): readonly AxelPlaceCandidate[] {
return this.candidates;
}
hasSearchResults(): boolean {
return this.candidates.length > 0;
}
hasPlaceCommit(placeId: string): boolean {
return this.committedPlaceIds.has(normalizeAxelPlaceId(placeId));
}
/** 诊断bridge 已观测到的 place commit placeId 列表 */
getObservedPlaceCommitIds(): string[] {
return [...this.committedPlaceIds];
}
/** 是否观测到 GET place/{placeId} 且 HTTP 200 */
hasSuccessfulPlaceNetworkRequest(placeId: string): boolean {
const normalized = normalizeAxelPlaceId(placeId);
return this.placeNetworkLog.some(
(event) => event.placeId === normalized && event.status === 200,
);
}
getPlaceNetworkLog(): readonly AxelPlaceNetworkEvent[] {
return [...this.placeNetworkLog];
}
/** label → { label, placeId, description },禁止无 placeId 的纯 DOM 选择 */
resolveSelection(
label: string,
query?: AddressLookupInput,
): AxelSelectionPayload | null {
let best: { candidate: AxelPlaceCandidate; score: number } | null = null;
let pool = this.candidates;
const streetNum = query?.street.match(/^\d+/)?.[0];
if (streetNum) {
const numbered = pool.filter((candidate) => {
const texts = [candidate.description, candidate.mainText].filter(
(text): text is string => Boolean(text?.trim()),
);
return texts.some((text) => text.includes(streetNum));
});
if (numbered.length > 0) {
pool = numbered;
}
}
for (const candidate of pool) {
const probeTexts = [
candidate.description,
candidate.mainText && candidate.secondaryText
? `${candidate.mainText}, ${candidate.secondaryText}`
: null,
candidate.mainText,
].filter((text): text is string => Boolean(text?.trim()));
for (const probe of probeTexts) {
const score = scoreSuggestionLabelMatch(label, probe, query);
if (!best || score > best.score) {
best = { candidate, score };
}
}
}
if (!best || best.score < MIN_LABEL_MATCH_SCORE) {
return null;
}
return {
label,
placeId: best.candidate.placeId,
description: best.candidate.description,
};
}
/** 等待 axel/location/search 结果被 bridge 捕获 */
async waitForSearchResults(timeoutMs: number): Promise<boolean> {
if (this.candidates.length > 0) {
return true;
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (this.candidates.length > 0) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
return false;
}
getCandidateIndex(placeId: string): number {
const normalized = normalizeAxelPlaceId(placeId);
return this.candidates.findIndex((item) => item.placeId === normalized);
}
async waitForPlaceCommit(
placeId: string,
timeoutMs: number,
): Promise<boolean> {
const normalized = normalizeAxelPlaceId(placeId);
if (this.hasPlaceCommit(normalized)) {
// #region agent log
debugAxelLog("H5", "axel-selection-bridge.ts:waitForPlaceCommit", "already-committed", {
placeId,
});
// #endregion
return true;
}
const page = this.attachedPage;
if (!page) {
return false;
}
const placeUrl = `${AXEL_PLACE_PREFIX}${normalized}`;
const raced = await Promise.race([
page
.waitForResponse(
(response) => {
const url = response.url();
return (
url.includes(AXEL_PLACE_PREFIX) &&
(url.includes(normalized) || url.includes(encodeURIComponent(normalized))) &&
response.status() === 200
);
},
{ timeout: timeoutMs },
)
.then((response) => {
this.committedPlaceIds.add(normalized);
return response.status() === 200;
}),
new Promise<boolean>((resolve) => {
const deadline = Date.now() + timeoutMs;
const poll = (): void => {
if (this.hasPlaceCommit(normalized)) {
resolve(true);
return;
}
if (Date.now() >= deadline) {
resolve(false);
return;
}
setTimeout(poll, 100);
};
poll();
}),
]).catch(() => false);
// #region agent log
debugAxelLog("H2", "axel-selection-bridge.ts:waitForPlaceCommit", "wait-finished", {
placeId: normalized,
rawPlaceId: placeId.slice(0, 48),
committed: raced,
timeoutMs,
diagnostics: this.getPlaceCommitDiagnostics(placeId),
});
// #endregion
return raced;
}
private recordPlaceNetwork(url: string, status: number): void {
if (!url.includes(AXEL_PLACE_PREFIX) || url.includes("reverse-geocode")) {
return;
}
const placeId = normalizeAxelPlaceId(
decodeURIComponent(
url.split("/axel/location/place/")[1]?.split("?")[0] ?? "",
),
);
if (!placeId) {
return;
}
this.placeNetworkLog.push({
placeId,
status,
url,
at: Date.now(),
});
// #region agent log
debugAxelLog("H4", "axel-selection-bridge.ts:recordPlaceNetwork", "place-request", {
placeId,
status,
urlTail: url.slice(-80),
});
// #endregion
}
private async handleResponse(response: Response): Promise<void> {
const url = response.url();
if (url.includes(AXEL_PLACE_PREFIX) && !url.includes("reverse-geocode")) {
this.recordPlaceNetwork(url, response.status());
}
if (response.status() !== 200) {
return;
}
if (url.includes(AXEL_PLACE_PREFIX) && !url.includes("reverse-geocode")) {
const placeId = normalizeAxelPlaceId(
decodeURIComponent(
url.split("/axel/location/place/")[1]?.split("?")[0] ?? "",
),
);
if (placeId) {
this.committedPlaceIds.add(placeId);
}
return;
}
if (!url.includes(AXEL_SEARCH_URL) || response.request().method() !== "POST") {
return;
}
try {
const body = (await response.json()) as SearchResponseBody;
const before = this.candidates.length;
this.ingestSearchResults(body);
const added = this.candidates.length - before;
if (added > 0) {
this.searchIngestCount += 1;
// #region agent log
debugAxelLog("H1", "axel-selection-bridge.ts:handleResponse", "search-ingested", {
added,
totalCandidates: this.candidates.length,
placeIds: this.candidates.slice(-added).map((c) => c.placeId.slice(0, 20)),
});
// #endregion
}
} catch {
// 忽略非 JSON 响应
}
}
}