|
|
/**
|
|
|
* P1:headed 解剖 Axel Search / 地址输入组件
|
|
|
*
|
|
|
* 自动化采集 gm_*、Fiber、联想 DOM、P2 fetch 栈;可选 page.pause() 供人工点选对照。
|
|
|
*
|
|
|
* PowerShell:
|
|
|
* $env:RPA_HEADLESS="false"
|
|
|
* $env:RPA_PLACE_DIAG="true"
|
|
|
* $env:PROBE_ANATOMY_PAUSE="true" # 打开 Playwright Inspector 暂停
|
|
|
* npm run probe:axel-anatomy
|
|
|
*/
|
|
|
process.env.RPA_PLACE_DIAG = "true";
|
|
|
process.env.RPA_HEADLESS = "false";
|
|
|
process.env.RPA_KEEP_BROWSER_OPEN = "true";
|
|
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
|
import { join } from "node:path";
|
|
|
import type { Page, Request, Response } from "playwright";
|
|
|
import { loadDevEnv } from "@/lib/dev-env";
|
|
|
import { hostFetchMothershipCandidates } from "@/lib/frontend/api-client";
|
|
|
import { applyMothershipCandidate } from "@/lib/frontend/mothership-address";
|
|
|
import {
|
|
|
SCAN_GLOBAL_AXEL_HOOKS,
|
|
|
SCAN_INPUT_GM_AND_DOM,
|
|
|
SCAN_REACT_FIBER_ADDRESS,
|
|
|
SCAN_SUGGESTION_ROWS,
|
|
|
} from "@/workers/rpa/fix-place/axel-anatomy-scripts";
|
|
|
import {
|
|
|
P0_PLACE_DIAG_INIT,
|
|
|
P2_FETCH_HIJACK_INIT,
|
|
|
} from "@/workers/rpa/fix-place/place-diagnostic-init";
|
|
|
import {
|
|
|
collectPlaceDiagSnapshot,
|
|
|
interpretPlaceDiagnostic,
|
|
|
} from "@/workers/rpa/fix-place/place-diagnostic-probe";
|
|
|
import { openAddressSide, visibleStreetInput } from "@/workers/rpa/address-side";
|
|
|
import { ensureAddressSuggestions } from "@/workers/rpa/address-suggestions";
|
|
|
import { RPAContext } from "@/workers/rpa/kernel/context";
|
|
|
import {
|
|
|
stabilizeQuoteLandingPage,
|
|
|
waitForQuoterWidgetHydrated,
|
|
|
} from "@/workers/rpa/page-prep";
|
|
|
import { closeBrowser, openQuotePage } from "@/workers/rpa/session-manager";
|
|
|
import { quotePageAdapter } from "@/workers/rpa/quote-page-adapter";
|
|
|
import { waitForStyledSuggestionRows } from "@/workers/rpa/mothership-styled-suggestion-click";
|
|
|
|
|
|
loadDevEnv();
|
|
|
process.env.RPA_MOCK_MODE = "false";
|
|
|
process.env.RPA_ADDRESS_MODE = "real";
|
|
|
|
|
|
const BASE = process.env.PROVE_BASE_URL ?? "http://localhost:3000";
|
|
|
const TOKEN = "demo-host-token";
|
|
|
const CUSTOMER_ID = "CUST_001";
|
|
|
const OUT_DIR = join(process.cwd(), ".rpa", "axel-anatomy");
|
|
|
|
|
|
const PICKUP = {
|
|
|
street: "Washington State Convention Center Main Garage, Pike Street",
|
|
|
city: "Seattle",
|
|
|
state: "WA",
|
|
|
} as const;
|
|
|
|
|
|
type NetEvent = {
|
|
|
phase: string;
|
|
|
method: string;
|
|
|
url: string;
|
|
|
status?: number;
|
|
|
headers: Record<string, string>;
|
|
|
at: number;
|
|
|
};
|
|
|
|
|
|
function evalScan<T>(page: Page, fn: string): Promise<T> {
|
|
|
return page.evaluate((body) => {
|
|
|
const runner = eval(`(${body})`) as () => T;
|
|
|
return runner();
|
|
|
}, fn);
|
|
|
}
|
|
|
|
|
|
function attachNetworkTap(page: Page, phase: string, log: NetEvent[]): void {
|
|
|
const onReq = (req: Request): void => {
|
|
|
const url = req.url();
|
|
|
if (!url.includes("axel/location/")) {
|
|
|
return;
|
|
|
}
|
|
|
const headers = req.headers();
|
|
|
const axelHeaders: Record<string, string> = {};
|
|
|
for (const [k, v] of Object.entries(headers)) {
|
|
|
if (/x-axel|x-mothership|authorization|cookie/i.test(k)) {
|
|
|
axelHeaders[k] = k.toLowerCase() === "cookie" ? "(redacted)" : v;
|
|
|
}
|
|
|
}
|
|
|
log.push({
|
|
|
phase,
|
|
|
method: req.method(),
|
|
|
url: url.slice(0, 280),
|
|
|
headers: axelHeaders,
|
|
|
at: Date.now(),
|
|
|
});
|
|
|
};
|
|
|
const onRes = (res: Response): void => {
|
|
|
const url = res.url();
|
|
|
if (!url.includes("axel/location/place/")) {
|
|
|
return;
|
|
|
}
|
|
|
const row = log.find((e) => e.url === url.slice(0, 280) && e.status === undefined);
|
|
|
if (row) {
|
|
|
row.status = res.status();
|
|
|
} else {
|
|
|
log.push({
|
|
|
phase,
|
|
|
method: res.request().method(),
|
|
|
url: url.slice(0, 280),
|
|
|
status: res.status(),
|
|
|
headers: {},
|
|
|
at: Date.now(),
|
|
|
});
|
|
|
}
|
|
|
};
|
|
|
page.on("request", onReq);
|
|
|
page.on("response", onRes);
|
|
|
}
|
|
|
|
|
|
async function capturePhase(
|
|
|
page: Page,
|
|
|
phase: string,
|
|
|
): Promise<Record<string, unknown>> {
|
|
|
const [globals, inputs, fiber, suggestions, p0] = await Promise.all([
|
|
|
evalScan<Record<string, unknown>>(page, SCAN_GLOBAL_AXEL_HOOKS),
|
|
|
evalScan<Record<string, unknown>>(page, SCAN_INPUT_GM_AND_DOM),
|
|
|
evalScan<Record<string, unknown>>(page, SCAN_REACT_FIBER_ADDRESS),
|
|
|
evalScan<Record<string, unknown>>(page, SCAN_SUGGESTION_ROWS),
|
|
|
collectPlaceDiagSnapshot(page),
|
|
|
]);
|
|
|
return {
|
|
|
phase,
|
|
|
at: new Date().toISOString(),
|
|
|
globals,
|
|
|
inputs,
|
|
|
fiber,
|
|
|
suggestions,
|
|
|
p0,
|
|
|
p0Interpretation: interpretPlaceDiagnostic(p0, phase),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function summarizeVerdict(phases: Record<string, unknown>[]): string[] {
|
|
|
const lines: string[] = ["=== 解剖结论(自动)==="];
|
|
|
const afterSearch = phases.find((p) => p.phase === "after-search");
|
|
|
const inputScan = (afterSearch?.inputs ?? {}) as {
|
|
|
inputs?: Array<{ gmKeys?: string[]; pacTarget?: boolean }>;
|
|
|
pacItemCount?: number;
|
|
|
styledRowCount?: number;
|
|
|
};
|
|
|
const gmInputs = (inputScan.inputs ?? []).filter((r) => (r.gmKeys?.length ?? 0) > 0);
|
|
|
if (gmInputs.length > 0) {
|
|
|
lines.push("→ 输入框存在 gm_*:底层可能仍用 Google PAC,但 P0 未 patch 到(或实例在 iframe/早于 patch)");
|
|
|
} else {
|
|
|
lines.push("→ 输入框无 gm_*:倾向 Axel 自研 Search UI(非标准 PAC 输入)");
|
|
|
}
|
|
|
if ((inputScan.pacItemCount ?? 0) > 0) {
|
|
|
lines.push(`→ 可见 .pac-item × ${inputScan.pacItemCount}`);
|
|
|
}
|
|
|
if ((inputScan.styledRowCount ?? 0) > 0) {
|
|
|
lines.push(`→ styled-components 联想行容器 × ${inputScan.styledRowCount}`);
|
|
|
}
|
|
|
const fiberHits = ((afterSearch?.fiber as { hits?: unknown[] })?.hits ?? []).length;
|
|
|
lines.push(`→ React Fiber 地址相关节点 × ${fiberHits}`);
|
|
|
return lines;
|
|
|
}
|
|
|
|
|
|
async function main(): Promise<void> {
|
|
|
const runId = randomUUID().slice(0, 8);
|
|
|
mkdirSync(OUT_DIR, { recursive: true });
|
|
|
const netLog: NetEvent[] = [];
|
|
|
const phases: Record<string, unknown>[] = [];
|
|
|
|
|
|
const candRes = await hostFetchMothershipCandidates(
|
|
|
BASE,
|
|
|
TOKEN,
|
|
|
CUSTOMER_ID,
|
|
|
{ ...PICKUP, zip: "" },
|
|
|
{ street: "Ocean Drive", city: "Miami Beach", state: "FL", zip: "" },
|
|
|
);
|
|
|
if (candRes.code !== 0 || !candRes.data?.pickup_candidates[0]) {
|
|
|
throw new Error("候选 API 失败");
|
|
|
}
|
|
|
const pickupCand = candRes.data.pickup_candidates[0];
|
|
|
const label = pickupCand.display_label;
|
|
|
|
|
|
console.log(`\n=== Axel Search 解剖 run=${runId} ===`);
|
|
|
console.log(`地址:${label}\n`);
|
|
|
|
|
|
const opened = await openQuotePage(quotePageAdapter, false, undefined, {
|
|
|
freshCargoContext: true,
|
|
|
});
|
|
|
await opened.context.addInitScript({ content: P0_PLACE_DIAG_INIT });
|
|
|
await opened.context.addInitScript({ content: P2_FETCH_HIJACK_INIT });
|
|
|
await opened.page.reload({ waitUntil: "domcontentloaded" });
|
|
|
|
|
|
const ctx = RPAContext.fromSession(opened.page, opened.context);
|
|
|
attachNetworkTap(ctx.page, "session", netLog);
|
|
|
|
|
|
await stabilizeQuoteLandingPage(ctx);
|
|
|
await waitForQuoterWidgetHydrated(ctx.page, 90_000);
|
|
|
|
|
|
phases.push(await capturePhase(ctx.page, "widget-hydrated"));
|
|
|
|
|
|
await openAddressSide(ctx, "pickup");
|
|
|
const street = await visibleStreetInput(ctx, "pickup");
|
|
|
if (!street) {
|
|
|
throw new Error("提货输入框不可见");
|
|
|
}
|
|
|
|
|
|
phases.push(await capturePhase(ctx.page, "pickup-input-focused"));
|
|
|
|
|
|
await ensureAddressSuggestions(ctx.page, street, PICKUP, {
|
|
|
mothershipLabel: label,
|
|
|
preferConfirmedLabel: true,
|
|
|
});
|
|
|
await waitForStyledSuggestionRows(ctx.page, [], PICKUP, 15_000);
|
|
|
|
|
|
phases.push(await capturePhase(ctx.page, "after-search"));
|
|
|
|
|
|
console.log("\n--- 人工步骤(headed)---");
|
|
|
console.log("1. 在浏览器中手动点选一条联想");
|
|
|
console.log("2. DevTools → Elements:检查 input 是否有 gm_*");
|
|
|
console.log("3. DevTools → Network:点选后是否 GET axel/location/place/{id}");
|
|
|
console.log("4. React DevTools:找 Search/Candidate/Place 组件与 onSelect");
|
|
|
console.log("5. 完成后在 Playwright Inspector 点 Resume\n");
|
|
|
|
|
|
if (process.env.PROBE_ANATOMY_PAUSE !== "false") {
|
|
|
await ctx.page.pause();
|
|
|
}
|
|
|
|
|
|
phases.push(await capturePhase(ctx.page, "after-manual-or-pause"));
|
|
|
|
|
|
const report = {
|
|
|
runId,
|
|
|
label,
|
|
|
placeId: pickupCand.option_id,
|
|
|
phases,
|
|
|
network: netLog,
|
|
|
verdict: summarizeVerdict(phases),
|
|
|
finishedAt: new Date().toISOString(),
|
|
|
};
|
|
|
|
|
|
const reportPath = join(OUT_DIR, `${runId}-anatomy.json`);
|
|
|
writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf8");
|
|
|
|
|
|
console.log("\n" + report.verdict.join("\n"));
|
|
|
console.log(`\n报告:${reportPath}`);
|
|
|
console.log("浏览器保持打开(RPA_KEEP_BROWSER_OPEN=true),关闭窗口或 Ctrl+C 结束。");
|
|
|
|
|
|
if (process.env.PROBE_ANATOMY_CLOSE === "true") {
|
|
|
await opened.page.close().catch(() => undefined);
|
|
|
await opened.context.close().catch(() => undefined);
|
|
|
await closeBrowser();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
main().catch((error) => {
|
|
|
console.error(error);
|
|
|
process.exit(1);
|
|
|
});
|