|
|
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<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 响应
|
|
|
}
|
|
|
}
|
|
|
}
|