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.

496 lines
16 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.

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 };
}