Merge pull request #694 from getmaxun/child-shadow
feat(maxun-core): child extraction + deep shadow dom
This commit is contained in:
@@ -424,26 +424,214 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
|
|||||||
*/
|
*/
|
||||||
window.scrapeList = async function ({ listSelector, fields, limit = 10 }) {
|
window.scrapeList = async function ({ listSelector, fields, limit = 10 }) {
|
||||||
// XPath evaluation functions
|
// XPath evaluation functions
|
||||||
const evaluateXPath = (rootElement, xpath) => {
|
const queryInsideContext = (context, part) => {
|
||||||
try {
|
try {
|
||||||
const ownerDoc =
|
const { tagName, conditions } = parseXPathPart(part);
|
||||||
rootElement.nodeType === Node.DOCUMENT_NODE
|
|
||||||
? rootElement
|
|
||||||
: rootElement.ownerDocument;
|
|
||||||
|
|
||||||
if (!ownerDoc) return null;
|
const candidateElements = Array.from(context.querySelectorAll(tagName));
|
||||||
|
if (candidateElements.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const result = ownerDoc.evaluate(
|
const matchingElements = candidateElements.filter((el) => {
|
||||||
|
return elementMatchesConditions(el, conditions);
|
||||||
|
});
|
||||||
|
|
||||||
|
return matchingElements;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error in queryInsideContext:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to parse XPath part
|
||||||
|
const parseXPathPart = (part) => {
|
||||||
|
const tagMatch = part.match(/^([a-zA-Z0-9-]+)/);
|
||||||
|
const tagName = tagMatch ? tagMatch[1] : "*";
|
||||||
|
|
||||||
|
const conditionMatches = part.match(/\[([^\]]+)\]/g);
|
||||||
|
const conditions = conditionMatches
|
||||||
|
? conditionMatches.map((c) => c.slice(1, -1))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { tagName, conditions };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check if element matches all conditions
|
||||||
|
const elementMatchesConditions = (element, conditions) => {
|
||||||
|
for (const condition of conditions) {
|
||||||
|
if (!elementMatchesCondition(element, condition)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to check if element matches a single condition
|
||||||
|
const elementMatchesCondition = (element, condition) => {
|
||||||
|
condition = condition.trim();
|
||||||
|
|
||||||
|
if (/^\d+$/.test(condition)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle @attribute="value"
|
||||||
|
const attrMatch = condition.match(/^@([^=]+)=["']([^"']+)["']$/);
|
||||||
|
if (attrMatch) {
|
||||||
|
const [, attr, value] = attrMatch;
|
||||||
|
const elementValue = element.getAttribute(attr);
|
||||||
|
return elementValue === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle contains(@class, 'value')
|
||||||
|
const classContainsMatch = condition.match(
|
||||||
|
/^contains\(@class,\s*["']([^"']+)["']\)$/
|
||||||
|
);
|
||||||
|
if (classContainsMatch) {
|
||||||
|
const className = classContainsMatch[1];
|
||||||
|
return element.classList.contains(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle contains(@attribute, 'value')
|
||||||
|
const attrContainsMatch = condition.match(
|
||||||
|
/^contains\(@([^,]+),\s*["']([^"']+)["']\)$/
|
||||||
|
);
|
||||||
|
if (attrContainsMatch) {
|
||||||
|
const [, attr, value] = attrContainsMatch;
|
||||||
|
const elementValue = element.getAttribute(attr) || "";
|
||||||
|
return elementValue.includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text()="value"
|
||||||
|
const textMatch = condition.match(/^text\(\)=["']([^"']+)["']$/);
|
||||||
|
if (textMatch) {
|
||||||
|
const expectedText = textMatch[1];
|
||||||
|
const elementText = element.textContent?.trim() || "";
|
||||||
|
return elementText === expectedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle contains(text(), 'value')
|
||||||
|
const textContainsMatch = condition.match(
|
||||||
|
/^contains\(text\(\),\s*["']([^"']+)["']\)$/
|
||||||
|
);
|
||||||
|
if (textContainsMatch) {
|
||||||
|
const expectedText = textContainsMatch[1];
|
||||||
|
const elementText = element.textContent?.trim() || "";
|
||||||
|
return elementText.includes(expectedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle count(*)=0 (element has no children)
|
||||||
|
if (condition === "count(*)=0") {
|
||||||
|
return element.children.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other count conditions
|
||||||
|
const countMatch = condition.match(/^count\(\*\)=(\d+)$/);
|
||||||
|
if (countMatch) {
|
||||||
|
const expectedCount = parseInt(countMatch[1]);
|
||||||
|
return element.children.length === expectedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateXPath = (document, xpath, isShadow = false) => {
|
||||||
|
try {
|
||||||
|
const result = document.evaluate(
|
||||||
xpath,
|
xpath,
|
||||||
rootElement,
|
document,
|
||||||
null,
|
null,
|
||||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||||
null
|
null
|
||||||
);
|
).singleNodeValue;
|
||||||
|
|
||||||
return result.singleNodeValue;
|
if (!isShadow) {
|
||||||
} catch (error) {
|
if (result === null) {
|
||||||
console.warn("XPath evaluation failed:", xpath, error);
|
return null;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanPath = xpath;
|
||||||
|
let isIndexed = false;
|
||||||
|
|
||||||
|
const indexedMatch = xpath.match(/^\((.*?)\)\[(\d+)\](.*)$/);
|
||||||
|
if (indexedMatch) {
|
||||||
|
cleanPath = indexedMatch[1] + indexedMatch[3];
|
||||||
|
isIndexed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathParts = cleanPath
|
||||||
|
.replace(/^\/\//, "")
|
||||||
|
.split("/")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter((p) => p.length > 0);
|
||||||
|
|
||||||
|
let currentContexts = [document];
|
||||||
|
|
||||||
|
for (let i = 0; i < pathParts.length; i++) {
|
||||||
|
const part = pathParts[i];
|
||||||
|
const nextContexts = [];
|
||||||
|
|
||||||
|
for (const ctx of currentContexts) {
|
||||||
|
const positionalMatch = part.match(/^([^[]+)\[(\d+)\]$/);
|
||||||
|
let partWithoutPosition = part;
|
||||||
|
let requestedPosition = null;
|
||||||
|
|
||||||
|
if (positionalMatch) {
|
||||||
|
partWithoutPosition = positionalMatch[1];
|
||||||
|
requestedPosition = parseInt(positionalMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = queryInsideContext(ctx, partWithoutPosition);
|
||||||
|
|
||||||
|
let elementsToAdd = matched;
|
||||||
|
if (requestedPosition !== null) {
|
||||||
|
const index = requestedPosition - 1; // XPath is 1-based, arrays are 0-based
|
||||||
|
if (index >= 0 && index < matched.length) {
|
||||||
|
elementsToAdd = [matched[index]];
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`Position ${requestedPosition} out of range (${matched.length} elements found)`
|
||||||
|
);
|
||||||
|
elementsToAdd = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elementsToAdd.forEach((el) => {
|
||||||
|
nextContexts.push(el);
|
||||||
|
if (el.shadowRoot) {
|
||||||
|
nextContexts.push(el.shadowRoot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextContexts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentContexts = nextContexts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentContexts.length > 0) {
|
||||||
|
if (isIndexed && indexedMatch) {
|
||||||
|
const requestedIndex = parseInt(indexedMatch[2]) - 1;
|
||||||
|
if (requestedIndex >= 0 && requestedIndex < currentContexts.length) {
|
||||||
|
return currentContexts[requestedIndex];
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`Requested index ${requestedIndex + 1} out of range (${currentContexts.length} elements found)`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentContexts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Critical XPath failure:", xpath, err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1018,7 +1206,7 @@ function scrapableHeuristics(maxCountPerPage = 50, minArea = 20000, scrolls = 3,
|
|||||||
listSelector,
|
listSelector,
|
||||||
containerIndex + 1
|
containerIndex + 1
|
||||||
);
|
);
|
||||||
element = evaluateXPath(document, indexedSelector);
|
element = evaluateXPath(document, indexedSelector, field.isShadow);
|
||||||
} else {
|
} else {
|
||||||
// Fallback for CSS selectors within XPath containers
|
// Fallback for CSS selectors within XPath containers
|
||||||
const container = containers[containerIndex];
|
const container = containers[containerIndex];
|
||||||
|
|||||||
@@ -147,7 +147,18 @@ export const BrowserWindow = () => {
|
|||||||
const { browserWidth, browserHeight } = useBrowserDimensionsStore();
|
const { browserWidth, browserHeight } = useBrowserDimensionsStore();
|
||||||
const [canvasRef, setCanvasReference] = useState<React.RefObject<HTMLCanvasElement> | undefined>(undefined);
|
const [canvasRef, setCanvasReference] = useState<React.RefObject<HTMLCanvasElement> | undefined>(undefined);
|
||||||
const [screenShot, setScreenShot] = useState<string>("");
|
const [screenShot, setScreenShot] = useState<string>("");
|
||||||
const [highlighterData, setHighlighterData] = useState<{ rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[], groupElements?: Array<{ element: HTMLElement; rect: DOMRect } >} | null>(null);
|
const [highlighterData, setHighlighterData] = useState<{
|
||||||
|
rect: DOMRect;
|
||||||
|
selector: string;
|
||||||
|
elementInfo: ElementInfo | null;
|
||||||
|
isShadow?: boolean;
|
||||||
|
childSelectors?: string[];
|
||||||
|
groupElements?: Array<{ element: HTMLElement; rect: DOMRect }>;
|
||||||
|
similarElements?: {
|
||||||
|
elements: HTMLElement[];
|
||||||
|
rects: DOMRect[];
|
||||||
|
};
|
||||||
|
} | null>(null);
|
||||||
const [showAttributeModal, setShowAttributeModal] = useState(false);
|
const [showAttributeModal, setShowAttributeModal] = useState(false);
|
||||||
const [attributeOptions, setAttributeOptions] = useState<AttributeOption[]>([]);
|
const [attributeOptions, setAttributeOptions] = useState<AttributeOption[]>([]);
|
||||||
const [selectedElement, setSelectedElement] = useState<{ selector: string, info: ElementInfo | null } | null>(null);
|
const [selectedElement, setSelectedElement] = useState<{ selector: string, info: ElementInfo | null } | null>(null);
|
||||||
@@ -161,11 +172,20 @@ export const BrowserWindow = () => {
|
|||||||
const [paginationSelector, setPaginationSelector] = useState<string>('');
|
const [paginationSelector, setPaginationSelector] = useState<string>('');
|
||||||
|
|
||||||
const highlighterUpdateRef = useRef<number>(0);
|
const highlighterUpdateRef = useRef<number>(0);
|
||||||
|
const [isCachingChildSelectors, setIsCachingChildSelectors] = useState(false);
|
||||||
|
const [cachedListSelector, setCachedListSelector] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [pendingNotification, setPendingNotification] = useState<{
|
||||||
|
type: "error" | "warning" | "info" | "success";
|
||||||
|
message: string;
|
||||||
|
count?: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const { socket } = useSocketStore();
|
const { socket } = useSocketStore();
|
||||||
const { notify, currentTextActionId, currentListActionId, updateDOMMode, isDOMMode, currentSnapshot } = useGlobalInfoStore();
|
const { notify, currentTextActionId, currentListActionId, updateDOMMode, isDOMMode, currentSnapshot } = useGlobalInfoStore();
|
||||||
const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext();
|
const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext();
|
||||||
const { addTextStep, addListStep, updateListStepData } = useBrowserSteps();
|
const { addTextStep, addListStep } = useBrowserSteps();
|
||||||
|
|
||||||
const [currentGroupInfo, setCurrentGroupInfo] = useState<{
|
const [currentGroupInfo, setCurrentGroupInfo] = useState<{
|
||||||
isGroupElement: boolean;
|
isGroupElement: boolean;
|
||||||
@@ -270,17 +290,6 @@ export const BrowserWindow = () => {
|
|||||||
[user?.id, socket, updateDOMMode]
|
[user?.id, socket, updateDOMMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
const screenshotModeHandler = useCallback(
|
|
||||||
(data: any) => {
|
|
||||||
if (!data.userId || data.userId === user?.id) {
|
|
||||||
updateDOMMode(false);
|
|
||||||
socket?.emit("screenshot-mode-enabled");
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[user?.id, updateDOMMode]
|
|
||||||
);
|
|
||||||
|
|
||||||
const domModeErrorHandler = useCallback(
|
const domModeErrorHandler = useCallback(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
if (!data.userId || data.userId === user?.id) {
|
if (!data.userId || data.userId === user?.id) {
|
||||||
@@ -306,22 +315,62 @@ export const BrowserWindow = () => {
|
|||||||
|
|
||||||
clientSelectorGenerator.setListSelector(listSelector);
|
clientSelectorGenerator.setListSelector(listSelector);
|
||||||
|
|
||||||
|
if (currentSnapshot && cachedListSelector !== listSelector) {
|
||||||
setCachedChildSelectors([]);
|
setCachedChildSelectors([]);
|
||||||
|
setIsCachingChildSelectors(true);
|
||||||
|
setCachedListSelector(listSelector);
|
||||||
|
|
||||||
if (currentSnapshot) {
|
|
||||||
const iframeElement = document.querySelector(
|
const iframeElement = document.querySelector(
|
||||||
"#dom-browser-iframe"
|
"#dom-browser-iframe"
|
||||||
) as HTMLIFrameElement;
|
) as HTMLIFrameElement;
|
||||||
|
|
||||||
if (iframeElement?.contentDocument) {
|
if (iframeElement?.contentDocument) {
|
||||||
const childSelectors = clientSelectorGenerator.getChildSelectors(
|
setTimeout(() => {
|
||||||
iframeElement.contentDocument,
|
try {
|
||||||
|
const childSelectors =
|
||||||
|
clientSelectorGenerator.getChildSelectors(
|
||||||
|
iframeElement.contentDocument as Document,
|
||||||
listSelector
|
listSelector
|
||||||
);
|
);
|
||||||
|
|
||||||
|
clientSelectorGenerator.precomputeChildSelectorMappings(
|
||||||
|
childSelectors,
|
||||||
|
iframeElement.contentDocument as Document
|
||||||
|
);
|
||||||
|
|
||||||
setCachedChildSelectors(childSelectors);
|
setCachedChildSelectors(childSelectors);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during child selector caching:", error);
|
||||||
|
} finally {
|
||||||
|
setIsCachingChildSelectors(false);
|
||||||
|
|
||||||
|
if (pendingNotification) {
|
||||||
|
notify(pendingNotification.type, pendingNotification.message);
|
||||||
|
setPendingNotification(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
setIsCachingChildSelectors(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isDOMMode, listSelector, socket, getList, currentSnapshot]);
|
}, [
|
||||||
|
isDOMMode,
|
||||||
|
listSelector,
|
||||||
|
socket,
|
||||||
|
getList,
|
||||||
|
currentSnapshot,
|
||||||
|
cachedListSelector,
|
||||||
|
pendingNotification,
|
||||||
|
notify,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!listSelector) {
|
||||||
|
setCachedListSelector(null);
|
||||||
|
}
|
||||||
|
}, [listSelector]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
coordinateMapper.updateDimensions(dimensions.width, dimensions.height, viewportInfo.width, viewportInfo.height);
|
coordinateMapper.updateDimensions(dimensions.width, dimensions.height, viewportInfo.width, viewportInfo.height);
|
||||||
@@ -389,7 +438,6 @@ export const BrowserWindow = () => {
|
|||||||
socket.on("screencast", screencastHandler);
|
socket.on("screencast", screencastHandler);
|
||||||
socket.on("domcast", rrwebSnapshotHandler);
|
socket.on("domcast", rrwebSnapshotHandler);
|
||||||
socket.on("dom-mode-enabled", domModeHandler);
|
socket.on("dom-mode-enabled", domModeHandler);
|
||||||
// socket.on("screenshot-mode-enabled", screenshotModeHandler);
|
|
||||||
socket.on("dom-mode-error", domModeErrorHandler);
|
socket.on("dom-mode-error", domModeErrorHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,11 +447,9 @@ export const BrowserWindow = () => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
console.log("Cleaning up DOM streaming event listeners");
|
|
||||||
socket.off("screencast", screencastHandler);
|
socket.off("screencast", screencastHandler);
|
||||||
socket.off("domcast", rrwebSnapshotHandler);
|
socket.off("domcast", rrwebSnapshotHandler);
|
||||||
socket.off("dom-mode-enabled", domModeHandler);
|
socket.off("dom-mode-enabled", domModeHandler);
|
||||||
// socket.off("screenshot-mode-enabled", screenshotModeHandler);
|
|
||||||
socket.off("dom-mode-error", domModeErrorHandler);
|
socket.off("dom-mode-error", domModeErrorHandler);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -415,7 +461,6 @@ export const BrowserWindow = () => {
|
|||||||
screencastHandler,
|
screencastHandler,
|
||||||
rrwebSnapshotHandler,
|
rrwebSnapshotHandler,
|
||||||
domModeHandler,
|
domModeHandler,
|
||||||
// screenshotModeHandler,
|
|
||||||
domModeErrorHandler,
|
domModeErrorHandler,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -425,12 +470,17 @@ export const BrowserWindow = () => {
|
|||||||
selector: string;
|
selector: string;
|
||||||
elementInfo: ElementInfo | null;
|
elementInfo: ElementInfo | null;
|
||||||
childSelectors?: string[];
|
childSelectors?: string[];
|
||||||
|
isShadow?: boolean;
|
||||||
groupInfo?: {
|
groupInfo?: {
|
||||||
isGroupElement: boolean;
|
isGroupElement: boolean;
|
||||||
groupSize: number;
|
groupSize: number;
|
||||||
groupElements: HTMLElement[];
|
groupElements: HTMLElement[];
|
||||||
groupFingerprint: ElementFingerprint;
|
groupFingerprint: ElementFingerprint;
|
||||||
};
|
};
|
||||||
|
similarElements?: {
|
||||||
|
elements: HTMLElement[];
|
||||||
|
rects: DOMRect[];
|
||||||
|
};
|
||||||
isDOMMode?: boolean;
|
isDOMMode?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (!getText && !getList) {
|
if (!getText && !getList) {
|
||||||
@@ -460,6 +510,22 @@ export const BrowserWindow = () => {
|
|||||||
const iframeRect = iframeElement.getBoundingClientRect();
|
const iframeRect = iframeElement.getBoundingClientRect();
|
||||||
const IFRAME_BODY_PADDING = 16;
|
const IFRAME_BODY_PADDING = 16;
|
||||||
|
|
||||||
|
let mappedSimilarElements;
|
||||||
|
if (data.similarElements) {
|
||||||
|
mappedSimilarElements = {
|
||||||
|
elements: data.similarElements.elements,
|
||||||
|
rects: data.similarElements.rects.map(
|
||||||
|
(rect) =>
|
||||||
|
new DOMRect(
|
||||||
|
rect.x + iframeRect.left - IFRAME_BODY_PADDING,
|
||||||
|
rect.y + iframeRect.top - IFRAME_BODY_PADDING,
|
||||||
|
rect.width,
|
||||||
|
rect.height
|
||||||
|
)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (data.groupInfo) {
|
if (data.groupInfo) {
|
||||||
setCurrentGroupInfo(data.groupInfo);
|
setCurrentGroupInfo(data.groupInfo);
|
||||||
} else {
|
} else {
|
||||||
@@ -477,6 +543,7 @@ export const BrowserWindow = () => {
|
|||||||
...data,
|
...data,
|
||||||
rect: absoluteRect,
|
rect: absoluteRect,
|
||||||
childSelectors: data.childSelectors || cachedChildSelectors,
|
childSelectors: data.childSelectors || cachedChildSelectors,
|
||||||
|
similarElements: mappedSimilarElements,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (getList === true) {
|
if (getList === true) {
|
||||||
@@ -638,21 +705,6 @@ export const BrowserWindow = () => {
|
|||||||
}
|
}
|
||||||
}, [getList, socket, listSelector, paginationMode, paginationType, limitMode]);
|
}, [getList, socket, listSelector, paginationMode, paginationType, limitMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener('mousemove', onMouseMove, false);
|
|
||||||
if (socket) {
|
|
||||||
socket.off("highlighter", highlighterHandler);
|
|
||||||
|
|
||||||
socket.on("highlighter", highlighterHandler);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousemove', onMouseMove);
|
|
||||||
if (socket) {
|
|
||||||
socket.off("highlighter", highlighterHandler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [socket, highlighterHandler, onMouseMove, getList, listSelector]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener("mousemove", onMouseMove, false);
|
document.addEventListener("mousemove", onMouseMove, false);
|
||||||
if (socket) {
|
if (socket) {
|
||||||
@@ -669,7 +721,6 @@ export const BrowserWindow = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (socket && listSelector) {
|
if (socket && listSelector) {
|
||||||
console.log('Syncing list selector with server:', listSelector);
|
|
||||||
socket.emit('setGetList', { getList: true });
|
socket.emit('setGetList', { getList: true });
|
||||||
socket.emit('listSelector', { selector: listSelector });
|
socket.emit('listSelector', { selector: listSelector });
|
||||||
}
|
}
|
||||||
@@ -686,6 +737,7 @@ export const BrowserWindow = () => {
|
|||||||
(highlighterData: {
|
(highlighterData: {
|
||||||
rect: DOMRect;
|
rect: DOMRect;
|
||||||
selector: string;
|
selector: string;
|
||||||
|
isShadow?: boolean;
|
||||||
elementInfo: ElementInfo | null;
|
elementInfo: ElementInfo | null;
|
||||||
childSelectors?: string[];
|
childSelectors?: string[];
|
||||||
groupInfo?: {
|
groupInfo?: {
|
||||||
@@ -717,7 +769,13 @@ export const BrowserWindow = () => {
|
|||||||
fields,
|
fields,
|
||||||
currentListId || 0,
|
currentListId || 0,
|
||||||
currentListActionId || `list-${crypto.randomUUID()}`,
|
currentListActionId || `list-${crypto.randomUUID()}`,
|
||||||
{ type: paginationType, selector: highlighterData.selector }
|
{
|
||||||
|
type: paginationType,
|
||||||
|
selector: highlighterData.selector,
|
||||||
|
isShadow: highlighterData.isShadow
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
highlighterData.isShadow
|
||||||
);
|
);
|
||||||
socket?.emit("setPaginationMode", { pagination: false });
|
socket?.emit("setPaginationMode", { pagination: false });
|
||||||
}
|
}
|
||||||
@@ -776,7 +834,7 @@ export const BrowserWindow = () => {
|
|||||||
selectorObj: {
|
selectorObj: {
|
||||||
selector: currentSelector,
|
selector: currentSelector,
|
||||||
tag: highlighterData.elementInfo?.tagName,
|
tag: highlighterData.elementInfo?.tagName,
|
||||||
shadow: highlighterData.elementInfo?.isShadowRoot,
|
isShadow: highlighterData.isShadow || highlighterData.elementInfo?.isShadowRoot,
|
||||||
attribute,
|
attribute,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -794,7 +852,9 @@ export const BrowserWindow = () => {
|
|||||||
updatedFields,
|
updatedFields,
|
||||||
currentListId,
|
currentListId,
|
||||||
currentListActionId || `list-${crypto.randomUUID()}`,
|
currentListActionId || `list-${crypto.randomUUID()}`,
|
||||||
{ type: "", selector: paginationSelector }
|
{ type: "", selector: paginationSelector },
|
||||||
|
undefined,
|
||||||
|
highlighterData.isShadow
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -829,7 +889,7 @@ export const BrowserWindow = () => {
|
|||||||
{
|
{
|
||||||
selector: highlighterData.selector,
|
selector: highlighterData.selector,
|
||||||
tag: highlighterData.elementInfo?.tagName,
|
tag: highlighterData.elementInfo?.tagName,
|
||||||
shadow: highlighterData.elementInfo?.isShadowRoot,
|
isShadow: highlighterData.isShadow || highlighterData.elementInfo?.isShadowRoot,
|
||||||
attribute,
|
attribute,
|
||||||
},
|
},
|
||||||
currentTextActionId || `text-${crypto.randomUUID()}`
|
currentTextActionId || `text-${crypto.randomUUID()}`
|
||||||
@@ -908,7 +968,7 @@ export const BrowserWindow = () => {
|
|||||||
{
|
{
|
||||||
selector: highlighterData.selector,
|
selector: highlighterData.selector,
|
||||||
tag: highlighterData.elementInfo?.tagName,
|
tag: highlighterData.elementInfo?.tagName,
|
||||||
shadow: highlighterData.elementInfo?.isShadowRoot,
|
isShadow: highlighterData.isShadow || highlighterData.elementInfo?.isShadowRoot,
|
||||||
attribute,
|
attribute,
|
||||||
},
|
},
|
||||||
currentTextActionId || `text-${crypto.randomUUID()}`
|
currentTextActionId || `text-${crypto.randomUUID()}`
|
||||||
@@ -942,7 +1002,9 @@ export const BrowserWindow = () => {
|
|||||||
fields,
|
fields,
|
||||||
currentListId || 0,
|
currentListId || 0,
|
||||||
currentListActionId || `list-${crypto.randomUUID()}`,
|
currentListActionId || `list-${crypto.randomUUID()}`,
|
||||||
{ type: paginationType, selector: highlighterData.selector }
|
{ type: paginationType, selector: highlighterData.selector, isShadow: highlighterData.isShadow },
|
||||||
|
undefined,
|
||||||
|
highlighterData.isShadow
|
||||||
);
|
);
|
||||||
socket?.emit("setPaginationMode", { pagination: false });
|
socket?.emit("setPaginationMode", { pagination: false });
|
||||||
}
|
}
|
||||||
@@ -1000,7 +1062,7 @@ export const BrowserWindow = () => {
|
|||||||
selectorObj: {
|
selectorObj: {
|
||||||
selector: currentSelector,
|
selector: currentSelector,
|
||||||
tag: highlighterData.elementInfo?.tagName,
|
tag: highlighterData.elementInfo?.tagName,
|
||||||
shadow: highlighterData.elementInfo?.isShadowRoot,
|
isShadow: highlighterData.isShadow || highlighterData.elementInfo?.isShadowRoot,
|
||||||
attribute,
|
attribute,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1018,7 +1080,9 @@ export const BrowserWindow = () => {
|
|||||||
updatedFields,
|
updatedFields,
|
||||||
currentListId,
|
currentListId,
|
||||||
currentListActionId || `list-${crypto.randomUUID()}`,
|
currentListActionId || `list-${crypto.randomUUID()}`,
|
||||||
{ type: "", selector: paginationSelector }
|
{ type: "", selector: paginationSelector, isShadow: highlighterData.isShadow },
|
||||||
|
undefined,
|
||||||
|
highlighterData.isShadow
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1052,7 +1116,7 @@ export const BrowserWindow = () => {
|
|||||||
addTextStep('', data, {
|
addTextStep('', data, {
|
||||||
selector: selectedElement.selector,
|
selector: selectedElement.selector,
|
||||||
tag: selectedElement.info?.tagName,
|
tag: selectedElement.info?.tagName,
|
||||||
shadow: selectedElement.info?.isShadowRoot,
|
isShadow: highlighterData?.isShadow || selectedElement.info?.isShadowRoot,
|
||||||
attribute: attribute
|
attribute: attribute
|
||||||
}, currentTextActionId || `text-${crypto.randomUUID()}`);
|
}, currentTextActionId || `text-${crypto.randomUUID()}`);
|
||||||
}
|
}
|
||||||
@@ -1065,7 +1129,7 @@ export const BrowserWindow = () => {
|
|||||||
selectorObj: {
|
selectorObj: {
|
||||||
selector: selectedElement.selector,
|
selector: selectedElement.selector,
|
||||||
tag: selectedElement.info?.tagName,
|
tag: selectedElement.info?.tagName,
|
||||||
shadow: selectedElement.info?.isShadowRoot,
|
isShadow: highlighterData?.isShadow || highlighterData?.elementInfo?.isShadowRoot,
|
||||||
attribute: attribute
|
attribute: attribute
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1083,7 +1147,9 @@ export const BrowserWindow = () => {
|
|||||||
updatedFields,
|
updatedFields,
|
||||||
currentListId,
|
currentListId,
|
||||||
currentListActionId || `list-${crypto.randomUUID()}`,
|
currentListActionId || `list-${crypto.randomUUID()}`,
|
||||||
{ type: '', selector: paginationSelector }
|
{ type: "", selector: paginationSelector, isShadow: highlighterData?.isShadow },
|
||||||
|
undefined,
|
||||||
|
highlighterData?.isShadow
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1110,9 +1176,13 @@ export const BrowserWindow = () => {
|
|||||||
}, [paginationMode, resetPaginationSelector]);
|
}, [paginationMode, resetPaginationSelector]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onClick={handleClick} style={{ width: browserWidth }} id="browser-window">
|
<div
|
||||||
{
|
onClick={handleClick}
|
||||||
getText === true || getList === true ? (
|
style={{ width: browserWidth }}
|
||||||
|
id="browser-window"
|
||||||
|
>
|
||||||
|
{/* Attribute selection modal */}
|
||||||
|
{(getText === true || getList === true) && (
|
||||||
<GenericModal
|
<GenericModal
|
||||||
isOpen={showAttributeModal}
|
isOpen={showAttributeModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -1125,32 +1195,42 @@ export const BrowserWindow = () => {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2>Select Attribute</h2>
|
<h2>Select Attribute</h2>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', marginTop: '30px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "20px",
|
||||||
|
marginTop: "30px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{attributeOptions.map((option) => (
|
{attributeOptions.map((option) => (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="medium"
|
size="medium"
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => handleAttributeSelection(option.value)}
|
onClick={() => {
|
||||||
|
handleAttributeSelection(option.value);
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
justifyContent: 'flex-start',
|
justifyContent: "flex-start",
|
||||||
maxWidth: '80%',
|
maxWidth: "80%",
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
padding: '5px 10px',
|
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
color: '#ff00c3 !important',
|
color: "#ff00c3 !important",
|
||||||
borderColor: '#ff00c3 !important',
|
borderColor: "#ff00c3 !important",
|
||||||
backgroundColor: 'whitesmoke !important',
|
backgroundColor: "whitesmoke !important",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
maxWidth: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{
|
|
||||||
display: 'block',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
maxWidth: '100%'
|
|
||||||
}}>
|
|
||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1158,8 +1238,7 @@ export const BrowserWindow = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GenericModal>
|
</GenericModal>
|
||||||
) : null
|
)}
|
||||||
}
|
|
||||||
|
|
||||||
{datePickerInfo && (
|
{datePickerInfo && (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -1191,7 +1270,16 @@ export const BrowserWindow = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
<div style={{ height: dimensions.height, overflow: "hidden" }}>
|
<div style={{ height: dimensions.height, overflow: "hidden" }}>
|
||||||
|
{/* Add CSS for the spinner animation */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
{(getText === true || getList === true) &&
|
{(getText === true || getList === true) &&
|
||||||
!showAttributeModal &&
|
!showAttributeModal &&
|
||||||
highlighterData?.rect != null && (
|
highlighterData?.rect != null && (
|
||||||
@@ -1209,9 +1297,7 @@ export const BrowserWindow = () => {
|
|||||||
{isDOMMode && highlighterData && (
|
{isDOMMode && highlighterData && (
|
||||||
<>
|
<>
|
||||||
{/* Individual element highlight (for non-group or hovered element) */}
|
{/* Individual element highlight (for non-group or hovered element) */}
|
||||||
{(!getList ||
|
{getText && !listSelector && (
|
||||||
listSelector ||
|
|
||||||
!currentGroupInfo?.isGroupElement) && (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -1241,7 +1327,8 @@ export const BrowserWindow = () => {
|
|||||||
!listSelector &&
|
!listSelector &&
|
||||||
currentGroupInfo?.isGroupElement &&
|
currentGroupInfo?.isGroupElement &&
|
||||||
highlighterData.groupElements &&
|
highlighterData.groupElements &&
|
||||||
highlighterData.groupElements.map((groupElement, index) => (
|
highlighterData.groupElements.map(
|
||||||
|
(groupElement, index) => (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
{/* Highlight box */}
|
{/* Highlight box */}
|
||||||
<div
|
<div
|
||||||
@@ -1286,14 +1373,70 @@ export const BrowserWindow = () => {
|
|||||||
List item {index + 1}
|
List item {index + 1}
|
||||||
</div>
|
</div>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{getList &&
|
||||||
|
listSelector &&
|
||||||
|
!paginationMode &&
|
||||||
|
!limitMode &&
|
||||||
|
highlighterData?.similarElements &&
|
||||||
|
highlighterData.similarElements.rects.map(
|
||||||
|
(rect, index) => (
|
||||||
|
<React.Fragment key={`item-${index}`}>
|
||||||
|
{/* Highlight box for similar element */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: Math.max(0, rect.x),
|
||||||
|
top: Math.max(0, rect.y),
|
||||||
|
width: Math.min(rect.width, dimensions.width),
|
||||||
|
height: Math.min(
|
||||||
|
rect.height,
|
||||||
|
dimensions.height
|
||||||
|
),
|
||||||
|
background: "rgba(255, 0, 195, 0.15)",
|
||||||
|
border: "2px dashed #ff00c3",
|
||||||
|
borderRadius: "3px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 1000,
|
||||||
|
boxShadow: "0 0 0 1px rgba(255, 255, 255, 0.8)",
|
||||||
|
transition: "all 0.1s ease-out",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Label for similar element */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: Math.max(0, rect.x),
|
||||||
|
top: Math.max(0, rect.y - 20),
|
||||||
|
background: "#ff00c3",
|
||||||
|
color: "white",
|
||||||
|
padding: "2px 6px",
|
||||||
|
fontSize: "10px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
borderRadius: "2px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 1001,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Item {index + 1}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDOMMode ? (
|
{isDOMMode ? (
|
||||||
currentSnapshot ? (
|
<div
|
||||||
|
style={{ position: "relative", width: "100%", height: "100%" }}
|
||||||
|
>
|
||||||
|
{currentSnapshot ? (
|
||||||
<DOMBrowserRenderer
|
<DOMBrowserRenderer
|
||||||
width={dimensions.width}
|
width={dimensions.width}
|
||||||
height={dimensions.height}
|
height={dimensions.height}
|
||||||
@@ -1305,9 +1448,10 @@ export const BrowserWindow = () => {
|
|||||||
paginationMode={paginationMode}
|
paginationMode={paginationMode}
|
||||||
paginationType={paginationType}
|
paginationType={paginationType}
|
||||||
limitMode={limitMode}
|
limitMode={limitMode}
|
||||||
onHighlight={(data: any) => {
|
onHighlight={(data) => {
|
||||||
domHighlighterHandler(data);
|
domHighlighterHandler(data);
|
||||||
}}
|
}}
|
||||||
|
isCachingChildSelectors={isCachingChildSelectors}
|
||||||
onElementSelect={handleDOMElementSelection}
|
onElementSelect={handleDOMElementSelection}
|
||||||
onShowDatePicker={handleShowDatePicker}
|
onShowDatePicker={handleShowDatePicker}
|
||||||
onShowDropdown={handleShowDropdown}
|
onShowDropdown={handleShowDropdown}
|
||||||
@@ -1353,7 +1497,39 @@ export const BrowserWindow = () => {
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
|
|
||||||
|
{/* Loading overlay positioned specifically over DOM content */}
|
||||||
|
{isCachingChildSelectors && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
background: "rgba(255, 255, 255, 0.8)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 9999,
|
||||||
|
pointerEvents: "none",
|
||||||
|
borderRadius: "0px 0px 5px 5px", // Match the DOM renderer border radius
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "40px",
|
||||||
|
height: "40px",
|
||||||
|
border: "4px solid #f3f3f3",
|
||||||
|
borderTop: "4px solid #ff00c3",
|
||||||
|
borderRadius: "50%",
|
||||||
|
animation: "spin 1s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Screenshot mode canvas */
|
/* Screenshot mode canvas */
|
||||||
<Canvas
|
<Canvas
|
||||||
|
|||||||
@@ -102,16 +102,20 @@ interface RRWebDOMBrowserRendererProps {
|
|||||||
paginationMode?: boolean;
|
paginationMode?: boolean;
|
||||||
paginationType?: string;
|
paginationType?: string;
|
||||||
limitMode?: boolean;
|
limitMode?: boolean;
|
||||||
|
isCachingChildSelectors?: boolean;
|
||||||
onHighlight?: (data: {
|
onHighlight?: (data: {
|
||||||
rect: DOMRect;
|
rect: DOMRect;
|
||||||
selector: string;
|
selector: string;
|
||||||
|
isShadow?: boolean;
|
||||||
elementInfo: ElementInfo | null;
|
elementInfo: ElementInfo | null;
|
||||||
childSelectors?: string[];
|
childSelectors?: string[];
|
||||||
groupInfo?: any;
|
groupInfo?: any;
|
||||||
|
similarElements?: any;
|
||||||
}) => void;
|
}) => void;
|
||||||
onElementSelect?: (data: {
|
onElementSelect?: (data: {
|
||||||
rect: DOMRect;
|
rect: DOMRect;
|
||||||
selector: string;
|
selector: string;
|
||||||
|
isShadow?: boolean;
|
||||||
elementInfo: ElementInfo | null;
|
elementInfo: ElementInfo | null;
|
||||||
childSelectors?: string[];
|
childSelectors?: string[];
|
||||||
groupInfo?: any;
|
groupInfo?: any;
|
||||||
@@ -151,6 +155,7 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
|
|||||||
paginationMode = false,
|
paginationMode = false,
|
||||||
paginationType = "",
|
paginationType = "",
|
||||||
limitMode = false,
|
limitMode = false,
|
||||||
|
isCachingChildSelectors = false,
|
||||||
onHighlight,
|
onHighlight,
|
||||||
onElementSelect,
|
onElementSelect,
|
||||||
onShowDatePicker,
|
onShowDatePicker,
|
||||||
@@ -241,17 +246,15 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rect, selector, elementInfo, childSelectors, groupInfo } =
|
const { rect, selector, elementInfo, childSelectors, groupInfo, similarElements, isShadow } =
|
||||||
highlighterData;
|
highlighterData;
|
||||||
|
|
||||||
let shouldHighlight = false;
|
let shouldHighlight = false;
|
||||||
|
|
||||||
if (getList) {
|
if (getList) {
|
||||||
// First phase: Allow any group to be highlighted for selection
|
|
||||||
if (!listSelector && groupInfo?.isGroupElement) {
|
if (!listSelector && groupInfo?.isGroupElement) {
|
||||||
shouldHighlight = true;
|
shouldHighlight = true;
|
||||||
}
|
}
|
||||||
// Second phase: Show valid children within selected group
|
|
||||||
else if (listSelector) {
|
else if (listSelector) {
|
||||||
if (limitMode) {
|
if (limitMode) {
|
||||||
shouldHighlight = false;
|
shouldHighlight = false;
|
||||||
@@ -262,19 +265,15 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
|
|||||||
) {
|
) {
|
||||||
shouldHighlight = true;
|
shouldHighlight = true;
|
||||||
} else if (childSelectors && childSelectors.length > 0) {
|
} else if (childSelectors && childSelectors.length > 0) {
|
||||||
console.log("✅ Child selectors present, highlighting enabled");
|
|
||||||
shouldHighlight = true;
|
shouldHighlight = true;
|
||||||
} else {
|
} else {
|
||||||
console.log("❌ No child selectors available");
|
|
||||||
shouldHighlight = false;
|
shouldHighlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No list selector - show regular highlighting
|
|
||||||
else {
|
else {
|
||||||
shouldHighlight = true;
|
shouldHighlight = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// getText mode - always highlight
|
|
||||||
shouldHighlight = true;
|
shouldHighlight = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,8 +301,10 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
|
|||||||
isDOMMode: true,
|
isDOMMode: true,
|
||||||
},
|
},
|
||||||
selector,
|
selector,
|
||||||
|
isShadow,
|
||||||
childSelectors,
|
childSelectors,
|
||||||
groupInfo,
|
groupInfo,
|
||||||
|
similarElements, // Pass similar elements data
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,7 +334,6 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
|
|||||||
onHighlight,
|
onHighlight,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up enhanced interaction handlers for DOM mode
|
* Set up enhanced interaction handlers for DOM mode
|
||||||
*/
|
*/
|
||||||
@@ -408,6 +408,7 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
|
|||||||
rect: currentHighlight.rect,
|
rect: currentHighlight.rect,
|
||||||
selector: currentHighlight.selector,
|
selector: currentHighlight.selector,
|
||||||
elementInfo: currentHighlight.elementInfo,
|
elementInfo: currentHighlight.elementInfo,
|
||||||
|
isShadow: highlighterData?.isShadow,
|
||||||
childSelectors:
|
childSelectors:
|
||||||
cachedChildSelectors.length > 0
|
cachedChildSelectors.length > 0
|
||||||
? cachedChildSelectors
|
? cachedChildSelectors
|
||||||
@@ -756,7 +757,7 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInCaptureMode) {
|
if (isInCaptureMode || isCachingChildSelectors) {
|
||||||
return; // Skip rendering in capture mode
|
return; // Skip rendering in capture mode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,7 +868,7 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
|
|||||||
showErrorInIframe(error);
|
showErrorInIframe(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setupIframeInteractions, isInCaptureMode]
|
[setupIframeInteractions, isInCaptureMode, isCachingChildSelectors]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1083,7 +1084,7 @@ export const DOMBrowserRenderer: React.FC<RRWebDOMBrowserRendererProps> = ({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
cursor: "pointer !important",
|
cursor: "pointer",
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
zIndex: 999,
|
zIndex: 999,
|
||||||
borderRadius: "0px 0px 5px 5px",
|
borderRadius: "0px 0px 5px 5px",
|
||||||
|
|||||||
@@ -463,14 +463,15 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
const getListSettingsObject = useCallback(() => {
|
const getListSettingsObject = useCallback(() => {
|
||||||
let settings: {
|
let settings: {
|
||||||
listSelector?: string;
|
listSelector?: string;
|
||||||
fields?: Record<string, { selector: string; tag?: string;[key: string]: any }>;
|
fields?: Record<string, { selector: string; tag?: string; [key: string]: any; isShadow?: boolean }>;
|
||||||
pagination?: { type: string; selector?: string };
|
pagination?: { type: string; selector?: string; isShadow?: boolean };
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
isShadow?: boolean
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
browserSteps.forEach(step => {
|
browserSteps.forEach(step => {
|
||||||
if (step.type === 'list' && step.listSelector && Object.keys(step.fields).length > 0) {
|
if (step.type === 'list' && step.listSelector && Object.keys(step.fields).length > 0) {
|
||||||
const fields: Record<string, { selector: string; tag?: string;[key: string]: any }> = {};
|
const fields: Record<string, { selector: string; tag?: string;[key: string]: any; isShadow?: boolean }> = {};
|
||||||
|
|
||||||
Object.entries(step.fields).forEach(([id, field]) => {
|
Object.entries(step.fields).forEach(([id, field]) => {
|
||||||
if (field.selectorObj?.selector) {
|
if (field.selectorObj?.selector) {
|
||||||
@@ -478,6 +479,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
selector: field.selectorObj.selector,
|
selector: field.selectorObj.selector,
|
||||||
tag: field.selectorObj.tag,
|
tag: field.selectorObj.tag,
|
||||||
attribute: field.selectorObj.attribute,
|
attribute: field.selectorObj.attribute,
|
||||||
|
isShadow: field.selectorObj.isShadow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -485,8 +487,9 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
settings = {
|
settings = {
|
||||||
listSelector: step.listSelector,
|
listSelector: step.listSelector,
|
||||||
fields: fields,
|
fields: fields,
|
||||||
pagination: { type: paginationType, selector: step.pagination?.selector },
|
pagination: { type: paginationType, selector: step.pagination?.selector, isShadow: step.isShadow },
|
||||||
limit: parseInt(limitType === 'custom' ? customLimit : limitType),
|
limit: parseInt(limitType === 'custom' ? customLimit : limitType),
|
||||||
|
isShadow: step.isShadow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface TextStep {
|
|||||||
type: 'text';
|
type: 'text';
|
||||||
label: string;
|
label: string;
|
||||||
data: string;
|
data: string;
|
||||||
|
isShadow?: boolean;
|
||||||
selectorObj: SelectorObject;
|
selectorObj: SelectorObject;
|
||||||
actionId?: string;
|
actionId?: string;
|
||||||
}
|
}
|
||||||
@@ -21,10 +22,12 @@ export interface ListStep {
|
|||||||
id: number;
|
id: number;
|
||||||
type: 'list';
|
type: 'list';
|
||||||
listSelector: string;
|
listSelector: string;
|
||||||
|
isShadow?: boolean;
|
||||||
fields: { [key: string]: TextStep };
|
fields: { [key: string]: TextStep };
|
||||||
pagination?: {
|
pagination?: {
|
||||||
type: string;
|
type: string;
|
||||||
selector: string;
|
selector: string;
|
||||||
|
isShadow?: boolean;
|
||||||
};
|
};
|
||||||
limit?: number;
|
limit?: number;
|
||||||
actionId?: string;
|
actionId?: string;
|
||||||
@@ -36,14 +39,14 @@ export interface SelectorObject {
|
|||||||
selector: string;
|
selector: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
attribute?: string;
|
attribute?: string;
|
||||||
shadow?: boolean;
|
isShadow?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BrowserStepsContextType {
|
interface BrowserStepsContextType {
|
||||||
browserSteps: BrowserStep[];
|
browserSteps: BrowserStep[];
|
||||||
addTextStep: (label: string, data: string, selectorObj: SelectorObject, actionId: string) => void;
|
addTextStep: (label: string, data: string, selectorObj: SelectorObject, actionId: string) => void;
|
||||||
addListStep: (listSelector: string, fields: { [key: string]: TextStep }, listId: number, actionId: string, pagination?: { type: string; selector: string }, limit?: number) => void
|
addListStep: (listSelector: string, fields: { [key: string]: TextStep }, listId: number, actionId: string, pagination?: { type: string; selector: string, isShadow?: boolean }, limit?: number, isShadow?: boolean) => void
|
||||||
addScreenshotStep: (fullPage: boolean, actionId: string) => void;
|
addScreenshotStep: (fullPage: boolean, actionId: string) => void;
|
||||||
deleteBrowserStep: (id: number) => void;
|
deleteBrowserStep: (id: number) => void;
|
||||||
updateBrowserTextStepLabel: (id: number, newLabel: string) => void;
|
updateBrowserTextStepLabel: (id: number, newLabel: string) => void;
|
||||||
@@ -68,7 +71,15 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addListStep = (listSelector: string, newFields: { [key: string]: TextStep }, listId: number, actionId: string, pagination?: { type: string; selector: string }, limit?: number) => {
|
const addListStep = (
|
||||||
|
listSelector: string,
|
||||||
|
newFields: { [key: string]: TextStep },
|
||||||
|
listId: number,
|
||||||
|
actionId: string,
|
||||||
|
pagination?: { type: string; selector: string; isShadow?: boolean },
|
||||||
|
limit?: number,
|
||||||
|
isShadow?: boolean
|
||||||
|
) => {
|
||||||
setBrowserSteps(prevSteps => {
|
setBrowserSteps(prevSteps => {
|
||||||
const existingListStepIndex = prevSteps.findIndex(step => step.type === 'list' && step.id === listId);
|
const existingListStepIndex = prevSteps.findIndex(step => step.type === 'list' && step.id === listId);
|
||||||
|
|
||||||
@@ -101,7 +112,8 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
fields: mergedFields,
|
fields: mergedFields,
|
||||||
pagination: pagination || existingListStep.pagination,
|
pagination: pagination || existingListStep.pagination,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
actionId
|
actionId,
|
||||||
|
isShadow: isShadow !== undefined ? isShadow : existingListStep.isShadow
|
||||||
};
|
};
|
||||||
return updatedSteps;
|
return updatedSteps;
|
||||||
} else {
|
} else {
|
||||||
@@ -115,7 +127,16 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
...prevSteps,
|
...prevSteps,
|
||||||
{ id: listId, type: 'list', listSelector, fields: fieldsWithActionId, pagination, limit, actionId }
|
{
|
||||||
|
id: listId,
|
||||||
|
type: 'list',
|
||||||
|
listSelector,
|
||||||
|
fields: fieldsWithActionId,
|
||||||
|
pagination,
|
||||||
|
limit,
|
||||||
|
actionId,
|
||||||
|
isShadow
|
||||||
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface TextStep {
|
|||||||
selectorObj: {
|
selectorObj: {
|
||||||
selector: string;
|
selector: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
shadow?: boolean;
|
isShadow?: boolean;
|
||||||
attribute: string;
|
attribute: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,8 @@ interface ExtractedListData {
|
|||||||
interface Field {
|
interface Field {
|
||||||
selector: string;
|
selector: string;
|
||||||
attribute: string;
|
attribute: string;
|
||||||
|
tag?: string;
|
||||||
|
isShadow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ClientListExtractor {
|
class ClientListExtractor {
|
||||||
@@ -156,50 +158,6 @@ class ClientListExtractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
!nextElement &&
|
|
||||||
"shadowRoot" in currentElement &&
|
|
||||||
(currentElement as Element).shadowRoot
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
parts[i].startsWith("//") ||
|
|
||||||
parts[i].startsWith("/") ||
|
|
||||||
parts[i].startsWith("./")
|
|
||||||
) {
|
|
||||||
nextElement = this.evaluateXPath(
|
|
||||||
(currentElement as Element).shadowRoot as unknown as Document,
|
|
||||||
parts[i]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
nextElement = (currentElement as Element).shadowRoot!.querySelector(
|
|
||||||
parts[i]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nextElement && "children" in currentElement) {
|
|
||||||
const children: any = Array.from(
|
|
||||||
(currentElement as Element).children || []
|
|
||||||
);
|
|
||||||
for (const child of children) {
|
|
||||||
if (child.shadowRoot) {
|
|
||||||
if (
|
|
||||||
parts[i].startsWith("//") ||
|
|
||||||
parts[i].startsWith("/") ||
|
|
||||||
parts[i].startsWith("./")
|
|
||||||
) {
|
|
||||||
nextElement = this.evaluateXPath(
|
|
||||||
child.shadowRoot as unknown as Document,
|
|
||||||
parts[i]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
nextElement = child.shadowRoot.querySelector(parts[i]);
|
|
||||||
}
|
|
||||||
if (nextElement) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentElement = nextElement;
|
currentElement = nextElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,43 +223,6 @@ class ClientListExtractor {
|
|||||||
nextElements.push(...Array.from(element.querySelectorAll(part)));
|
nextElements.push(...Array.from(element.querySelectorAll(part)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("shadowRoot" in element && (element as Element).shadowRoot) {
|
|
||||||
if (part.startsWith("//") || part.startsWith("/")) {
|
|
||||||
nextElements.push(
|
|
||||||
...this.evaluateXPathAll(
|
|
||||||
(element as Element).shadowRoot as unknown as Document,
|
|
||||||
part
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
nextElements.push(
|
|
||||||
...Array.from(
|
|
||||||
(element as Element).shadowRoot!.querySelectorAll(part)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("children" in element) {
|
|
||||||
const children = Array.from((element as Element).children || []);
|
|
||||||
for (const child of children) {
|
|
||||||
if (child.shadowRoot) {
|
|
||||||
if (part.startsWith("//") || part.startsWith("/")) {
|
|
||||||
nextElements.push(
|
|
||||||
...this.evaluateXPathAll(
|
|
||||||
child.shadowRoot as unknown as Document,
|
|
||||||
part
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
nextElements.push(
|
|
||||||
...Array.from(child.shadowRoot.querySelectorAll(part))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,14 +249,11 @@ class ClientListExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (attribute === "innerText") {
|
if (attribute === "innerText") {
|
||||||
// First try standard innerText/textContent
|
|
||||||
let textContent =
|
let textContent =
|
||||||
(element as HTMLElement).innerText?.trim() ||
|
(element as HTMLElement).innerText?.trim() ||
|
||||||
(element as HTMLElement).textContent?.trim();
|
(element as HTMLElement).textContent?.trim();
|
||||||
|
|
||||||
// If empty, check for common data attributes that might contain the text
|
|
||||||
if (!textContent) {
|
if (!textContent) {
|
||||||
// Check for data-* attributes that commonly contain text values
|
|
||||||
const dataAttributes = [
|
const dataAttributes = [
|
||||||
"data-600",
|
"data-600",
|
||||||
"data-text",
|
"data-text",
|
||||||
@@ -356,10 +274,8 @@ class ClientListExtractor {
|
|||||||
} else if (attribute === "innerHTML") {
|
} else if (attribute === "innerHTML") {
|
||||||
return element.innerHTML?.trim() || null;
|
return element.innerHTML?.trim() || null;
|
||||||
} else if (attribute === "href") {
|
} else if (attribute === "href") {
|
||||||
// For href, we need to find the anchor tag if the current element isn't one
|
|
||||||
let anchorElement = element;
|
let anchorElement = element;
|
||||||
|
|
||||||
// If current element is not an anchor, look for parent anchor
|
|
||||||
if (element.tagName !== "A") {
|
if (element.tagName !== "A") {
|
||||||
anchorElement =
|
anchorElement =
|
||||||
element.closest("a") ||
|
element.closest("a") ||
|
||||||
@@ -410,6 +326,7 @@ class ClientListExtractor {
|
|||||||
convertedFields[typedField.label] = {
|
convertedFields[typedField.label] = {
|
||||||
selector: typedField.selectorObj.selector,
|
selector: typedField.selectorObj.selector,
|
||||||
attribute: typedField.selectorObj.attribute,
|
attribute: typedField.selectorObj.attribute,
|
||||||
|
isShadow: typedField.selectorObj.isShadow || false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,10 +340,8 @@ class ClientListExtractor {
|
|||||||
limit: number = 5
|
limit: number = 5
|
||||||
): ExtractedListData[] => {
|
): ExtractedListData[] => {
|
||||||
try {
|
try {
|
||||||
// Convert fields to the format expected by the extraction logic
|
|
||||||
const convertedFields = this.convertFields(fields);
|
const convertedFields = this.convertFields(fields);
|
||||||
|
|
||||||
// Step 1: Get all container elements matching the list selector
|
|
||||||
const containers = this.queryElementAll(iframeDocument, listSelector);
|
const containers = this.queryElementAll(iframeDocument, listSelector);
|
||||||
|
|
||||||
if (containers.length === 0) {
|
if (containers.length === 0) {
|
||||||
@@ -434,7 +349,6 @@ class ClientListExtractor {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Extract data from each container up to the limit
|
|
||||||
const extractedData: ExtractedListData[] = [];
|
const extractedData: ExtractedListData[] = [];
|
||||||
const containersToProcess = Math.min(containers.length, limit);
|
const containersToProcess = Math.min(containers.length, limit);
|
||||||
|
|
||||||
@@ -446,28 +360,27 @@ class ClientListExtractor {
|
|||||||
const container = containers[containerIndex];
|
const container = containers[containerIndex];
|
||||||
const record: ExtractedListData = {};
|
const record: ExtractedListData = {};
|
||||||
|
|
||||||
// Step 3: For each field, extract data from the current container
|
for (const [label, { selector, attribute, isShadow }] of Object.entries(
|
||||||
for (const [label, { selector, attribute }] of Object.entries(
|
|
||||||
convertedFields
|
convertedFields
|
||||||
)) {
|
)) {
|
||||||
let element: Element | null = null;
|
let element: Element | null = null;
|
||||||
|
|
||||||
// CORRECT APPROACH: Create indexed absolute XPath
|
|
||||||
if (selector.startsWith("//")) {
|
if (selector.startsWith("//")) {
|
||||||
// Convert the absolute selector to target the specific container instance
|
|
||||||
const indexedSelector = this.createIndexedXPath(
|
const indexedSelector = this.createIndexedXPath(
|
||||||
selector,
|
selector,
|
||||||
listSelector,
|
listSelector,
|
||||||
containerIndex + 1
|
containerIndex + 1
|
||||||
);
|
);
|
||||||
|
|
||||||
element = this.evaluateXPathSingle(iframeDocument, indexedSelector);
|
element = this.evaluateXPathSingle(
|
||||||
|
iframeDocument,
|
||||||
|
indexedSelector,
|
||||||
|
isShadow
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Fallback for non-XPath selectors
|
|
||||||
element = this.queryElement(container, selector);
|
element = this.queryElement(container, selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Extract the value from the found element
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const value = this.extractValue(element, attribute);
|
const value = this.extractValue(element, attribute);
|
||||||
if (value !== null && value !== "") {
|
if (value !== null && value !== "") {
|
||||||
@@ -482,7 +395,6 @@ class ClientListExtractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Add record if it has any non-empty values
|
|
||||||
if (Object.values(record).some((value) => value !== "")) {
|
if (Object.values(record).some((value) => value !== "")) {
|
||||||
extractedData.push(record);
|
extractedData.push(record);
|
||||||
} else {
|
} else {
|
||||||
@@ -499,15 +411,12 @@ class ClientListExtractor {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create indexed XPath for specific container instance
|
|
||||||
private createIndexedXPath(
|
private createIndexedXPath(
|
||||||
childSelector: string,
|
childSelector: string,
|
||||||
listSelector: string,
|
listSelector: string,
|
||||||
containerIndex: number
|
containerIndex: number
|
||||||
): string {
|
): string {
|
||||||
// Check if the child selector contains the list selector pattern
|
|
||||||
if (childSelector.includes(listSelector.replace("//", ""))) {
|
if (childSelector.includes(listSelector.replace("//", ""))) {
|
||||||
// Replace the list selector part with indexed version
|
|
||||||
const listPattern = listSelector.replace("//", "");
|
const listPattern = listSelector.replace("//", "");
|
||||||
const indexedListSelector = `(${listSelector})[${containerIndex}]`;
|
const indexedListSelector = `(${listSelector})[${containerIndex}]`;
|
||||||
|
|
||||||
@@ -518,8 +427,6 @@ class ClientListExtractor {
|
|||||||
|
|
||||||
return indexedSelector;
|
return indexedSelector;
|
||||||
} else {
|
} else {
|
||||||
// If pattern doesn't match, create a more generic indexed selector
|
|
||||||
// This is a fallback approach
|
|
||||||
console.warn(` ⚠️ Pattern doesn't match, using fallback approach`);
|
console.warn(` ⚠️ Pattern doesn't match, using fallback approach`);
|
||||||
return `(${listSelector})[${containerIndex}]${childSelector.replace(
|
return `(${listSelector})[${containerIndex}]${childSelector.replace(
|
||||||
"//",
|
"//",
|
||||||
@@ -531,7 +438,8 @@ class ClientListExtractor {
|
|||||||
// Helper method for single XPath evaluation
|
// Helper method for single XPath evaluation
|
||||||
private evaluateXPathSingle = (
|
private evaluateXPathSingle = (
|
||||||
document: Document,
|
document: Document,
|
||||||
xpath: string
|
xpath: string,
|
||||||
|
isShadow: boolean = false
|
||||||
): Element | null => {
|
): Element | null => {
|
||||||
try {
|
try {
|
||||||
const result = document.evaluate(
|
const result = document.evaluate(
|
||||||
@@ -540,19 +448,227 @@ class ClientListExtractor {
|
|||||||
null,
|
null,
|
||||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||||
null
|
null
|
||||||
);
|
).singleNodeValue as Element | null;
|
||||||
|
|
||||||
const element = result.singleNodeValue as Element | null;
|
if (!isShadow) {
|
||||||
|
if (result === null) {
|
||||||
if (!element) {
|
|
||||||
console.warn(`❌ XPath found no element for: ${xpath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return element;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ XPath evaluation failed:", xpath, error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanPath = xpath;
|
||||||
|
let isIndexed = false;
|
||||||
|
|
||||||
|
const indexedMatch = xpath.match(/^\((.*?)\)\[(\d+)\](.*)$/);
|
||||||
|
if (indexedMatch) {
|
||||||
|
cleanPath = indexedMatch[1] + indexedMatch[3];
|
||||||
|
isIndexed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathParts = cleanPath
|
||||||
|
.replace(/^\/\//, "")
|
||||||
|
.split("/")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter((p) => p.length > 0);
|
||||||
|
|
||||||
|
let currentContexts: (Document | Element | ShadowRoot)[] = [document];
|
||||||
|
|
||||||
|
for (let i = 0; i < pathParts.length; i++) {
|
||||||
|
const part = pathParts[i];
|
||||||
|
const nextContexts: (Element | ShadowRoot)[] = [];
|
||||||
|
|
||||||
|
for (const ctx of currentContexts) {
|
||||||
|
const positionalMatch = part.match(/^([^[]+)\[(\d+)\]$/);
|
||||||
|
let partWithoutPosition = part;
|
||||||
|
let requestedPosition: number | null = null;
|
||||||
|
|
||||||
|
if (positionalMatch) {
|
||||||
|
partWithoutPosition = positionalMatch[1];
|
||||||
|
requestedPosition = parseInt(positionalMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = this.queryInsideContext(ctx, partWithoutPosition);
|
||||||
|
|
||||||
|
let elementsToAdd = matched;
|
||||||
|
if (requestedPosition !== null) {
|
||||||
|
const index = requestedPosition - 1; // XPath is 1-based, arrays are 0-based
|
||||||
|
if (index >= 0 && index < matched.length) {
|
||||||
|
elementsToAdd = [matched[index]];
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
` ⚠️ Position ${requestedPosition} out of range (${matched.length} elements found)`
|
||||||
|
);
|
||||||
|
elementsToAdd = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elementsToAdd.forEach((el) => {
|
||||||
|
nextContexts.push(el);
|
||||||
|
if (el.shadowRoot) {
|
||||||
|
nextContexts.push(el.shadowRoot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextContexts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentContexts = nextContexts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentContexts.length > 0) {
|
||||||
|
if (isIndexed && indexedMatch) {
|
||||||
|
const requestedIndex = parseInt(indexedMatch[2]) - 1; // XPath is 1-based, array is 0-based
|
||||||
|
if (requestedIndex >= 0 && requestedIndex < currentContexts.length) {
|
||||||
|
return currentContexts[requestedIndex] as Element;
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`⚠️ Requested index ${requestedIndex + 1} out of range (${
|
||||||
|
currentContexts.length
|
||||||
|
} elements found)`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentContexts[0] as Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("💥 Critical XPath failure:", xpath, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private queryInsideContext = (
|
||||||
|
context: Document | Element | ShadowRoot,
|
||||||
|
part: string
|
||||||
|
): Element[] => {
|
||||||
|
try {
|
||||||
|
const { tagName, conditions } = this.parseXPathPart(part);
|
||||||
|
|
||||||
|
const candidateElements = Array.from(context.querySelectorAll(tagName));
|
||||||
|
if (candidateElements.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingElements = candidateElements.filter((el) => {
|
||||||
|
const matches = this.elementMatchesConditions(el, conditions);
|
||||||
|
return matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
return matchingElements;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error in queryInsideContext:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private parseXPathPart = (
|
||||||
|
part: string
|
||||||
|
): { tagName: string; conditions: string[] } => {
|
||||||
|
const tagMatch = part.match(/^([a-zA-Z0-9-]+)/);
|
||||||
|
const tagName = tagMatch ? tagMatch[1] : "*";
|
||||||
|
|
||||||
|
const conditionMatches = part.match(/\[([^\]]+)\]/g);
|
||||||
|
const conditions = conditionMatches
|
||||||
|
? conditionMatches.map((c) => c.slice(1, -1))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { tagName, conditions };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if element matches all given conditions
|
||||||
|
private elementMatchesConditions = (
|
||||||
|
element: Element,
|
||||||
|
conditions: string[]
|
||||||
|
): boolean => {
|
||||||
|
for (const condition of conditions) {
|
||||||
|
if (!this.elementMatchesCondition(element, condition)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
private elementMatchesCondition = (
|
||||||
|
element: Element,
|
||||||
|
condition: string
|
||||||
|
): boolean => {
|
||||||
|
condition = condition.trim();
|
||||||
|
|
||||||
|
if (/^\d+$/.test(condition)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle @attribute="value"
|
||||||
|
const attrMatch = condition.match(/^@([^=]+)=["']([^"']+)["']$/);
|
||||||
|
if (attrMatch) {
|
||||||
|
const [, attr, value] = attrMatch;
|
||||||
|
const elementValue = element.getAttribute(attr);
|
||||||
|
const matches = elementValue === value;
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle contains(@class, 'value')
|
||||||
|
const classContainsMatch = condition.match(
|
||||||
|
/^contains\(@class,\s*["']([^"']+)["']\)$/
|
||||||
|
);
|
||||||
|
if (classContainsMatch) {
|
||||||
|
const className = classContainsMatch[1];
|
||||||
|
const matches = element.classList.contains(className);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle contains(@attribute, 'value')
|
||||||
|
const attrContainsMatch = condition.match(
|
||||||
|
/^contains\(@([^,]+),\s*["']([^"']+)["']\)$/
|
||||||
|
);
|
||||||
|
if (attrContainsMatch) {
|
||||||
|
const [, attr, value] = attrContainsMatch;
|
||||||
|
const elementValue = element.getAttribute(attr) || "";
|
||||||
|
const matches = elementValue.includes(value);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text()="value"
|
||||||
|
const textMatch = condition.match(/^text\(\)=["']([^"']+)["']$/);
|
||||||
|
if (textMatch) {
|
||||||
|
const expectedText = textMatch[1];
|
||||||
|
const elementText = element.textContent?.trim() || "";
|
||||||
|
const matches = elementText === expectedText;
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle contains(text(), 'value')
|
||||||
|
const textContainsMatch = condition.match(
|
||||||
|
/^contains\(text\(\),\s*["']([^"']+)["']\)$/
|
||||||
|
);
|
||||||
|
if (textContainsMatch) {
|
||||||
|
const expectedText = textContainsMatch[1];
|
||||||
|
const elementText = element.textContent?.trim() || "";
|
||||||
|
const matches = elementText.includes(expectedText);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle count(*)=0 (element has no children)
|
||||||
|
if (condition === "count(*)=0") {
|
||||||
|
const matches = element.children.length === 0;
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other count conditions
|
||||||
|
const countMatch = condition.match(/^count\(\*\)=(\d+)$/);
|
||||||
|
if (countMatch) {
|
||||||
|
const expectedCount = parseInt(countMatch[1]);
|
||||||
|
const matches = element.children.length === expectedCount;
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user