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/scripts/probe-axel-search-anatomy.ts

265 lines
8.6 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.

/**
* P1headed 解剖 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);
});