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 复合 placeId,commit 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, ): 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(); 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>(); setPlaceDetails(placeId: string, location: Record): void { const normalized = normalizeAxelPlaceId(placeId); if (normalized) { this.placeDetailsById.set(normalized, location); } } getPlaceDetails(placeId: string): Record | 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 { 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 { 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((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 { 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 响应 } } }