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