Merge pull request #694 from getmaxun/child-shadow

feat(maxun-core): child extraction + deep shadow dom
This commit is contained in:
Karishma Shukla
2025-07-16 01:20:08 +05:30
committed by GitHub
7 changed files with 2320 additions and 1188 deletions

View File

@@ -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];

View File

@@ -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

View File

@@ -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",

View File

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

View File

@@ -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
}
]; ];
} }
}); });

View File

@@ -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