|
|
import type { Page } from "playwright";
|
|
|
|
|
|
/** 候选出现后 / 点选前后:焦点、combobox、pac、listbox 快照(仅取证,不改交互) */
|
|
|
export type SelectionTraceSnapshot = {
|
|
|
label: string;
|
|
|
at: number;
|
|
|
activeElement: {
|
|
|
tagName: string;
|
|
|
role: string | null;
|
|
|
className: string;
|
|
|
id: string;
|
|
|
ariaExpanded: string | null;
|
|
|
ariaAutocomplete: string | null;
|
|
|
ariaActivedescendant: string | null;
|
|
|
placeholder: string | null;
|
|
|
valuePreview: string;
|
|
|
isPacTarget: boolean;
|
|
|
} | null;
|
|
|
activeDescendant: {
|
|
|
id: string;
|
|
|
tagName: string;
|
|
|
role: string | null;
|
|
|
className: string;
|
|
|
ariaSelected: string | null;
|
|
|
textPreview: string;
|
|
|
} | null;
|
|
|
pac: {
|
|
|
visible: boolean;
|
|
|
itemCount: number;
|
|
|
highlightedIndex: number;
|
|
|
items: Array<{ index: number; className: string; textPreview: string }>;
|
|
|
};
|
|
|
listboxOptions: Array<{
|
|
|
index: number;
|
|
|
tagName: string;
|
|
|
role: string | null;
|
|
|
className: string;
|
|
|
ariaSelected: string | null;
|
|
|
textPreview: string;
|
|
|
}>;
|
|
|
widgetInputs: Array<{
|
|
|
tagName: string;
|
|
|
role: string | null;
|
|
|
className: string;
|
|
|
placeholder: string;
|
|
|
ariaActivedescendant: string | null;
|
|
|
isPacTarget: boolean;
|
|
|
valuePreview: string;
|
|
|
}>;
|
|
|
comboboxAlive: boolean;
|
|
|
};
|
|
|
|
|
|
export type ManualInteractionRecord = {
|
|
|
kind: "click" | "keydown";
|
|
|
at: number;
|
|
|
key?: string;
|
|
|
target: {
|
|
|
tagName: string;
|
|
|
role: string | null;
|
|
|
className: string;
|
|
|
id: string;
|
|
|
textPreview: string;
|
|
|
closestOption: string | null;
|
|
|
closestPacItem: string | null;
|
|
|
dataOptionId: string | null;
|
|
|
closestMothershipSuggestion: string | null;
|
|
|
};
|
|
|
};
|
|
|
|
|
|
const CAPTURE_SELECTION_TRACE = `(function(label) {
|
|
|
function summarize(el) {
|
|
|
if (!el) return null;
|
|
|
var html = el;
|
|
|
return {
|
|
|
tagName: html.tagName || "",
|
|
|
role: html.getAttribute("role"),
|
|
|
className: (html.className && String(html.className)) || "",
|
|
|
id: html.id || "",
|
|
|
ariaExpanded: html.getAttribute("aria-expanded"),
|
|
|
ariaAutocomplete: html.getAttribute("aria-autocomplete"),
|
|
|
ariaActivedescendant: html.getAttribute("aria-activedescendant"),
|
|
|
placeholder: html.getAttribute("placeholder"),
|
|
|
valuePreview: ("value" in html && html.value)
|
|
|
? String(html.value).replace(/\\s+/g, " ").trim().slice(0, 80)
|
|
|
: (html.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 80),
|
|
|
isPacTarget: html.classList && html.classList.contains("pac-target-input"),
|
|
|
};
|
|
|
}
|
|
|
function resolveDescendant(id) {
|
|
|
if (!id) return null;
|
|
|
var el = document.getElementById(id);
|
|
|
if (!el) return null;
|
|
|
return {
|
|
|
id: id,
|
|
|
tagName: el.tagName || "",
|
|
|
role: el.getAttribute("role"),
|
|
|
className: (el.className && String(el.className)) || "",
|
|
|
ariaSelected: el.getAttribute("aria-selected"),
|
|
|
textPreview: (el.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 100),
|
|
|
};
|
|
|
}
|
|
|
var active = document.activeElement;
|
|
|
var activeSummary = summarize(active);
|
|
|
var descendant = activeSummary && activeSummary.ariaActivedescendant
|
|
|
? resolveDescendant(activeSummary.ariaActivedescendant)
|
|
|
: null;
|
|
|
var pacContainer = document.querySelector(".pac-container");
|
|
|
var pacVisible = !!(pacContainer && pacContainer.offsetParent !== null);
|
|
|
var pacItems = pacContainer
|
|
|
? Array.prototype.slice.call(pacContainer.querySelectorAll(".pac-item"))
|
|
|
: [];
|
|
|
var highlightedIndex = -1;
|
|
|
var pacItemSummaries = [];
|
|
|
for (var i = 0; i < pacItems.length; i += 1) {
|
|
|
var item = pacItems[i];
|
|
|
var cls = (item.className && String(item.className)) || "";
|
|
|
if (cls.indexOf("pac-item-selected") >= 0 || cls.indexOf("pac-item-hover") >= 0) {
|
|
|
highlightedIndex = i;
|
|
|
}
|
|
|
pacItemSummaries.push({
|
|
|
index: i,
|
|
|
className: cls.slice(0, 120),
|
|
|
textPreview: (item.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 100),
|
|
|
});
|
|
|
}
|
|
|
var listboxOptions = [];
|
|
|
var optionNodes = document.querySelectorAll('[role="listbox"] [role="option"]');
|
|
|
for (var j = 0; j < optionNodes.length && j < 12; j += 1) {
|
|
|
var opt = optionNodes[j];
|
|
|
listboxOptions.push({
|
|
|
index: j,
|
|
|
tagName: opt.tagName || "",
|
|
|
role: opt.getAttribute("role"),
|
|
|
className: ((opt.className && String(opt.className)) || "").slice(0, 120),
|
|
|
ariaSelected: opt.getAttribute("aria-selected"),
|
|
|
textPreview: (opt.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 100),
|
|
|
});
|
|
|
}
|
|
|
var widget = document.querySelector("#react-quoter-landing-plugin");
|
|
|
var widgetInputs = [];
|
|
|
if (widget) {
|
|
|
var inputs = widget.querySelectorAll("input, textarea, [role='combobox']");
|
|
|
for (var k = 0; k < inputs.length && k < 8; k += 1) {
|
|
|
var inp = inputs[k];
|
|
|
widgetInputs.push({
|
|
|
tagName: inp.tagName || "",
|
|
|
role: inp.getAttribute("role"),
|
|
|
className: ((inp.className && String(inp.className)) || "").slice(0, 120),
|
|
|
placeholder: inp.getAttribute("placeholder") || "",
|
|
|
ariaActivedescendant: inp.getAttribute("aria-activedescendant"),
|
|
|
isPacTarget: inp.classList && inp.classList.contains("pac-target-input"),
|
|
|
valuePreview: ("value" in inp && inp.value)
|
|
|
? String(inp.value).replace(/\\s+/g, " ").trim().slice(0, 80)
|
|
|
: "",
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
var comboboxAlive = widgetInputs.some(function(w) {
|
|
|
return !!w.ariaActivedescendant;
|
|
|
}) || highlightedIndex >= 0;
|
|
|
return {
|
|
|
label: label,
|
|
|
at: Date.now(),
|
|
|
activeElement: activeSummary,
|
|
|
activeDescendant: descendant,
|
|
|
pac: {
|
|
|
visible: pacVisible,
|
|
|
itemCount: pacItems.length,
|
|
|
highlightedIndex: highlightedIndex,
|
|
|
items: pacItemSummaries,
|
|
|
},
|
|
|
listboxOptions: listboxOptions,
|
|
|
widgetInputs: widgetInputs,
|
|
|
comboboxAlive: comboboxAlive,
|
|
|
};
|
|
|
})`;
|
|
|
|
|
|
const INSTALL_MANUAL_LISTENERS = `(function() {
|
|
|
if (window.__rpaManualSelectionTrace) {
|
|
|
return { ok: true, reason: "already-installed" };
|
|
|
}
|
|
|
window.__rpaManualSelectionTrace = { clicks: [], keys: [] };
|
|
|
function closestMothershipSuggestionRow(el) {
|
|
|
if (!el || !el.closest) return null;
|
|
|
var widget = document.querySelector("#react-quoter-landing-plugin");
|
|
|
if (!widget || !widget.contains(el)) return null;
|
|
|
var node = el;
|
|
|
while (node && node !== widget) {
|
|
|
var tag = node.tagName || "";
|
|
|
if (tag !== "DIV" && tag !== "LI" && tag !== "BUTTON") {
|
|
|
node = node.parentElement;
|
|
|
continue;
|
|
|
}
|
|
|
var cls = (node.className && String(node.className)) || "";
|
|
|
if (cls.indexOf("sc-") !== 0) {
|
|
|
node = node.parentElement;
|
|
|
continue;
|
|
|
}
|
|
|
var text = (node.textContent || "").replace(/\\s+/g, " ").trim();
|
|
|
if (text.length < 18) {
|
|
|
node = node.parentElement;
|
|
|
continue;
|
|
|
}
|
|
|
if (/^(pick up from|deliver to|use my location)$/i.test(text)) {
|
|
|
node = node.parentElement;
|
|
|
continue;
|
|
|
}
|
|
|
if (node.querySelector && node.querySelector("input, textarea")) {
|
|
|
node = node.parentElement;
|
|
|
continue;
|
|
|
}
|
|
|
var clsShort = cls.split(/\\s+/).slice(0, 2).join(".");
|
|
|
return tag + "." + clsShort + ":" + text.slice(0, 100);
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
function summarizeTarget(el) {
|
|
|
if (!el || !el.closest) {
|
|
|
return {
|
|
|
tagName: "",
|
|
|
role: null,
|
|
|
className: "",
|
|
|
id: "",
|
|
|
textPreview: "",
|
|
|
closestOption: null,
|
|
|
closestPacItem: null,
|
|
|
dataOptionId: null,
|
|
|
closestMothershipSuggestion: null,
|
|
|
};
|
|
|
}
|
|
|
var opt = el.closest('[role="option"], [data-option-id], button[data-option-id], li[role="option"]');
|
|
|
var pac = el.closest(".pac-item");
|
|
|
var dataOptionId = el.getAttribute("data-option-id");
|
|
|
if (!dataOptionId && opt) {
|
|
|
dataOptionId = opt.getAttribute("data-option-id");
|
|
|
}
|
|
|
var msRow = closestMothershipSuggestionRow(el);
|
|
|
return {
|
|
|
tagName: el.tagName || "",
|
|
|
role: el.getAttribute("role"),
|
|
|
className: ((el.className && String(el.className)) || "").slice(0, 160),
|
|
|
id: el.id || "",
|
|
|
textPreview: (el.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 120),
|
|
|
closestOption: opt
|
|
|
? (opt.tagName + "#" + (opt.id || "") + "." + ((opt.className && String(opt.className)) || "").slice(0, 80))
|
|
|
: null,
|
|
|
closestPacItem: pac
|
|
|
? ("pac-item:" + ((pac.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 80)))
|
|
|
: null,
|
|
|
dataOptionId: dataOptionId,
|
|
|
closestMothershipSuggestion: msRow,
|
|
|
};
|
|
|
}
|
|
|
document.addEventListener("click", function(e) {
|
|
|
var t = e.target;
|
|
|
window.__rpaManualSelectionTrace.clicks.push({
|
|
|
kind: "click",
|
|
|
at: Date.now(),
|
|
|
target: summarizeTarget(t),
|
|
|
});
|
|
|
}, true);
|
|
|
document.addEventListener("keydown", function(e) {
|
|
|
window.__rpaManualSelectionTrace.keys.push({
|
|
|
kind: "keydown",
|
|
|
at: Date.now(),
|
|
|
key: e.key,
|
|
|
target: summarizeTarget(e.target),
|
|
|
});
|
|
|
}, true);
|
|
|
return { ok: true, reason: "installed" };
|
|
|
})()`;
|
|
|
|
|
|
const DRAIN_MANUAL_INTERACTIONS = `(function() {
|
|
|
var buf = window.__rpaManualSelectionTrace || { clicks: [], keys: [] };
|
|
|
window.__rpaManualSelectionTrace = { clicks: [], keys: [] };
|
|
|
return {
|
|
|
clicks: buf.clicks || [],
|
|
|
keys: buf.keys || [],
|
|
|
};
|
|
|
})()`;
|
|
|
|
|
|
const traceBuffer: SelectionTraceSnapshot[] = [];
|
|
|
|
|
|
export function drainSelectionTraces(): SelectionTraceSnapshot[] {
|
|
|
const out = [...traceBuffer];
|
|
|
traceBuffer.length = 0;
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
export function peekSelectionTraces(): SelectionTraceSnapshot[] {
|
|
|
return [...traceBuffer];
|
|
|
}
|
|
|
|
|
|
export function formatSelectionTraceEvent(snapshot: SelectionTraceSnapshot): string {
|
|
|
const active = snapshot.activeElement;
|
|
|
const descendant = snapshot.activeDescendant?.id ?? "null";
|
|
|
const pacHi =
|
|
|
snapshot.pac.highlightedIndex >= 0
|
|
|
? `pac-highlight=${snapshot.pac.highlightedIndex}`
|
|
|
: "pac-highlight=none";
|
|
|
const listboxSel = snapshot.listboxOptions.find(
|
|
|
(o) => o.ariaSelected === "true",
|
|
|
);
|
|
|
const listboxPart = listboxSel
|
|
|
? `listbox-selected=${listboxSel.index}`
|
|
|
: "listbox-selected=none";
|
|
|
const combobox = snapshot.comboboxAlive ? "combobox-alive" : "combobox-dead";
|
|
|
const focus = active
|
|
|
? `focus=${active.tagName}${active.isPacTarget ? "+pac" : ""}`
|
|
|
: "focus=null";
|
|
|
return `selection-trace:${snapshot.label}:${focus}:aria-activedescendant=${descendant}:${pacHi}:${listboxPart}:${combobox}`;
|
|
|
}
|
|
|
|
|
|
function emptySelectionTraceSnapshot(label: string): SelectionTraceSnapshot {
|
|
|
return {
|
|
|
label,
|
|
|
at: Date.now(),
|
|
|
activeElement: null,
|
|
|
activeDescendant: null,
|
|
|
pac: { visible: false, itemCount: 0, highlightedIndex: -1, items: [] },
|
|
|
listboxOptions: [],
|
|
|
widgetInputs: [],
|
|
|
comboboxAlive: false,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export async function captureSelectionTrace(
|
|
|
page: Page,
|
|
|
label: string,
|
|
|
): Promise<SelectionTraceSnapshot> {
|
|
|
let snapshot: SelectionTraceSnapshot;
|
|
|
try {
|
|
|
snapshot = (await page.evaluate(
|
|
|
`${CAPTURE_SELECTION_TRACE}(${JSON.stringify(label)})`,
|
|
|
)) as SelectionTraceSnapshot;
|
|
|
} catch (error) {
|
|
|
console.warn(
|
|
|
`[rpa] selection-trace:capture-failed label=${label}`,
|
|
|
error instanceof Error ? error.message : error,
|
|
|
);
|
|
|
snapshot = emptySelectionTraceSnapshot(label);
|
|
|
}
|
|
|
if (!snapshot || typeof snapshot !== "object" || !snapshot.label) {
|
|
|
console.warn(`[rpa] selection-trace:capture-empty label=${label}`);
|
|
|
snapshot = emptySelectionTraceSnapshot(label);
|
|
|
}
|
|
|
traceBuffer.push(snapshot);
|
|
|
return snapshot;
|
|
|
}
|
|
|
|
|
|
export function logSelectionTrace(
|
|
|
side: "pickup" | "delivery" | "manual" | "rpa",
|
|
|
snapshot: SelectionTraceSnapshot | null | undefined,
|
|
|
): void {
|
|
|
if (!snapshot) {
|
|
|
console.log(`[rpa] selection-trace:${side} {"error":"snapshot为空"}`);
|
|
|
return;
|
|
|
}
|
|
|
console.log(
|
|
|
`[rpa] selection-trace:${side} ${JSON.stringify({
|
|
|
label: snapshot.label,
|
|
|
at: snapshot.at,
|
|
|
activeElement: snapshot.activeElement,
|
|
|
activeDescendant: snapshot.activeDescendant,
|
|
|
pac: {
|
|
|
visible: snapshot.pac.visible,
|
|
|
itemCount: snapshot.pac.itemCount,
|
|
|
highlightedIndex: snapshot.pac.highlightedIndex,
|
|
|
},
|
|
|
listboxCount: snapshot.listboxOptions.length,
|
|
|
listboxSelected: snapshot.listboxOptions
|
|
|
.filter((o) => o.ariaSelected === "true")
|
|
|
.map((o) => o.index),
|
|
|
comboboxAlive: snapshot.comboboxAlive,
|
|
|
widgetInputs: snapshot.widgetInputs,
|
|
|
})}`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export type ManualInteractionBundle = {
|
|
|
clicks: ManualInteractionRecord[];
|
|
|
keys: ManualInteractionRecord[];
|
|
|
};
|
|
|
|
|
|
const WIDGET_CHROME_LABEL_RE =
|
|
|
/^(pick up from|deliver to|use my location)$/i;
|
|
|
|
|
|
/** MotherShip 联想行:styled-components DIV,非 role=option / pac-item */
|
|
|
export function isMothershipSuggestionClickTarget(
|
|
|
target: ManualInteractionRecord["target"],
|
|
|
): boolean {
|
|
|
if (
|
|
|
target.closestOption ||
|
|
|
target.closestPacItem ||
|
|
|
target.dataOptionId ||
|
|
|
target.closestMothershipSuggestion
|
|
|
) {
|
|
|
return true;
|
|
|
}
|
|
|
const cls = target.className || "";
|
|
|
if (!/\bsc-/.test(cls)) {
|
|
|
return false;
|
|
|
}
|
|
|
const tag = (target.tagName || "").toUpperCase();
|
|
|
if (tag === "INPUT" || tag === "BUTTON" || tag === "SVG") {
|
|
|
return false;
|
|
|
}
|
|
|
const text = (target.textPreview || "").trim();
|
|
|
if (text.length < 18 || WIDGET_CHROME_LABEL_RE.test(text)) {
|
|
|
return false;
|
|
|
}
|
|
|
return /,|\bUSA\b|\bFL\b|\bCA\b|Street|Boulevard|Avenue|Hollywood|Key West/i.test(
|
|
|
text,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export function pickSelectionGoldInteractions(
|
|
|
interactions: ManualInteractionBundle,
|
|
|
): {
|
|
|
selectionClicks: ManualInteractionRecord[];
|
|
|
selectionKeys: ManualInteractionRecord[];
|
|
|
} {
|
|
|
const selectionClicks = interactions.clicks.filter((c) =>
|
|
|
isMothershipSuggestionClickTarget(c.target),
|
|
|
);
|
|
|
const selectionKeys = interactions.keys.filter((k) => {
|
|
|
const key = k.key ?? "";
|
|
|
if (!["Enter", "ArrowDown", "ArrowUp", "Tab"].includes(key)) {
|
|
|
return false;
|
|
|
}
|
|
|
const tag = (k.target.tagName || "").toUpperCase();
|
|
|
return tag === "INPUT" || tag === "TEXTAREA";
|
|
|
});
|
|
|
return { selectionClicks, selectionKeys };
|
|
|
}
|
|
|
|
|
|
export function logManualInteractionGoldSample(
|
|
|
phase: "pickup" | "delivery",
|
|
|
interactions: ManualInteractionBundle,
|
|
|
): void {
|
|
|
const { selectionClicks, selectionKeys } =
|
|
|
pickSelectionGoldInteractions(interactions);
|
|
|
console.log(
|
|
|
`[manual] interactions-gold:${phase} clicks=${interactions.clicks.length} keys=${interactions.keys.length} selectionClicks=${selectionClicks.length} selectionKeys=${selectionKeys.length}`,
|
|
|
);
|
|
|
if (selectionClicks.length > 0) {
|
|
|
const last = selectionClicks[selectionClicks.length - 1]!;
|
|
|
console.log(
|
|
|
`[manual] interactions-gold:${phase} last-selection-click ${JSON.stringify(last.target)}`,
|
|
|
);
|
|
|
} else if (selectionKeys.length > 0) {
|
|
|
const last = selectionKeys[selectionKeys.length - 1]!;
|
|
|
console.log(
|
|
|
`[manual] interactions-gold:${phase} last-selection-key ${JSON.stringify({ key: last.key, target: last.target })}`,
|
|
|
);
|
|
|
} else {
|
|
|
console.warn(
|
|
|
`[manual] ⚠ interactions-gold:${phase} 未捕获到联想点选。请用鼠标点联想行(sc- DIV)或键盘 ↓+Enter 后再按检查点。`,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export async function installManualSelectionListeners(
|
|
|
page: Page,
|
|
|
): Promise<void> {
|
|
|
const result = await page.evaluate(INSTALL_MANUAL_LISTENERS);
|
|
|
console.log(`[rpa] selection-trace:manual-listeners ${JSON.stringify(result)}`);
|
|
|
}
|
|
|
|
|
|
export async function drainManualInteractions(page: Page): Promise<{
|
|
|
clicks: ManualInteractionRecord[];
|
|
|
keys: ManualInteractionRecord[];
|
|
|
}> {
|
|
|
try {
|
|
|
return (await page.evaluate(DRAIN_MANUAL_INTERACTIONS)) as {
|
|
|
clicks: ManualInteractionRecord[];
|
|
|
keys: ManualInteractionRecord[];
|
|
|
};
|
|
|
} catch {
|
|
|
return { clicks: [], keys: [] };
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export async function recordManualCheckpoint(
|
|
|
page: Page,
|
|
|
phase: string,
|
|
|
): Promise<{
|
|
|
snapshot: SelectionTraceSnapshot;
|
|
|
interactions: { clicks: ManualInteractionRecord[]; keys: ManualInteractionRecord[] };
|
|
|
}> {
|
|
|
const interactions = await drainManualInteractions(page);
|
|
|
const snapshot = await captureSelectionTrace(page, phase);
|
|
|
logSelectionTrace("manual", snapshot);
|
|
|
return { snapshot, interactions };
|
|
|
}
|