Merge pull request #654 from getmaxun/record-revamp
feat: recorder revamp
This commit is contained in:
File diff suppressed because it is too large
Load Diff
10
server/src/browser-management/classes/bundle-rrweb.js
Normal file
10
server/src/browser-management/classes/bundle-rrweb.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const esbuild = require('esbuild');
|
||||||
|
|
||||||
|
esbuild.build({
|
||||||
|
entryPoints: ['rrweb-entry.js'],
|
||||||
|
bundle: true,
|
||||||
|
minify: true,
|
||||||
|
outfile: 'rrweb-bundle.js',
|
||||||
|
format: 'iife', // so that rrwebSnapshot is available on window
|
||||||
|
globalName: 'rrwebSnapshotBundle'
|
||||||
|
}).catch(() => process.exit(1));
|
||||||
1
server/src/browser-management/classes/rrweb-bundle.js
Normal file
1
server/src/browser-management/classes/rrweb-bundle.js
Normal file
File diff suppressed because one or more lines are too long
2
server/src/browser-management/classes/rrweb-entry.js
Normal file
2
server/src/browser-management/classes/rrweb-entry.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { snapshot } from 'rrweb-snapshot';
|
||||||
|
window.rrwebSnapshot = { snapshot };
|
||||||
@@ -20,7 +20,7 @@ import logger from "../logger";
|
|||||||
* @returns string
|
* @returns string
|
||||||
* @category BrowserManagement-Controller
|
* @category BrowserManagement-Controller
|
||||||
*/
|
*/
|
||||||
export const initializeRemoteBrowserForRecording = (userId: string): string => {
|
export const initializeRemoteBrowserForRecording = (userId: string, mode: string = "dom"): string => {
|
||||||
const id = getActiveBrowserIdByState(userId, "recording") || uuid();
|
const id = getActiveBrowserIdByState(userId, "recording") || uuid();
|
||||||
createSocketConnection(
|
createSocketConnection(
|
||||||
io.of(id),
|
io.of(id),
|
||||||
@@ -37,7 +37,15 @@ export const initializeRemoteBrowserForRecording = (userId: string): string => {
|
|||||||
browserSession.interpreter.subscribeToPausing();
|
browserSession.interpreter.subscribeToPausing();
|
||||||
await browserSession.initialize(userId);
|
await browserSession.initialize(userId);
|
||||||
await browserSession.registerEditorEvents();
|
await browserSession.registerEditorEvents();
|
||||||
await browserSession.subscribeToScreencast();
|
|
||||||
|
if (mode === "dom") {
|
||||||
|
await browserSession.subscribeToDOM();
|
||||||
|
logger.info('DOM streaming started for scraping browser in recording mode');
|
||||||
|
} else {
|
||||||
|
await browserSession.subscribeToScreencast();
|
||||||
|
logger.info('Screenshot streaming started for local browser in recording mode');
|
||||||
|
}
|
||||||
|
|
||||||
browserPool.addRemoteBrowser(id, browserSession, userId, false, "recording");
|
browserPool.addRemoteBrowser(id, browserSession, userId, false, "recording");
|
||||||
}
|
}
|
||||||
socket.emit('loaded');
|
socket.emit('loaded');
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { WorkflowGenerator } from "../workflow-management/classes/Generator";
|
|||||||
import { Page } from "playwright";
|
import { Page } from "playwright";
|
||||||
import { throttle } from "../../../src/helpers/inputHelpers";
|
import { throttle } from "../../../src/helpers/inputHelpers";
|
||||||
import { CustomActions } from "../../../src/shared/types";
|
import { CustomActions } from "../../../src/shared/types";
|
||||||
|
import { WhereWhatPair } from "maxun-core";
|
||||||
|
import { RemoteBrowser } from './classes/RemoteBrowser';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapper function for handling user input.
|
* A wrapper function for handling user input.
|
||||||
@@ -27,7 +29,7 @@ import { CustomActions } from "../../../src/shared/types";
|
|||||||
*/
|
*/
|
||||||
const handleWrapper = async (
|
const handleWrapper = async (
|
||||||
handleCallback: (
|
handleCallback: (
|
||||||
generator: WorkflowGenerator,
|
activeBrowser: RemoteBrowser,
|
||||||
page: Page,
|
page: Page,
|
||||||
args?: any
|
args?: any
|
||||||
) => Promise<void>,
|
) => Promise<void>,
|
||||||
@@ -44,9 +46,9 @@ const handleWrapper = async (
|
|||||||
const currentPage = activeBrowser?.getCurrentPage();
|
const currentPage = activeBrowser?.getCurrentPage();
|
||||||
if (currentPage && activeBrowser) {
|
if (currentPage && activeBrowser) {
|
||||||
if (args) {
|
if (args) {
|
||||||
await handleCallback(activeBrowser.generator, currentPage, args);
|
await handleCallback(activeBrowser, currentPage, args);
|
||||||
} else {
|
} else {
|
||||||
await handleCallback(activeBrowser.generator, currentPage);
|
await handleCallback(activeBrowser, currentPage);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.log('warn', `No active page for browser ${id}`);
|
logger.log('warn', `No active page for browser ${id}`);
|
||||||
@@ -85,8 +87,19 @@ const onGenerateAction = async (customActionEventData: CustomActionEventData, us
|
|||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleGenerateAction =
|
const handleGenerateAction =
|
||||||
async (generator: WorkflowGenerator, page: Page, { action, settings }: CustomActionEventData) => {
|
async (activeBrowser: RemoteBrowser, page: Page, { action, settings }: CustomActionEventData) => {
|
||||||
await generator.customAction(action, settings, page);
|
try {
|
||||||
|
if (page.isClosed()) {
|
||||||
|
logger.log("debug", `Ignoring generate action event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await generator.customAction(action, settings, page);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling generate action event: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,40 +117,51 @@ const onMousedown = async (coordinates: Coordinates, userId: string) => {
|
|||||||
* A mousedown event handler.
|
* A mousedown event handler.
|
||||||
* Reproduces the click on the remote browser instance
|
* Reproduces the click on the remote browser instance
|
||||||
* and generates pair data for the recorded workflow.
|
* and generates pair data for the recorded workflow.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @param x - the x coordinate of the mousedown event
|
* @param x - the x coordinate of the mousedown event
|
||||||
* @param y - the y coordinate of the mousedown event
|
* @param y - the y coordinate of the mousedown event
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleMousedown = async (generator: WorkflowGenerator, page: Page, { x, y }: Coordinates) => {
|
const handleMousedown = async (activeBrowser: RemoteBrowser, page: Page, { x, y }: Coordinates) => {
|
||||||
|
try {
|
||||||
|
if (page.isClosed()) {
|
||||||
|
logger.log("debug", `Ignoring mousedown event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
await generator.onClick({ x, y }, page);
|
await generator.onClick({ x, y }, page);
|
||||||
const previousUrl = page.url();
|
const previousUrl = page.url();
|
||||||
const tabsBeforeClick = page.context().pages().length;
|
const tabsBeforeClick = page.context().pages().length;
|
||||||
await page.mouse.click(x, y);
|
await page.mouse.click(x, y);
|
||||||
// try if the click caused a navigation to a new url
|
// try if the click caused a navigation to a new url
|
||||||
try {
|
try {
|
||||||
await page.waitForNavigation({ timeout: 2000 });
|
await page.waitForNavigation({ timeout: 2000 });
|
||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
if (currentUrl !== previousUrl) {
|
if (currentUrl !== previousUrl) {
|
||||||
generator.notifyUrlChange(currentUrl);
|
generator.notifyUrlChange(currentUrl);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { message } = e as Error;
|
const { message } = e as Error;
|
||||||
} //ignore possible timeouts
|
} //ignore possible timeouts
|
||||||
|
|
||||||
// check if any new page was opened by the click
|
// check if any new page was opened by the click
|
||||||
const tabsAfterClick = page.context().pages().length;
|
const tabsAfterClick = page.context().pages().length;
|
||||||
const numOfNewPages = tabsAfterClick - tabsBeforeClick;
|
const numOfNewPages = tabsAfterClick - tabsBeforeClick;
|
||||||
if (numOfNewPages > 0) {
|
if (numOfNewPages > 0) {
|
||||||
for (let i = 1; i <= numOfNewPages; i++) {
|
for (let i = 1; i <= numOfNewPages; i++) {
|
||||||
const newPage = page.context().pages()[tabsAfterClick - i];
|
const newPage = page.context().pages()[tabsAfterClick - i];
|
||||||
if (newPage) {
|
if (newPage) {
|
||||||
generator.notifyOnNewTab(newPage, tabsAfterClick - i);
|
generator.notifyOnNewTab(newPage, tabsAfterClick - i);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.log('debug', `Clicked on position x:${x}, y:${y}`);
|
logger.log("debug", `Clicked on position x:${x}, y:${y}`);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling mousedown event: ${message}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,15 +180,16 @@ const onWheel = async (scrollDeltas: ScrollDeltas, userId: string) => {
|
|||||||
* Reproduces the wheel event on the remote browser instance.
|
* Reproduces the wheel event on the remote browser instance.
|
||||||
* Scroll is not generated for the workflow pair. This is because
|
* Scroll is not generated for the workflow pair. This is because
|
||||||
* Playwright scrolls elements into focus on any action.
|
* Playwright scrolls elements into focus on any action.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @param deltaX - the delta x of the wheel event
|
* @param deltaX - the delta x of the wheel event
|
||||||
* @param deltaY - the delta y of the wheel event
|
* @param deltaY - the delta y of the wheel event
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleWheel = async (generator: WorkflowGenerator, page: Page, { deltaX, deltaY }: ScrollDeltas) => {
|
const handleWheel = async (activeBrowser: RemoteBrowser, page: Page, { deltaX, deltaY }: ScrollDeltas) => {
|
||||||
try {
|
try {
|
||||||
if (page.isClosed()) {
|
if (page.isClosed()) {
|
||||||
|
logger.log("debug", `Ignoring wheel event: page is closed`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,28 +219,30 @@ const onMousemove = async (coordinates: Coordinates, userId: string) => {
|
|||||||
* Reproduces the mousemove event on the remote browser instance
|
* Reproduces the mousemove event on the remote browser instance
|
||||||
* and generates data for the client's highlighter.
|
* and generates data for the client's highlighter.
|
||||||
* Mousemove is also not reflected in the workflow.
|
* Mousemove is also not reflected in the workflow.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @param x - the x coordinate of the mousemove event
|
* @param x - the x coordinate of the mousemove event
|
||||||
* @param y - the y coordinate of the mousemove event
|
* @param y - the y coordinate of the mousemove event
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleMousemove = async (generator: WorkflowGenerator, page: Page, { x, y }: Coordinates) => {
|
const handleMousemove = async (activeBrowser: RemoteBrowser, page: Page, { x, y }: Coordinates) => {
|
||||||
try {
|
try {
|
||||||
if (page.isClosed()) {
|
if (page.isClosed()) {
|
||||||
logger.log('debug', `Ignoring mousemove event: page is closed`);
|
logger.log("debug", `Ignoring mousemove event: page is closed`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
await page.mouse.move(x, y);
|
await page.mouse.move(x, y);
|
||||||
throttle(async () => {
|
throttle(async () => {
|
||||||
if (!page.isClosed()) {
|
if (!page.isClosed()) {
|
||||||
await generator.generateDataForHighlighter(page, { x, y });
|
await generator.generateDataForHighlighter(page, { x, y });
|
||||||
}
|
}
|
||||||
}, 100)();
|
}, 100)();
|
||||||
logger.log('debug', `Moved over position x:${x}, y:${y}`);
|
logger.log("debug", `Moved over position x:${x}, y:${y}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const { message } = e as Error;
|
const { message } = e as Error;
|
||||||
logger.log('error', message);
|
logger.log("error", message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,28 +261,50 @@ const onKeydown = async (keyboardInput: KeyboardInput, userId: string) => {
|
|||||||
* A keydown event handler.
|
* A keydown event handler.
|
||||||
* Reproduces the keydown event on the remote browser instance
|
* Reproduces the keydown event on the remote browser instance
|
||||||
* and generates the workflow pair data.
|
* and generates the workflow pair data.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @param key - the pressed key
|
* @param key - the pressed key
|
||||||
* @param coordinates - the coordinates, where the keydown event happened
|
* @param coordinates - the coordinates, where the keydown event happened
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleKeydown = async (generator: WorkflowGenerator, page: Page, { key, coordinates }: KeyboardInput) => {
|
const handleKeydown = async (activeBrowser: RemoteBrowser, page: Page, { key, coordinates }: KeyboardInput) => {
|
||||||
await page.keyboard.down(key);
|
try {
|
||||||
await generator.onKeyboardInput(key, coordinates, page);
|
if (page.isClosed()) {
|
||||||
logger.log('debug', `Key ${key} pressed`);
|
logger.log("debug", `Ignoring keydown event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await page.keyboard.down(key);
|
||||||
|
await generator.onKeyboardInput(key, coordinates, page);
|
||||||
|
logger.log("debug", `Key ${key} pressed`);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling keydown event: ${message}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the date selection event.
|
* Handles the date selection event.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @param data - the data of the date selection event {@link DatePickerEventData}
|
* @param data - the data of the date selection event {@link DatePickerEventData}
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleDateSelection = async (generator: WorkflowGenerator, page: Page, data: DatePickerEventData) => {
|
const handleDateSelection = async (activeBrowser: RemoteBrowser, page: Page, data: DatePickerEventData) => {
|
||||||
await generator.onDateSelection(page, data);
|
try {
|
||||||
logger.log('debug', `Date ${data.value} selected`);
|
if (page.isClosed()) {
|
||||||
|
logger.log("debug", `Ignoring date selection event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await generator.onDateSelection(page, data);
|
||||||
|
logger.log("debug", `Date ${data.value} selected`);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling date selection event: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,14 +320,25 @@ const onDateSelection = async (data: DatePickerEventData, userId: string) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the dropdown selection event.
|
* Handles the dropdown selection event.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @param data - the data of the dropdown selection event
|
* @param data - the data of the dropdown selection event
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleDropdownSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
|
const handleDropdownSelection = async (activeBrowser: RemoteBrowser, page: Page, data: { selector: string, value: string }) => {
|
||||||
await generator.onDropdownSelection(page, data);
|
try {
|
||||||
logger.log('debug', `Dropdown value ${data.value} selected`);
|
if (page.isClosed()) {
|
||||||
|
logger.log("debug", `Ignoring dropdown selection event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await generator.onDropdownSelection(page, data);
|
||||||
|
logger.log("debug", `Dropdown value ${data.value} selected`);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling dropdown selection event: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -294,14 +354,25 @@ const onDropdownSelection = async (data: { selector: string, value: string }, us
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the time selection event.
|
* Handles the time selection event.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @param data - the data of the time selection event
|
* @param data - the data of the time selection event
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleTimeSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
|
const handleTimeSelection = async (activeBrowser: RemoteBrowser, page: Page, data: { selector: string, value: string }) => {
|
||||||
await generator.onTimeSelection(page, data);
|
try {
|
||||||
logger.log('debug', `Time value ${data.value} selected`);
|
if (page.isClosed()) {
|
||||||
|
logger.log("debug", `Ignoring time selection event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await generator.onTimeSelection(page, data);
|
||||||
|
logger.log("debug", `Time value ${data.value} selected`);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling time selection event: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -317,14 +388,31 @@ const onTimeSelection = async (data: { selector: string, value: string }, userId
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the datetime-local selection event.
|
* Handles the datetime-local selection event.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @param data - the data of the datetime-local selection event
|
* @param data - the data of the datetime-local selection event
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleDateTimeLocalSelection = async (generator: WorkflowGenerator, page: Page, data: { selector: string, value: string }) => {
|
const handleDateTimeLocalSelection = async (activeBrowser: RemoteBrowser, page: Page, data: { selector: string, value: string }) => {
|
||||||
await generator.onDateTimeLocalSelection(page, data);
|
try {
|
||||||
logger.log('debug', `DateTime Local value ${data.value} selected`);
|
if (page.isClosed()) {
|
||||||
|
logger.log(
|
||||||
|
"debug",
|
||||||
|
`Ignoring datetime-local selection event: page is closed`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await generator.onDateTimeLocalSelection(page, data);
|
||||||
|
logger.log("debug", `DateTime Local value ${data.value} selected`);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log(
|
||||||
|
"warn",
|
||||||
|
`Error handling datetime-local selection event: ${message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -353,14 +441,24 @@ const onKeyup = async (keyboardInput: KeyboardInput, userId: string) => {
|
|||||||
* A keyup event handler.
|
* A keyup event handler.
|
||||||
* Reproduces the keyup event on the remote browser instance.
|
* Reproduces the keyup event on the remote browser instance.
|
||||||
* Does not generate any data - keyup is not reflected in the workflow.
|
* Does not generate any data - keyup is not reflected in the workflow.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @param key - the released key
|
* @param key - the released key
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleKeyup = async (generator: WorkflowGenerator, page: Page, key: string) => {
|
const handleKeyup = async (activeBrowser: RemoteBrowser, page: Page, key: string) => {
|
||||||
await page.keyboard.up(key);
|
try {
|
||||||
logger.log('debug', `Key ${key} unpressed`);
|
if (page.isClosed()) {
|
||||||
|
logger.log("debug", `Ignoring keyup event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.keyboard.up(key);
|
||||||
|
logger.log("debug", `Key ${key} unpressed`);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling keyup event: ${message}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -377,23 +475,35 @@ const onChangeUrl = async (url: string, userId: string) => {
|
|||||||
/**
|
/**
|
||||||
* An url change event handler.
|
* An url change event handler.
|
||||||
* Navigates the page to the given url and generates data for the workflow.
|
* Navigates the page to the given url and generates data for the workflow.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @param url - the new url of the page
|
* @param url - the new url of the page
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleChangeUrl = async (generator: WorkflowGenerator, page: Page, url: string) => {
|
const handleChangeUrl = async (activeBrowser: RemoteBrowser, page: Page, url: string) => {
|
||||||
if (url) {
|
try {
|
||||||
await generator.onChangeUrl(url, page);
|
if (page.isClosed()) {
|
||||||
try {
|
logger.log("debug", `Ignoring change url event: page is closed`);
|
||||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 100000 });
|
return;
|
||||||
logger.log('debug', `Went to ${url}`);
|
|
||||||
} catch (e) {
|
|
||||||
const { message } = e as Error;
|
|
||||||
logger.log('error', message);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.log('warn', `No url provided`);
|
if (url) {
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await generator.onChangeUrl(url, page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: "networkidle", timeout: 100000 });
|
||||||
|
logger.log("debug", `Went to ${url}`);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("error", message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.log("warn", `No url provided`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling change url event: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -410,13 +520,23 @@ const onRefresh = async (userId: string) => {
|
|||||||
/**
|
/**
|
||||||
* A refresh event handler.
|
* A refresh event handler.
|
||||||
* Refreshes the page. This is not reflected in the workflow.
|
* Refreshes the page. This is not reflected in the workflow.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleRefresh = async (generator: WorkflowGenerator, page: Page) => {
|
const handleRefresh = async (activeBrowser: RemoteBrowser, page: Page) => {
|
||||||
await page.reload();
|
try {
|
||||||
logger.log('debug', `Page refreshed.`);
|
if (page.isClosed()) {
|
||||||
|
logger.log("debug", `Ignoring refresh event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
logger.log("debug", `Page refreshed.`);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling refresh event: ${message}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -432,14 +552,25 @@ const onGoBack = async (userId: string) => {
|
|||||||
/**
|
/**
|
||||||
* A go back event handler.
|
* A go back event handler.
|
||||||
* Navigates the page back and generates data for the workflow.
|
* Navigates the page back and generates data for the workflow.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleGoBack = async (generator: WorkflowGenerator, page: Page) => {
|
const handleGoBack = async (activeBrowser: RemoteBrowser, page: Page) => {
|
||||||
await page.goBack({ waitUntil: 'commit' });
|
try {
|
||||||
generator.onGoBack(page.url());
|
if (page.isClosed()) {
|
||||||
logger.log('debug', 'Page went back')
|
logger.log("debug", `Ignoring go back event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await page.goBack({ waitUntil: "commit" });
|
||||||
|
generator.onGoBack(page.url());
|
||||||
|
logger.log("debug", "Page went back");
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling go back event: ${message}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -455,14 +586,207 @@ const onGoForward = async (userId: string) => {
|
|||||||
/**
|
/**
|
||||||
* A go forward event handler.
|
* A go forward event handler.
|
||||||
* Navigates the page forward and generates data for the workflow.
|
* Navigates the page forward and generates data for the workflow.
|
||||||
* @param generator - the workflow generator {@link Generator}
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
* @param page - the active page of the remote browser
|
* @param page - the active page of the remote browser
|
||||||
* @category BrowserManagement
|
* @category BrowserManagement
|
||||||
*/
|
*/
|
||||||
const handleGoForward = async (generator: WorkflowGenerator, page: Page) => {
|
const handleGoForward = async (activeBrowser: RemoteBrowser, page: Page) => {
|
||||||
await page.goForward({ waitUntil: 'commit' });
|
try {
|
||||||
generator.onGoForward(page.url());
|
if (page.isClosed()) {
|
||||||
logger.log('debug', 'Page went forward');
|
logger.log("debug", `Ignoring go forward event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await page.goForward({ waitUntil: "commit" });
|
||||||
|
generator.onGoForward(page.url());
|
||||||
|
logger.log("debug", "Page went forward");
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling go forward event: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the click action event.
|
||||||
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
|
* @param page - the active page of the remote browser
|
||||||
|
* @param data - the data of the click action event
|
||||||
|
* @category BrowserManagement
|
||||||
|
*/
|
||||||
|
const handleClickAction = async (
|
||||||
|
activeBrowser: RemoteBrowser,
|
||||||
|
page: Page,
|
||||||
|
data: {
|
||||||
|
selector: string;
|
||||||
|
url: string;
|
||||||
|
userId: string;
|
||||||
|
elementInfo?: any;
|
||||||
|
coordinates?: { x: number; y: number };
|
||||||
|
isSPA?: boolean;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (page.isClosed()) {
|
||||||
|
logger.log("debug", `Ignoring click action event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { selector, url, elementInfo, coordinates, isSPA = false } = data;
|
||||||
|
const currentUrl = page.url();
|
||||||
|
|
||||||
|
await page.click(selector);
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await generator.onDOMClickAction(page, data);
|
||||||
|
|
||||||
|
logger.log("debug", `Click action processed: ${selector}`);
|
||||||
|
|
||||||
|
if (isSPA) {
|
||||||
|
logger.log("debug", `SPA interaction detected for selector: ${selector}`);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
} else {
|
||||||
|
const newUrl = page.url();
|
||||||
|
const hasNavigated = newUrl !== currentUrl && !newUrl.endsWith("/#");
|
||||||
|
|
||||||
|
if (hasNavigated) {
|
||||||
|
logger.log("debug", `Navigation detected: ${currentUrl} -> ${newUrl}`);
|
||||||
|
|
||||||
|
await generator.onDOMNavigation(page, {
|
||||||
|
url: newUrl,
|
||||||
|
currentUrl: currentUrl,
|
||||||
|
userId: data.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
await activeBrowser.makeAndEmitDOMSnapshot();
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log(
|
||||||
|
"warn",
|
||||||
|
`Error handling enhanced click action event: ${message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper function for handling the click action event.
|
||||||
|
* @param socket The socket connection
|
||||||
|
* @param data - the data of the click action event
|
||||||
|
* @category HelperFunctions
|
||||||
|
*/
|
||||||
|
const onDOMClickAction = async (
|
||||||
|
data: {
|
||||||
|
selector: string;
|
||||||
|
url: string;
|
||||||
|
userId: string;
|
||||||
|
elementInfo?: any;
|
||||||
|
coordinates?: { x: number; y: number };
|
||||||
|
},
|
||||||
|
userId: string
|
||||||
|
) => {
|
||||||
|
logger.log("debug", "Handling click action event emitted from client");
|
||||||
|
await handleWrapper(handleClickAction, userId, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the keyboard action event.
|
||||||
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
|
* @param page - the active page of the remote browser
|
||||||
|
* @param data - the data of the keyboard action event
|
||||||
|
* @category BrowserManagement
|
||||||
|
*/
|
||||||
|
const handleKeyboardAction = async (
|
||||||
|
activeBrowser: RemoteBrowser,
|
||||||
|
page: Page,
|
||||||
|
data: {
|
||||||
|
selector: string;
|
||||||
|
key: string;
|
||||||
|
url: string;
|
||||||
|
userId: string;
|
||||||
|
inputType?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (page.isClosed()) {
|
||||||
|
logger.log("debug", `Ignoring keyboard action event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await generator.onDOMKeyboardAction(page, data);
|
||||||
|
logger.log(
|
||||||
|
"debug",
|
||||||
|
`Keyboard action processed: ${data.key} on ${data.selector}`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling keyboard action event: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper function for handling the keyboard action event.
|
||||||
|
* @param socket The socket connection
|
||||||
|
* @param data - the data of the keyboard action event
|
||||||
|
* @category HelperFunctions
|
||||||
|
*/
|
||||||
|
const onDOMKeyboardAction = async (
|
||||||
|
data: {
|
||||||
|
selector: string;
|
||||||
|
key: string;
|
||||||
|
url: string;
|
||||||
|
userId: string;
|
||||||
|
inputType?: string;
|
||||||
|
},
|
||||||
|
userId: string
|
||||||
|
) => {
|
||||||
|
logger.log("debug", "Handling keyboard action event emitted from client");
|
||||||
|
await handleWrapper(handleKeyboardAction, userId, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the workflow pair event.
|
||||||
|
* @param activeBrowser - the active remote browser {@link RemoteBrowser}
|
||||||
|
* @param page - the active page of the remote browser
|
||||||
|
* @param data - the data of the workflow pair event
|
||||||
|
* @category BrowserManagement
|
||||||
|
*/
|
||||||
|
const handleWorkflowPair = async (
|
||||||
|
activeBrowser: RemoteBrowser,
|
||||||
|
page: Page,
|
||||||
|
data: { pair: WhereWhatPair; userId: string }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (page.isClosed()) {
|
||||||
|
logger.log("debug", `Ignoring workflow pair event: page is closed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = activeBrowser.generator;
|
||||||
|
await generator.onDOMWorkflowPair(page, data);
|
||||||
|
logger.log("debug", `Workflow pair processed from frontend`);
|
||||||
|
} catch (e) {
|
||||||
|
const { message } = e as Error;
|
||||||
|
logger.log("warn", `Error handling workflow pair event: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper function for handling the workflow pair event.
|
||||||
|
* @param socket The socket connection
|
||||||
|
* @param data - the data of the workflow pair event
|
||||||
|
* @category HelperFunctions
|
||||||
|
*/
|
||||||
|
const onDOMWorkflowPair = async (
|
||||||
|
data: { pair: WhereWhatPair; userId: string },
|
||||||
|
userId: string
|
||||||
|
) => {
|
||||||
|
logger.log("debug", "Handling workflow pair event emitted from client");
|
||||||
|
await handleWrapper(handleWorkflowPair, userId, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -493,6 +817,10 @@ const registerInputHandlers = (socket: Socket, userId: string) => {
|
|||||||
socket.on("input:time", (data) => onTimeSelection(data, userId));
|
socket.on("input:time", (data) => onTimeSelection(data, userId));
|
||||||
socket.on("input:datetime-local", (data) => onDateTimeLocalSelection(data, userId));
|
socket.on("input:datetime-local", (data) => onDateTimeLocalSelection(data, userId));
|
||||||
socket.on("action", (data) => onGenerateAction(data, userId));
|
socket.on("action", (data) => onGenerateAction(data, userId));
|
||||||
|
|
||||||
|
socket.on("dom:click", (data) => onDOMClickAction(data, userId));
|
||||||
|
socket.on("dom:keypress", (data) => onDOMKeyboardAction(data, userId));
|
||||||
|
socket.on("dom:addpair", (data) => onDOMWorkflowPair(data, userId));
|
||||||
};
|
};
|
||||||
|
|
||||||
export default registerInputHandlers;
|
export default registerInputHandlers;
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export class WorkflowGenerator {
|
|||||||
this.poolId = poolId;
|
this.poolId = poolId;
|
||||||
this.registerEventHandlers(socket);
|
this.registerEventHandlers(socket);
|
||||||
this.initializeSocketListeners();
|
this.initializeSocketListeners();
|
||||||
|
this.initializeDOMListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +93,8 @@ export class WorkflowGenerator {
|
|||||||
workflow: [],
|
workflow: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private isDOMMode: boolean = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata of the currently recorded workflow.
|
* Metadata of the currently recorded workflow.
|
||||||
* @private
|
* @private
|
||||||
@@ -134,6 +137,18 @@ export class WorkflowGenerator {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private initializeDOMListeners() {
|
||||||
|
this.socket.on('dom-mode-enabled', () => {
|
||||||
|
this.isDOMMode = true;
|
||||||
|
logger.log('debug', 'Generator: DOM mode enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('screenshot-mode-enabled', () => {
|
||||||
|
this.isDOMMode = false;
|
||||||
|
logger.log('debug', 'Generator: Screenshot mode enabled');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers the event handlers for all generator-related events on the socket.
|
* Registers the event handlers for all generator-related events on the socket.
|
||||||
* @param socket The socket used to communicate with the client.
|
* @param socket The socket used to communicate with the client.
|
||||||
@@ -348,6 +363,96 @@ export class WorkflowGenerator {
|
|||||||
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handles click events on the DOM, generating a pair for the click action
|
||||||
|
public onDOMClickAction = async (page: Page, data: {
|
||||||
|
selector: string,
|
||||||
|
url: string,
|
||||||
|
userId: string,
|
||||||
|
elementInfo?: any,
|
||||||
|
coordinates?: { x: number, y: number }
|
||||||
|
}) => {
|
||||||
|
const { selector, url, elementInfo, coordinates } = data;
|
||||||
|
|
||||||
|
const pair: WhereWhatPair = {
|
||||||
|
where: {
|
||||||
|
url: this.getBestUrl(url),
|
||||||
|
selectors: [selector]
|
||||||
|
},
|
||||||
|
what: [{
|
||||||
|
action: 'click',
|
||||||
|
args: [selector],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle special input elements with cursor positioning
|
||||||
|
if (elementInfo && coordinates &&
|
||||||
|
(elementInfo.tagName === 'INPUT' || elementInfo.tagName === 'TEXTAREA')) {
|
||||||
|
pair.what[0] = {
|
||||||
|
action: 'click',
|
||||||
|
args: [selector, { position: coordinates }, { cursorIndex: 0 }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.generatedData.lastUsedSelector = selector;
|
||||||
|
this.generatedData.lastAction = 'click';
|
||||||
|
|
||||||
|
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handles keyboard actions on the DOM, generating a pair for the key press action
|
||||||
|
public onDOMKeyboardAction = async (page: Page, data: {
|
||||||
|
selector: string,
|
||||||
|
key: string,
|
||||||
|
url: string,
|
||||||
|
userId: string,
|
||||||
|
inputType?: string
|
||||||
|
}) => {
|
||||||
|
const { selector, key, url, inputType } = data;
|
||||||
|
|
||||||
|
const pair: WhereWhatPair = {
|
||||||
|
where: {
|
||||||
|
url: this.getBestUrl(url),
|
||||||
|
selectors: [selector]
|
||||||
|
},
|
||||||
|
what: [{
|
||||||
|
action: 'press',
|
||||||
|
args: [selector, encrypt(key), inputType || 'text'],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.generatedData.lastUsedSelector = selector;
|
||||||
|
this.generatedData.lastAction = 'press';
|
||||||
|
|
||||||
|
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handles navigation events on the DOM, generating a pair for the navigation action
|
||||||
|
public onDOMNavigation = async (page: Page, data: {
|
||||||
|
url: string,
|
||||||
|
currentUrl: string,
|
||||||
|
userId: string
|
||||||
|
}) => {
|
||||||
|
const { url, currentUrl } = data;
|
||||||
|
|
||||||
|
const pair: WhereWhatPair = {
|
||||||
|
where: { url: this.getBestUrl(currentUrl) },
|
||||||
|
what: [{
|
||||||
|
action: 'goto',
|
||||||
|
args: [url],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.generatedData.lastUsedSelector = '';
|
||||||
|
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handles workflow pair events on the DOM
|
||||||
|
public onDOMWorkflowPair = async (page: Page, data: { pair: WhereWhatPair, userId: string }) => {
|
||||||
|
const { pair } = data;
|
||||||
|
|
||||||
|
await this.addPairToWorkflowAndNotifyClient(pair, page);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a pair for the click event.
|
* Generates a pair for the click event.
|
||||||
* @param coordinates The coordinates of the click event.
|
* @param coordinates The coordinates of the click event.
|
||||||
@@ -357,6 +462,7 @@ export class WorkflowGenerator {
|
|||||||
public onClick = async (coordinates: Coordinates, page: Page) => {
|
public onClick = async (coordinates: Coordinates, page: Page) => {
|
||||||
let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) };
|
let where: WhereWhatPair["where"] = { url: this.getBestUrl(page.url()) };
|
||||||
const selector = await this.generateSelector(page, coordinates, ActionType.Click);
|
const selector = await this.generateSelector(page, coordinates, ActionType.Click);
|
||||||
|
console.log("COOORDINATES: ", coordinates);
|
||||||
logger.log('debug', `Element's selector: ${selector}`);
|
logger.log('debug', `Element's selector: ${selector}`);
|
||||||
|
|
||||||
const elementInfo = await getElementInformation(page, coordinates, '', false);
|
const elementInfo = await getElementInformation(page, coordinates, '', false);
|
||||||
@@ -708,6 +814,7 @@ export class WorkflowGenerator {
|
|||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
this.registerEventHandlers(socket);
|
this.registerEventHandlers(socket);
|
||||||
this.initializeSocketListeners();
|
this.initializeSocketListeners();
|
||||||
|
this.initializeDOMListeners();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { AuthContext } from '../../context/auth';
|
import { AuthContext } from '../../context/auth';
|
||||||
import { coordinateMapper } from '../../helpers/coordinateMapper';
|
import { coordinateMapper } from '../../helpers/coordinateMapper';
|
||||||
import { useBrowserDimensionsStore } from '../../context/browserDimensions';
|
import { useBrowserDimensionsStore } from '../../context/browserDimensions';
|
||||||
|
import { clientSelectorGenerator } from "../../helpers/clientSelectorGenerator";
|
||||||
|
import DatePicker from "../pickers/DatePicker";
|
||||||
|
import Dropdown from "../pickers/Dropdown";
|
||||||
|
import TimePicker from "../pickers/TimePicker";
|
||||||
|
import DateTimeLocalPicker from "../pickers/DateTimeLocalPicker";
|
||||||
|
import { DOMBrowserRenderer } from '../recorder/DOMBrowserRenderer';
|
||||||
|
|
||||||
interface ElementInfo {
|
interface ElementInfo {
|
||||||
tagName: string;
|
tagName: string;
|
||||||
@@ -23,6 +29,7 @@ interface ElementInfo {
|
|||||||
attributes?: Record<string, string>;
|
attributes?: Record<string, string>;
|
||||||
innerHTML?: string;
|
innerHTML?: string;
|
||||||
outerHTML?: string;
|
outerHTML?: string;
|
||||||
|
isDOMMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AttributeOption {
|
interface AttributeOption {
|
||||||
@@ -41,6 +48,73 @@ interface ViewportInfo {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RRWebSnapshot {
|
||||||
|
type: number;
|
||||||
|
childNodes?: RRWebSnapshot[];
|
||||||
|
tagName?: string;
|
||||||
|
attributes?: Record<string, string>;
|
||||||
|
textContent: string;
|
||||||
|
id: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessedSnapshot {
|
||||||
|
snapshot: RRWebSnapshot;
|
||||||
|
resources: {
|
||||||
|
stylesheets: Array<{
|
||||||
|
href: string;
|
||||||
|
content: string;
|
||||||
|
media?: string;
|
||||||
|
}>;
|
||||||
|
images: Array<{
|
||||||
|
src: string;
|
||||||
|
dataUrl: string;
|
||||||
|
alt?: string;
|
||||||
|
}>;
|
||||||
|
fonts: Array<{
|
||||||
|
url: string;
|
||||||
|
dataUrl: string;
|
||||||
|
format?: string;
|
||||||
|
}>;
|
||||||
|
scripts: Array<{
|
||||||
|
src: string;
|
||||||
|
content: string;
|
||||||
|
type?: string;
|
||||||
|
}>;
|
||||||
|
media: Array<{
|
||||||
|
src: string;
|
||||||
|
dataUrl: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
baseUrl: string;
|
||||||
|
viewport: { width: number; height: number };
|
||||||
|
timestamp: number;
|
||||||
|
processingStats: {
|
||||||
|
totalReplacements: number;
|
||||||
|
discoveredResources: {
|
||||||
|
images: number;
|
||||||
|
stylesheets: number;
|
||||||
|
scripts: number;
|
||||||
|
fonts: number;
|
||||||
|
media: number;
|
||||||
|
};
|
||||||
|
cachedResources: {
|
||||||
|
stylesheets: number;
|
||||||
|
images: number;
|
||||||
|
fonts: number;
|
||||||
|
scripts: number;
|
||||||
|
media: number;
|
||||||
|
};
|
||||||
|
totalCacheSize: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RRWebDOMCastData {
|
||||||
|
snapshotData: ProcessedSnapshot;
|
||||||
|
userId: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
const getAttributeOptions = (tagName: string, elementInfo: ElementInfo | null): AttributeOption[] => {
|
const getAttributeOptions = (tagName: string, elementInfo: ElementInfo | null): AttributeOption[] => {
|
||||||
if (!elementInfo) return [];
|
if (!elementInfo) return [];
|
||||||
@@ -79,6 +153,9 @@ export const BrowserWindow = () => {
|
|||||||
const [selectedElement, setSelectedElement] = useState<{ selector: string, info: ElementInfo | null } | null>(null);
|
const [selectedElement, setSelectedElement] = useState<{ selector: string, info: ElementInfo | null } | null>(null);
|
||||||
const [currentListId, setCurrentListId] = useState<number | null>(null);
|
const [currentListId, setCurrentListId] = useState<number | null>(null);
|
||||||
const [viewportInfo, setViewportInfo] = useState<ViewportInfo>({ width: browserWidth, height: browserHeight });
|
const [viewportInfo, setViewportInfo] = useState<ViewportInfo>({ width: browserWidth, height: browserHeight });
|
||||||
|
const [isDOMMode, setIsDOMMode] = useState(false);
|
||||||
|
const [currentSnapshot, setCurrentSnapshot] = useState<ProcessedSnapshot | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const [listSelector, setListSelector] = useState<string | null>(null);
|
const [listSelector, setListSelector] = useState<string | null>(null);
|
||||||
const [fields, setFields] = useState<Record<string, TextStep>>({});
|
const [fields, setFields] = useState<Record<string, TextStep>>({});
|
||||||
@@ -94,11 +171,142 @@ export const BrowserWindow = () => {
|
|||||||
const { state } = useContext(AuthContext);
|
const { state } = useContext(AuthContext);
|
||||||
const { user } = state;
|
const { user } = state;
|
||||||
|
|
||||||
|
const [datePickerInfo, setDatePickerInfo] = useState<{
|
||||||
|
coordinates: { x: number; y: number };
|
||||||
|
selector: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [dropdownInfo, setDropdownInfo] = useState<{
|
||||||
|
coordinates: { x: number; y: number };
|
||||||
|
selector: string;
|
||||||
|
options: Array<{
|
||||||
|
value: string;
|
||||||
|
text: string;
|
||||||
|
disabled: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
}>;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [timePickerInfo, setTimePickerInfo] = useState<{
|
||||||
|
coordinates: { x: number; y: number };
|
||||||
|
selector: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [dateTimeLocalInfo, setDateTimeLocalInfo] = useState<{
|
||||||
|
coordinates: { x: number; y: number };
|
||||||
|
selector: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const dimensions = {
|
const dimensions = {
|
||||||
width: browserWidth,
|
width: browserWidth,
|
||||||
height: browserHeight
|
height: browserHeight
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShowDatePicker = useCallback(
|
||||||
|
(info: { coordinates: { x: number; y: number }; selector: string }) => {
|
||||||
|
setDatePickerInfo(info);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShowDropdown = useCallback(
|
||||||
|
(info: {
|
||||||
|
coordinates: { x: number; y: number };
|
||||||
|
selector: string;
|
||||||
|
options: Array<{
|
||||||
|
value: string;
|
||||||
|
text: string;
|
||||||
|
disabled: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
}>;
|
||||||
|
}) => {
|
||||||
|
setDropdownInfo(info);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShowTimePicker = useCallback(
|
||||||
|
(info: { coordinates: { x: number; y: number }; selector: string }) => {
|
||||||
|
setTimePickerInfo(info);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShowDateTimePicker = useCallback(
|
||||||
|
(info: { coordinates: { x: number; y: number }; selector: string }) => {
|
||||||
|
setDateTimeLocalInfo(info);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rrwebSnapshotHandler = useCallback(
|
||||||
|
(data: RRWebDOMCastData) => {
|
||||||
|
if (!data.userId || data.userId === user?.id) {
|
||||||
|
if (data.snapshotData && data.snapshotData.snapshot) {
|
||||||
|
setCurrentSnapshot(data.snapshotData);
|
||||||
|
setIsDOMMode(true);
|
||||||
|
socket?.emit("dom-mode-enabled");
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[user?.id, socket]
|
||||||
|
);
|
||||||
|
|
||||||
|
const domModeHandler = useCallback(
|
||||||
|
(data: any) => {
|
||||||
|
if (!data.userId || data.userId === user?.id) {
|
||||||
|
setIsDOMMode(true);
|
||||||
|
socket?.emit("dom-mode-enabled");
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[user?.id, socket]
|
||||||
|
);
|
||||||
|
|
||||||
|
const screenshotModeHandler = useCallback(
|
||||||
|
(data: any) => {
|
||||||
|
if (!data.userId || data.userId === user?.id) {
|
||||||
|
setIsDOMMode(false);
|
||||||
|
socket?.emit("screenshot-mode-enabled");
|
||||||
|
setCurrentSnapshot(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[user?.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const domModeErrorHandler = useCallback(
|
||||||
|
(data: any) => {
|
||||||
|
if (!data.userId || data.userId === user?.id) {
|
||||||
|
setIsDOMMode(false);
|
||||||
|
setCurrentSnapshot(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[user?.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDOMMode) {
|
||||||
|
clientSelectorGenerator.setGetList(getList);
|
||||||
|
clientSelectorGenerator.setListSelector(listSelector || "");
|
||||||
|
clientSelectorGenerator.setPaginationMode(paginationMode);
|
||||||
|
}
|
||||||
|
}, [isDOMMode, getList, listSelector, paginationMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDOMMode && listSelector) {
|
||||||
|
socket?.emit("setGetList", { getList: true });
|
||||||
|
socket?.emit("listSelector", { selector: listSelector });
|
||||||
|
|
||||||
|
clientSelectorGenerator.setListSelector(listSelector);
|
||||||
|
}
|
||||||
|
}, [isDOMMode, listSelector, socket, getList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
coordinateMapper.updateDimensions(dimensions.width, dimensions.height, viewportInfo.width, viewportInfo.height);
|
coordinateMapper.updateDimensions(dimensions.width, dimensions.height, viewportInfo.width, viewportInfo.height);
|
||||||
}, [viewportInfo, dimensions.width, dimensions.height]);
|
}, [viewportInfo, dimensions.width, dimensions.height]);
|
||||||
@@ -162,16 +370,185 @@ export const BrowserWindow = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.on("screencast", screencastHandler);
|
socket.on("screencast", screencastHandler);
|
||||||
|
socket.on("domcast", rrwebSnapshotHandler);
|
||||||
|
socket.on("dom-mode-enabled", domModeHandler);
|
||||||
|
socket.on("screenshot-mode-enabled", screenshotModeHandler);
|
||||||
|
socket.on("dom-mode-error", domModeErrorHandler);
|
||||||
}
|
}
|
||||||
if (canvasRef?.current) {
|
|
||||||
|
if (canvasRef?.current && !isDOMMode && screenShot) {
|
||||||
drawImage(screenShot, canvasRef.current);
|
drawImage(screenShot, canvasRef.current);
|
||||||
} else {
|
|
||||||
console.log('Canvas is not initialized');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket?.off("screencast", screencastHandler);
|
if (socket) {
|
||||||
}
|
console.log("Cleaning up DOM streaming event listeners");
|
||||||
}, [screenShot, canvasRef, socket, screencastHandler]);
|
socket.off("screencast", screencastHandler);
|
||||||
|
socket.off("domcast", rrwebSnapshotHandler);
|
||||||
|
socket.off("dom-mode-enabled", domModeHandler);
|
||||||
|
socket.off("screenshot-mode-enabled", screenshotModeHandler);
|
||||||
|
socket.off("dom-mode-error", domModeErrorHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
socket,
|
||||||
|
screenShot,
|
||||||
|
canvasRef,
|
||||||
|
isDOMMode,
|
||||||
|
screencastHandler,
|
||||||
|
rrwebSnapshotHandler,
|
||||||
|
domModeHandler,
|
||||||
|
screenshotModeHandler,
|
||||||
|
domModeErrorHandler,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const domHighlighterHandler = useCallback(
|
||||||
|
(data: {
|
||||||
|
rect: DOMRect;
|
||||||
|
selector: string;
|
||||||
|
elementInfo: ElementInfo | null;
|
||||||
|
childSelectors?: string[];
|
||||||
|
isDOMMode?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!isDOMMode || !currentSnapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let iframeElement = document.querySelector(
|
||||||
|
"#dom-browser-iframe"
|
||||||
|
) as HTMLIFrameElement;
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
iframeElement = document.querySelector(
|
||||||
|
"#browser-window iframe"
|
||||||
|
) as HTMLIFrameElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
const browserWindow = document.querySelector("#browser-window");
|
||||||
|
if (browserWindow) {
|
||||||
|
iframeElement = browserWindow.querySelector(
|
||||||
|
"iframe"
|
||||||
|
) as HTMLIFrameElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
console.error("Could not find iframe element for DOM highlighting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iframeRect = iframeElement.getBoundingClientRect();
|
||||||
|
const IFRAME_BODY_PADDING = 16;
|
||||||
|
|
||||||
|
const absoluteRect = new DOMRect(
|
||||||
|
data.rect.x + iframeRect.left - IFRAME_BODY_PADDING,
|
||||||
|
data.rect.y + iframeRect.top - IFRAME_BODY_PADDING,
|
||||||
|
data.rect.width,
|
||||||
|
data.rect.height
|
||||||
|
);
|
||||||
|
|
||||||
|
const mappedData = {
|
||||||
|
...data,
|
||||||
|
rect: absoluteRect,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (getList === true) {
|
||||||
|
if (listSelector) {
|
||||||
|
socket?.emit("listSelector", { selector: listSelector });
|
||||||
|
const hasValidChildSelectors =
|
||||||
|
Array.isArray(mappedData.childSelectors) &&
|
||||||
|
mappedData.childSelectors.length > 0;
|
||||||
|
|
||||||
|
if (limitMode) {
|
||||||
|
setHighlighterData(null);
|
||||||
|
} else if (paginationMode) {
|
||||||
|
if (
|
||||||
|
paginationType !== "" &&
|
||||||
|
!["none", "scrollDown", "scrollUp"].includes(paginationType)
|
||||||
|
) {
|
||||||
|
setHighlighterData(mappedData);
|
||||||
|
} else {
|
||||||
|
setHighlighterData(null);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
mappedData.childSelectors &&
|
||||||
|
mappedData.childSelectors.includes(mappedData.selector)
|
||||||
|
) {
|
||||||
|
setHighlighterData(mappedData);
|
||||||
|
} else if (
|
||||||
|
mappedData.elementInfo?.isIframeContent &&
|
||||||
|
mappedData.childSelectors
|
||||||
|
) {
|
||||||
|
const isIframeChild = mappedData.childSelectors.some(
|
||||||
|
(childSelector) =>
|
||||||
|
mappedData.selector.includes(":>>") &&
|
||||||
|
childSelector
|
||||||
|
.split(":>>")
|
||||||
|
.some((part) => mappedData.selector.includes(part.trim()))
|
||||||
|
);
|
||||||
|
setHighlighterData(isIframeChild ? mappedData : null);
|
||||||
|
} else if (
|
||||||
|
mappedData.selector.includes(":>>") &&
|
||||||
|
hasValidChildSelectors
|
||||||
|
) {
|
||||||
|
const selectorParts = mappedData.selector
|
||||||
|
.split(":>>")
|
||||||
|
.map((part) => part.trim());
|
||||||
|
const isValidMixedSelector = selectorParts.some((part) =>
|
||||||
|
mappedData.childSelectors!.some((childSelector) =>
|
||||||
|
childSelector.includes(part)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setHighlighterData(isValidMixedSelector ? mappedData : null);
|
||||||
|
} else if (
|
||||||
|
mappedData.elementInfo?.isShadowRoot &&
|
||||||
|
mappedData.childSelectors
|
||||||
|
) {
|
||||||
|
const isShadowChild = mappedData.childSelectors.some(
|
||||||
|
(childSelector) =>
|
||||||
|
mappedData.selector.includes(">>") &&
|
||||||
|
childSelector
|
||||||
|
.split(">>")
|
||||||
|
.some((part) => mappedData.selector.includes(part.trim()))
|
||||||
|
);
|
||||||
|
setHighlighterData(isShadowChild ? mappedData : null);
|
||||||
|
} else if (
|
||||||
|
mappedData.selector.includes(">>") &&
|
||||||
|
hasValidChildSelectors
|
||||||
|
) {
|
||||||
|
const selectorParts = mappedData.selector
|
||||||
|
.split(">>")
|
||||||
|
.map((part) => part.trim());
|
||||||
|
const isValidMixedSelector = selectorParts.some((part) =>
|
||||||
|
mappedData.childSelectors!.some((childSelector) =>
|
||||||
|
childSelector.includes(part)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setHighlighterData(isValidMixedSelector ? mappedData : null);
|
||||||
|
} else {
|
||||||
|
setHighlighterData(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setHighlighterData(mappedData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// getText mode
|
||||||
|
setHighlighterData(mappedData);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isDOMMode,
|
||||||
|
currentSnapshot,
|
||||||
|
getList,
|
||||||
|
socket,
|
||||||
|
listSelector,
|
||||||
|
paginationMode,
|
||||||
|
paginationType,
|
||||||
|
limitMode,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] }) => {
|
const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] }) => {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
@@ -260,20 +637,6 @@ export const BrowserWindow = () => {
|
|||||||
}
|
}
|
||||||
}, [getList, socket, listSelector, paginationMode, paginationType, limitMode]);
|
}, [getList, socket, listSelector, paginationMode, paginationType, limitMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (socket) {
|
|
||||||
socket.on('listDataExtracted', (response) => {
|
|
||||||
const { currentListId, data } = response;
|
|
||||||
|
|
||||||
updateListStepData(currentListId, data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket?.off('listDataExtracted');
|
|
||||||
};
|
|
||||||
}, [socket]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('mousemove', onMouseMove, false);
|
document.addEventListener('mousemove', onMouseMove, false);
|
||||||
if (socket) {
|
if (socket) {
|
||||||
@@ -304,6 +667,188 @@ export const BrowserWindow = () => {
|
|||||||
}
|
}
|
||||||
}, [captureStage, listSelector, socket]);
|
}, [captureStage, listSelector, socket]);
|
||||||
|
|
||||||
|
const handleDOMElementSelection = useCallback(
|
||||||
|
(highlighterData: {
|
||||||
|
rect: DOMRect;
|
||||||
|
selector: string;
|
||||||
|
elementInfo: ElementInfo | null;
|
||||||
|
childSelectors?: string[];
|
||||||
|
}) => {
|
||||||
|
setShowAttributeModal(false);
|
||||||
|
setSelectedElement(null);
|
||||||
|
setAttributeOptions([]);
|
||||||
|
|
||||||
|
const options = getAttributeOptions(
|
||||||
|
highlighterData.elementInfo?.tagName || "",
|
||||||
|
highlighterData.elementInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
if (getText === true) {
|
||||||
|
if (options.length === 1) {
|
||||||
|
const attribute = options[0].value;
|
||||||
|
const data =
|
||||||
|
attribute === "href"
|
||||||
|
? highlighterData.elementInfo?.url || ""
|
||||||
|
: attribute === "src"
|
||||||
|
? highlighterData.elementInfo?.imageUrl || ""
|
||||||
|
: highlighterData.elementInfo?.innerText || "";
|
||||||
|
|
||||||
|
addTextStep(
|
||||||
|
"",
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
selector: highlighterData.selector,
|
||||||
|
tag: highlighterData.elementInfo?.tagName,
|
||||||
|
shadow: highlighterData.elementInfo?.isShadowRoot,
|
||||||
|
attribute,
|
||||||
|
},
|
||||||
|
currentTextActionId || `text-${crypto.randomUUID()}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setAttributeOptions(options);
|
||||||
|
setSelectedElement({
|
||||||
|
selector: highlighterData.selector,
|
||||||
|
info: highlighterData.elementInfo,
|
||||||
|
});
|
||||||
|
setShowAttributeModal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paginationMode && getList) {
|
||||||
|
if (
|
||||||
|
paginationType !== "" &&
|
||||||
|
paginationType !== "scrollDown" &&
|
||||||
|
paginationType !== "scrollUp" &&
|
||||||
|
paginationType !== "none"
|
||||||
|
) {
|
||||||
|
setPaginationSelector(highlighterData.selector);
|
||||||
|
notify(
|
||||||
|
`info`,
|
||||||
|
t(
|
||||||
|
"browser_window.attribute_modal.notifications.pagination_select_success"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
addListStep(
|
||||||
|
listSelector!,
|
||||||
|
fields,
|
||||||
|
currentListId || 0,
|
||||||
|
currentListActionId || `list-${crypto.randomUUID()}`,
|
||||||
|
{ type: paginationType, selector: highlighterData.selector }
|
||||||
|
);
|
||||||
|
socket?.emit("setPaginationMode", { pagination: false });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getList === true && !listSelector) {
|
||||||
|
let cleanedSelector = highlighterData.selector;
|
||||||
|
if (cleanedSelector.includes("nth-child")) {
|
||||||
|
cleanedSelector = cleanedSelector.replace(/:nth-child\(\d+\)/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
setListSelector(cleanedSelector);
|
||||||
|
notify(
|
||||||
|
`info`,
|
||||||
|
t("browser_window.attribute_modal.notifications.list_select_success")
|
||||||
|
);
|
||||||
|
setCurrentListId(Date.now());
|
||||||
|
setFields({});
|
||||||
|
|
||||||
|
socket?.emit("setGetList", { getList: true });
|
||||||
|
socket?.emit("listSelector", { selector: cleanedSelector });
|
||||||
|
} else if (getList === true && listSelector && currentListId) {
|
||||||
|
if (options.length === 1) {
|
||||||
|
const attribute = options[0].value;
|
||||||
|
let currentSelector = highlighterData.selector;
|
||||||
|
|
||||||
|
if (currentSelector.includes(">")) {
|
||||||
|
const [firstPart, ...restParts] = currentSelector
|
||||||
|
.split(">")
|
||||||
|
.map((p) => p.trim());
|
||||||
|
const listSelectorRightPart = listSelector
|
||||||
|
.split(">")
|
||||||
|
.pop()
|
||||||
|
?.trim()
|
||||||
|
.replace(/:nth-child\(\d+\)/g, "");
|
||||||
|
|
||||||
|
if (
|
||||||
|
firstPart.includes("nth-child") &&
|
||||||
|
firstPart.replace(/:nth-child\(\d+\)/g, "") ===
|
||||||
|
listSelectorRightPart
|
||||||
|
) {
|
||||||
|
currentSelector = `${firstPart.replace(
|
||||||
|
/:nth-child\(\d+\)/g,
|
||||||
|
""
|
||||||
|
)} > ${restParts.join(" > ")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data =
|
||||||
|
attribute === "href"
|
||||||
|
? highlighterData.elementInfo?.url || ""
|
||||||
|
: attribute === "src"
|
||||||
|
? highlighterData.elementInfo?.imageUrl || ""
|
||||||
|
: highlighterData.elementInfo?.innerText || "";
|
||||||
|
|
||||||
|
const newField: TextStep = {
|
||||||
|
id: Date.now(),
|
||||||
|
type: "text",
|
||||||
|
label: `Label ${Object.keys(fields).length + 1}`,
|
||||||
|
data: data,
|
||||||
|
selectorObj: {
|
||||||
|
selector: currentSelector,
|
||||||
|
tag: highlighterData.elementInfo?.tagName,
|
||||||
|
shadow: highlighterData.elementInfo?.isShadowRoot,
|
||||||
|
attribute,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedFields = {
|
||||||
|
...fields,
|
||||||
|
[newField.id]: newField,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFields(updatedFields);
|
||||||
|
|
||||||
|
if (listSelector) {
|
||||||
|
addListStep(
|
||||||
|
listSelector,
|
||||||
|
updatedFields,
|
||||||
|
currentListId,
|
||||||
|
currentListActionId || `list-${crypto.randomUUID()}`,
|
||||||
|
{ type: "", selector: paginationSelector }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAttributeOptions(options);
|
||||||
|
setSelectedElement({
|
||||||
|
selector: highlighterData.selector,
|
||||||
|
info: highlighterData.elementInfo,
|
||||||
|
});
|
||||||
|
setShowAttributeModal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
getText,
|
||||||
|
getList,
|
||||||
|
listSelector,
|
||||||
|
paginationMode,
|
||||||
|
paginationType,
|
||||||
|
fields,
|
||||||
|
currentListId,
|
||||||
|
currentTextActionId,
|
||||||
|
currentListActionId,
|
||||||
|
addTextStep,
|
||||||
|
addListStep,
|
||||||
|
notify,
|
||||||
|
socket,
|
||||||
|
t,
|
||||||
|
paginationSelector,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (highlighterData && canvasRef?.current) {
|
if (highlighterData && canvasRef?.current) {
|
||||||
const canvasRect = canvasRef.current.getBoundingClientRect();
|
const canvasRect = canvasRef.current.getBoundingClientRect();
|
||||||
@@ -409,13 +954,6 @@ export const BrowserWindow = () => {
|
|||||||
setFields(updatedFields);
|
setFields(updatedFields);
|
||||||
|
|
||||||
if (listSelector) {
|
if (listSelector) {
|
||||||
socket?.emit('extractListData', {
|
|
||||||
listSelector,
|
|
||||||
fields: updatedFields,
|
|
||||||
currentListId,
|
|
||||||
pagination: { type: '', selector: paginationSelector }
|
|
||||||
});
|
|
||||||
|
|
||||||
addListStep(
|
addListStep(
|
||||||
listSelector,
|
listSelector,
|
||||||
updatedFields,
|
updatedFields,
|
||||||
@@ -482,13 +1020,6 @@ export const BrowserWindow = () => {
|
|||||||
setFields(updatedFields);
|
setFields(updatedFields);
|
||||||
|
|
||||||
if (listSelector) {
|
if (listSelector) {
|
||||||
socket?.emit('extractListData', {
|
|
||||||
listSelector,
|
|
||||||
fields: updatedFields,
|
|
||||||
currentListId,
|
|
||||||
pagination: { type: '', selector: paginationSelector }
|
|
||||||
});
|
|
||||||
|
|
||||||
addListStep(
|
addListStep(
|
||||||
listSelector,
|
listSelector,
|
||||||
updatedFields,
|
updatedFields,
|
||||||
@@ -500,7 +1031,14 @@ export const BrowserWindow = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowAttributeModal(false);
|
setShowAttributeModal(false);
|
||||||
|
setSelectedElement(null);
|
||||||
|
setAttributeOptions([]);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowAttributeModal(false);
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetPaginationSelector = useCallback(() => {
|
const resetPaginationSelector = useCallback(() => {
|
||||||
@@ -519,8 +1057,12 @@ export const BrowserWindow = () => {
|
|||||||
getText === true || getList === true ? (
|
getText === true || getList === true ? (
|
||||||
<GenericModal
|
<GenericModal
|
||||||
isOpen={showAttributeModal}
|
isOpen={showAttributeModal}
|
||||||
onClose={() => { }}
|
onClose={() => {
|
||||||
canBeClosed={false}
|
setShowAttributeModal(false);
|
||||||
|
setSelectedElement(null);
|
||||||
|
setAttributeOptions([]);
|
||||||
|
}}
|
||||||
|
canBeClosed={true}
|
||||||
modalStyle={modalStyle}
|
modalStyle={modalStyle}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@@ -560,21 +1102,150 @@ export const BrowserWindow = () => {
|
|||||||
</GenericModal>
|
</GenericModal>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
<div style={{ height: dimensions.height, overflow: 'hidden' }}>
|
|
||||||
{((getText === true || getList === true) && !showAttributeModal && highlighterData?.rect != null && highlighterData?.rect.top != null) && canvasRef?.current ?
|
{datePickerInfo && (
|
||||||
<Highlighter
|
<DatePicker
|
||||||
unmodifiedRect={highlighterData?.rect}
|
coordinates={datePickerInfo.coordinates}
|
||||||
displayedSelector={highlighterData?.selector}
|
selector={datePickerInfo.selector}
|
||||||
|
onClose={() => setDatePickerInfo(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dropdownInfo && (
|
||||||
|
<Dropdown
|
||||||
|
coordinates={dropdownInfo.coordinates}
|
||||||
|
selector={dropdownInfo.selector}
|
||||||
|
options={dropdownInfo.options}
|
||||||
|
onClose={() => setDropdownInfo(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{timePickerInfo && (
|
||||||
|
<TimePicker
|
||||||
|
coordinates={timePickerInfo.coordinates}
|
||||||
|
selector={timePickerInfo.selector}
|
||||||
|
onClose={() => setTimePickerInfo(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dateTimeLocalInfo && (
|
||||||
|
<DateTimeLocalPicker
|
||||||
|
coordinates={dateTimeLocalInfo.coordinates}
|
||||||
|
selector={dateTimeLocalInfo.selector}
|
||||||
|
onClose={() => setDateTimeLocalInfo(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ height: dimensions.height, overflow: "hidden" }}>
|
||||||
|
{(getText === true || getList === true) &&
|
||||||
|
!showAttributeModal &&
|
||||||
|
highlighterData?.rect != null && (
|
||||||
|
<>
|
||||||
|
{!isDOMMode && canvasRef?.current && (
|
||||||
|
<Highlighter
|
||||||
|
unmodifiedRect={highlighterData?.rect}
|
||||||
|
displayedSelector={highlighterData?.selector}
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
canvasRect={canvasRef.current.getBoundingClientRect()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDOMMode && highlighterData && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: Math.max(0, highlighterData.rect.x),
|
||||||
|
top: Math.max(0, highlighterData.rect.y),
|
||||||
|
width: Math.min(
|
||||||
|
highlighterData.rect.width,
|
||||||
|
dimensions.width
|
||||||
|
),
|
||||||
|
height: Math.min(
|
||||||
|
highlighterData.rect.height,
|
||||||
|
dimensions.height
|
||||||
|
),
|
||||||
|
background: "rgba(255, 0, 195, 0.15)",
|
||||||
|
border: "2px solid #ff00c3",
|
||||||
|
borderRadius: "3px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 1000,
|
||||||
|
boxShadow: "0 0 0 1px rgba(255, 255, 255, 0.8)",
|
||||||
|
transition: "all 0.1s ease-out",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDOMMode ? (
|
||||||
|
currentSnapshot ? (
|
||||||
|
<DOMBrowserRenderer
|
||||||
|
width={dimensions.width}
|
||||||
|
height={dimensions.height}
|
||||||
|
snapshot={currentSnapshot}
|
||||||
|
getList={getList}
|
||||||
|
getText={getText}
|
||||||
|
listSelector={listSelector}
|
||||||
|
paginationMode={paginationMode}
|
||||||
|
paginationType={paginationType}
|
||||||
|
limitMode={limitMode}
|
||||||
|
onHighlight={(data: any) => {
|
||||||
|
domHighlighterHandler(data);
|
||||||
|
}}
|
||||||
|
onElementSelect={handleDOMElementSelection}
|
||||||
|
onShowDatePicker={handleShowDatePicker}
|
||||||
|
onShowDropdown={handleShowDropdown}
|
||||||
|
onShowTimePicker={handleShowTimePicker}
|
||||||
|
onShowDateTimePicker={handleShowDateTimePicker}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#f5f5f5",
|
||||||
|
borderRadius: "5px",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "60px",
|
||||||
|
height: "60px",
|
||||||
|
borderTop: "4px solid transparent",
|
||||||
|
borderRadius: "50%",
|
||||||
|
animation: "spin 1s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "18px",
|
||||||
|
color: "#ff00c3",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading website...
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
/* Screenshot mode canvas */
|
||||||
|
<Canvas
|
||||||
|
onCreateRef={setCanvasReference}
|
||||||
width={dimensions.width}
|
width={dimensions.width}
|
||||||
height={dimensions.height}
|
height={dimensions.height}
|
||||||
canvasRect={canvasRef.current.getBoundingClientRect()}
|
|
||||||
/>
|
/>
|
||||||
: null}
|
)}
|
||||||
<Canvas
|
|
||||||
onCreateRef={setCanvasReference}
|
|
||||||
width={dimensions.width}
|
|
||||||
height={dimensions.height}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,12 +16,58 @@ const DatePicker: React.FC<DatePickerProps> = ({ coordinates, selector, onClose
|
|||||||
setSelectedDate(e.target.value);
|
setSelectedDate(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateDOMElement = (selector: string, value: string) => {
|
||||||
|
try {
|
||||||
|
let iframeElement = document.querySelector('#dom-browser-iframe') as HTMLIFrameElement;
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
iframeElement = document.querySelector('#browser-window iframe') as HTMLIFrameElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
const browserWindow = document.querySelector('#browser-window');
|
||||||
|
if (browserWindow) {
|
||||||
|
iframeElement = browserWindow.querySelector('iframe') as HTMLIFrameElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
console.error('Could not find iframe element for DOM update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iframeDoc = iframeElement.contentDocument;
|
||||||
|
if (!iframeDoc) {
|
||||||
|
console.error('Could not access iframe document');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = iframeDoc.querySelector(selector) as HTMLInputElement;
|
||||||
|
if (element) {
|
||||||
|
element.value = value;
|
||||||
|
|
||||||
|
const changeEvent = new Event('change', { bubbles: true });
|
||||||
|
element.dispatchEvent(changeEvent);
|
||||||
|
|
||||||
|
const inputEvent = new Event('input', { bubbles: true });
|
||||||
|
element.dispatchEvent(inputEvent);
|
||||||
|
} else {
|
||||||
|
console.warn(`Could not find element with selector: ${selector}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating DOM element:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (socket && selectedDate) {
|
if (socket && selectedDate) {
|
||||||
socket.emit('input:date', {
|
socket.emit('input:date', {
|
||||||
selector,
|
selector,
|
||||||
value: selectedDate
|
value: selectedDate
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateDOMElement(selector, selectedDate);
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,12 +16,58 @@ const DateTimeLocalPicker: React.FC<DateTimeLocalPickerProps> = ({ coordinates,
|
|||||||
setSelectedDateTime(e.target.value);
|
setSelectedDateTime(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateDOMElement = (selector: string, value: string) => {
|
||||||
|
try {
|
||||||
|
let iframeElement = document.querySelector('#dom-browser-iframe') as HTMLIFrameElement;
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
iframeElement = document.querySelector('#browser-window iframe') as HTMLIFrameElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
const browserWindow = document.querySelector('#browser-window');
|
||||||
|
if (browserWindow) {
|
||||||
|
iframeElement = browserWindow.querySelector('iframe') as HTMLIFrameElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
console.error('Could not find iframe element for DOM update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iframeDoc = iframeElement.contentDocument;
|
||||||
|
if (!iframeDoc) {
|
||||||
|
console.error('Could not access iframe document');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = iframeDoc.querySelector(selector) as HTMLInputElement;
|
||||||
|
if (element) {
|
||||||
|
element.value = value;
|
||||||
|
|
||||||
|
const changeEvent = new Event('change', { bubbles: true });
|
||||||
|
element.dispatchEvent(changeEvent);
|
||||||
|
|
||||||
|
const inputEvent = new Event('input', { bubbles: true });
|
||||||
|
element.dispatchEvent(inputEvent);
|
||||||
|
} else {
|
||||||
|
console.warn(`Could not find element with selector: ${selector}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating DOM element:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (socket && selectedDateTime) {
|
if (socket && selectedDateTime) {
|
||||||
socket.emit('input:datetime-local', {
|
socket.emit('input:datetime-local', {
|
||||||
selector,
|
selector,
|
||||||
value: selectedDateTime
|
value: selectedDateTime
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateDOMElement(selector, selectedDateTime);
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -58,8 +104,8 @@ const DateTimeLocalPicker: React.FC<DateTimeLocalPickerProps> = ({ coordinates,
|
|||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={!selectedDateTime}
|
disabled={!selectedDateTime}
|
||||||
className={`px-3 py-1 text-sm rounded ${selectedDateTime
|
className={`px-3 py-1 text-sm rounded ${selectedDateTime
|
||||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
? 'bg-blue-500 text-white hover:bg-blue-600'
|
||||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
|
|||||||
@@ -18,9 +18,65 @@ const Dropdown = ({ coordinates, selector, options, onClose }: DropdownProps) =>
|
|||||||
const { socket } = useSocketStore();
|
const { socket } = useSocketStore();
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const updateDOMElement = (selector: string, value: string) => {
|
||||||
|
try {
|
||||||
|
let iframeElement = document.querySelector('#dom-browser-iframe') as HTMLIFrameElement;
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
iframeElement = document.querySelector('#browser-window iframe') as HTMLIFrameElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
const browserWindow = document.querySelector('#browser-window');
|
||||||
|
if (browserWindow) {
|
||||||
|
iframeElement = browserWindow.querySelector('iframe') as HTMLIFrameElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
console.error('Could not find iframe element for DOM update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iframeDoc = iframeElement.contentDocument;
|
||||||
|
if (!iframeDoc) {
|
||||||
|
console.error('Could not access iframe document');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectElement = iframeDoc.querySelector(selector) as HTMLSelectElement;
|
||||||
|
if (selectElement) {
|
||||||
|
selectElement.value = value;
|
||||||
|
|
||||||
|
const optionElements = selectElement.querySelectorAll('option');
|
||||||
|
optionElements.forEach(option => {
|
||||||
|
if (option.value === value) {
|
||||||
|
option.selected = true;
|
||||||
|
option.setAttribute('selected', 'selected');
|
||||||
|
} else {
|
||||||
|
option.selected = false;
|
||||||
|
option.removeAttribute('selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeEvent = new Event('change', { bubbles: true });
|
||||||
|
selectElement.dispatchEvent(changeEvent);
|
||||||
|
|
||||||
|
const inputEvent = new Event('input', { bubbles: true });
|
||||||
|
selectElement.dispatchEvent(inputEvent);
|
||||||
|
} else {
|
||||||
|
console.warn(`Could not find select element with selector: ${selector}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating DOM select element:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelect = (value: string) => {
|
const handleSelect = (value: string) => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.emit('input:dropdown', { selector, value });
|
socket.emit('input:dropdown', { selector, value });
|
||||||
|
|
||||||
|
updateDOMElement(selector, value);
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|||||||
1139
src/components/recorder/DOMBrowserRenderer.tsx
Normal file
1139
src/components/recorder/DOMBrowserRenderer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import ActionDescriptionBox from '../action/ActionDescriptionBox';
|
|||||||
import { useThemeMode } from '../../context/theme-provider';
|
import { useThemeMode } from '../../context/theme-provider';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useBrowserDimensionsStore } from '../../context/browserDimensions';
|
import { useBrowserDimensionsStore } from '../../context/browserDimensions';
|
||||||
|
import { clientListExtractor } from '../../helpers/clientListExtractor';
|
||||||
|
|
||||||
const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => {
|
const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => {
|
||||||
getActiveWorkflow(id).then(
|
getActiveWorkflow(id).then(
|
||||||
@@ -51,6 +52,8 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
const [isCaptureTextConfirmed, setIsCaptureTextConfirmed] = useState(false);
|
const [isCaptureTextConfirmed, setIsCaptureTextConfirmed] = useState(false);
|
||||||
const [isCaptureListConfirmed, setIsCaptureListConfirmed] = useState(false);
|
const [isCaptureListConfirmed, setIsCaptureListConfirmed] = useState(false);
|
||||||
const { panelHeight } = useBrowserDimensionsStore();
|
const { panelHeight } = useBrowserDimensionsStore();
|
||||||
|
const [isDOMMode, setIsDOMMode] = useState(false);
|
||||||
|
const [currentSnapshot, setCurrentSnapshot] = useState<any>(null);
|
||||||
|
|
||||||
const { lastAction, notify, currentWorkflowActionsState, setCurrentWorkflowActionsState, resetInterpretationLog, currentListActionId, setCurrentListActionId, currentTextActionId, setCurrentTextActionId, currentScreenshotActionId, setCurrentScreenshotActionId } = useGlobalInfoStore();
|
const { lastAction, notify, currentWorkflowActionsState, setCurrentWorkflowActionsState, resetInterpretationLog, currentListActionId, setCurrentListActionId, currentTextActionId, setCurrentTextActionId, currentScreenshotActionId, setCurrentScreenshotActionId } = useGlobalInfoStore();
|
||||||
const {
|
const {
|
||||||
@@ -69,7 +72,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
startAction, finishAction
|
startAction, finishAction
|
||||||
} = useActionContext();
|
} = useActionContext();
|
||||||
|
|
||||||
const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField, updateListStepLimit, deleteStepsByActionId } = useBrowserSteps();
|
const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep, updateListTextFieldLabel, removeListTextField, updateListStepLimit, deleteStepsByActionId, updateListStepData } = useBrowserSteps();
|
||||||
const { id, socket } = useSocketStore();
|
const { id, socket } = useSocketStore();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -79,6 +82,42 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
setWorkflow(data);
|
setWorkflow(data);
|
||||||
}, [setWorkflow]);
|
}, [setWorkflow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (socket) {
|
||||||
|
const domModeHandler = (data: any) => {
|
||||||
|
if (!data.userId || data.userId === id) {
|
||||||
|
setIsDOMMode(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const screenshotModeHandler = (data: any) => {
|
||||||
|
if (!data.userId || data.userId === id) {
|
||||||
|
setIsDOMMode(false);
|
||||||
|
setCurrentSnapshot(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const domcastHandler = (data: any) => {
|
||||||
|
if (!data.userId || data.userId === id) {
|
||||||
|
if (data.snapshotData && data.snapshotData.snapshot) {
|
||||||
|
setCurrentSnapshot(data.snapshotData);
|
||||||
|
setIsDOMMode(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on("dom-mode-enabled", domModeHandler);
|
||||||
|
socket.on("screenshot-mode-enabled", screenshotModeHandler);
|
||||||
|
socket.on("domcast", domcastHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off("dom-mode-enabled", domModeHandler);
|
||||||
|
socket.off("screenshot-mode-enabled", screenshotModeHandler);
|
||||||
|
socket.off("domcast", domcastHandler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [socket, id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.on("workflow", workflowHandler);
|
socket.on("workflow", workflowHandler);
|
||||||
@@ -129,6 +168,100 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
setShowCaptureText(true);
|
setShowCaptureText(true);
|
||||||
}, [workflow, setCurrentWorkflowActionsState]);
|
}, [workflow, setCurrentWorkflowActionsState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.on('listDataExtracted', (response) => {
|
||||||
|
if (!isDOMMode) {
|
||||||
|
const { currentListId, data } = response;
|
||||||
|
updateListStepData(currentListId, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket?.off('listDataExtracted');
|
||||||
|
};
|
||||||
|
}, [socket, updateListStepData, isDOMMode]);
|
||||||
|
|
||||||
|
const extractDataClientSide = useCallback(
|
||||||
|
(
|
||||||
|
listSelector: string,
|
||||||
|
fields: Record<string, any>,
|
||||||
|
currentListId: number
|
||||||
|
) => {
|
||||||
|
if (isDOMMode && currentSnapshot) {
|
||||||
|
try {
|
||||||
|
// Find the DOM iframe element
|
||||||
|
let iframeElement = document.querySelector(
|
||||||
|
"#dom-browser-iframe"
|
||||||
|
) as HTMLIFrameElement;
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
iframeElement = document.querySelector(
|
||||||
|
"#browser-window iframe"
|
||||||
|
) as HTMLIFrameElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
const browserWindow = document.querySelector("#browser-window");
|
||||||
|
if (browserWindow) {
|
||||||
|
iframeElement = browserWindow.querySelector(
|
||||||
|
"iframe"
|
||||||
|
) as HTMLIFrameElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iframeElement) {
|
||||||
|
console.error(
|
||||||
|
"Could not find the DOM iframe element for extraction"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iframeDoc = iframeElement.contentDocument;
|
||||||
|
if (!iframeDoc) {
|
||||||
|
console.error("Failed to get iframe document");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use client-side extraction
|
||||||
|
const extractedData = clientListExtractor.extractListData(
|
||||||
|
iframeDoc,
|
||||||
|
listSelector,
|
||||||
|
fields,
|
||||||
|
5 // limit for preview
|
||||||
|
);
|
||||||
|
|
||||||
|
updateListStepData(currentListId, extractedData);
|
||||||
|
console.log("✅ Client-side extraction completed:", extractedData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in client-side data extraction:", error);
|
||||||
|
notify("error", "Failed to extract data client-side");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to socket-based extraction for screenshot mode
|
||||||
|
if (!socket) {
|
||||||
|
console.error("Socket not available for backend extraction");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.emit("extractListData", {
|
||||||
|
listSelector,
|
||||||
|
fields,
|
||||||
|
currentListId,
|
||||||
|
pagination: { type: "", selector: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("📤 Sent extraction request to backend");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in backend data extraction:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDOMMode, currentSnapshot, updateListStepData, socket, notify]
|
||||||
|
);
|
||||||
|
|
||||||
const handleMouseEnter = (id: number) => {
|
const handleMouseEnter = (id: number) => {
|
||||||
setHoverStates(prev => ({ ...prev, [id]: true }));
|
setHoverStates(prev => ({ ...prev, [id]: true }));
|
||||||
};
|
};
|
||||||
@@ -338,17 +471,22 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
|
|||||||
|
|
||||||
const stopCaptureAndEmitGetListSettings = useCallback(() => {
|
const stopCaptureAndEmitGetListSettings = useCallback(() => {
|
||||||
const settings = getListSettingsObject();
|
const settings = getListSettingsObject();
|
||||||
if (settings) {
|
|
||||||
|
const latestListStep = getLatestListStep(browserSteps);
|
||||||
|
if (latestListStep && settings) {
|
||||||
|
extractDataClientSide(latestListStep.listSelector!, latestListStep.fields, latestListStep.id);
|
||||||
|
|
||||||
socket?.emit('action', { action: 'scrapeList', settings });
|
socket?.emit('action', { action: 'scrapeList', settings });
|
||||||
} else {
|
} else {
|
||||||
notify('error', t('right_panel.errors.unable_create_settings'));
|
notify('error', t('right_panel.errors.unable_create_settings'));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleStopGetList();
|
handleStopGetList();
|
||||||
setCurrentListActionId('');
|
setCurrentListActionId('');
|
||||||
resetInterpretationLog();
|
resetInterpretationLog();
|
||||||
finishAction('list');
|
finishAction('list');
|
||||||
onFinishCapture();
|
onFinishCapture();
|
||||||
}, [getListSettingsObject, socket, notify, handleStopGetList, resetInterpretationLog, finishAction, onFinishCapture, t]);
|
}, [getListSettingsObject, socket, notify, handleStopGetList, resetInterpretationLog, finishAction, onFinishCapture, t, browserSteps, extractDataClientSide]);
|
||||||
|
|
||||||
const hasUnconfirmedListTextFields = browserSteps.some(step =>
|
const hasUnconfirmedListTextFields = browserSteps.some(step =>
|
||||||
step.type === 'list' &&
|
step.type === 'list' &&
|
||||||
|
|||||||
734
src/helpers/clientListExtractor.ts
Normal file
734
src/helpers/clientListExtractor.ts
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
interface TextStep {
|
||||||
|
id: number;
|
||||||
|
type: "text";
|
||||||
|
label: string;
|
||||||
|
data: string;
|
||||||
|
selectorObj: {
|
||||||
|
selector: string;
|
||||||
|
tag?: string;
|
||||||
|
shadow?: boolean;
|
||||||
|
attribute: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtractedListData {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableField {
|
||||||
|
selector: string;
|
||||||
|
attribute: string;
|
||||||
|
tableContext?: string;
|
||||||
|
cellIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NonTableField {
|
||||||
|
selector: string;
|
||||||
|
attribute: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContainerFields {
|
||||||
|
tableFields: Record<string, TableField>;
|
||||||
|
nonTableFields: Record<string, NonTableField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClientListExtractor {
|
||||||
|
private queryElement = (
|
||||||
|
rootElement: Element | Document,
|
||||||
|
selector: string
|
||||||
|
): Element | null => {
|
||||||
|
if (!selector.includes(">>") && !selector.includes(":>>")) {
|
||||||
|
return rootElement.querySelector(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = selector.split(/(?:>>|:>>)/).map((part) => part.trim());
|
||||||
|
let currentElement: Element | Document | null = rootElement;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
if (!currentElement) return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(currentElement as Element).tagName === "IFRAME" ||
|
||||||
|
(currentElement as Element).tagName === "FRAME"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const frameElement = currentElement as
|
||||||
|
| HTMLIFrameElement
|
||||||
|
| HTMLFrameElement;
|
||||||
|
const frameDoc =
|
||||||
|
frameElement.contentDocument ||
|
||||||
|
frameElement.contentWindow?.document;
|
||||||
|
if (!frameDoc) return null;
|
||||||
|
currentElement = frameDoc.querySelector(parts[i]);
|
||||||
|
continue;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot access ${(
|
||||||
|
currentElement as Element
|
||||||
|
).tagName.toLowerCase()} content:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextElement: Element | null = null;
|
||||||
|
|
||||||
|
if ("querySelector" in currentElement) {
|
||||||
|
nextElement = currentElement.querySelector(parts[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!nextElement &&
|
||||||
|
"shadowRoot" in currentElement &&
|
||||||
|
(currentElement as Element).shadowRoot
|
||||||
|
) {
|
||||||
|
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) {
|
||||||
|
nextElement = child.shadowRoot.querySelector(parts[i]);
|
||||||
|
if (nextElement) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentElement = nextElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentElement as Element | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
private queryElementAll = (
|
||||||
|
rootElement: Element | Document,
|
||||||
|
selector: string
|
||||||
|
): Element[] => {
|
||||||
|
if (!selector.includes(">>") && !selector.includes(":>>")) {
|
||||||
|
return Array.from(rootElement.querySelectorAll(selector));
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = selector.split(/(?:>>|:>>)/).map((part) => part.trim());
|
||||||
|
let currentElements: (Element | Document)[] = [rootElement];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const nextElements: Element[] = [];
|
||||||
|
|
||||||
|
for (const element of currentElements) {
|
||||||
|
if (
|
||||||
|
(element as Element).tagName === "IFRAME" ||
|
||||||
|
(element as Element).tagName === "FRAME"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const frameElement = element as
|
||||||
|
| HTMLIFrameElement
|
||||||
|
| HTMLFrameElement;
|
||||||
|
const frameDoc =
|
||||||
|
frameElement.contentDocument ||
|
||||||
|
frameElement.contentWindow?.document;
|
||||||
|
if (frameDoc) {
|
||||||
|
nextElements.push(...Array.from(frameDoc.querySelectorAll(part)));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot access ${(
|
||||||
|
element as Element
|
||||||
|
).tagName.toLowerCase()} content:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ("querySelectorAll" in element) {
|
||||||
|
nextElements.push(...Array.from(element.querySelectorAll(part)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("shadowRoot" in element && (element as Element).shadowRoot) {
|
||||||
|
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) {
|
||||||
|
nextElements.push(
|
||||||
|
...Array.from(child.shadowRoot.querySelectorAll(part))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentElements = nextElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentElements as Element[];
|
||||||
|
};
|
||||||
|
|
||||||
|
private extractValue = (
|
||||||
|
element: Element,
|
||||||
|
attribute: string
|
||||||
|
): string | null => {
|
||||||
|
if (!element) return null;
|
||||||
|
|
||||||
|
const baseURL =
|
||||||
|
element.ownerDocument?.location?.href || window.location.origin;
|
||||||
|
|
||||||
|
if (element.shadowRoot) {
|
||||||
|
const shadowContent = element.shadowRoot.textContent;
|
||||||
|
if (shadowContent?.trim()) {
|
||||||
|
return shadowContent.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attribute === "innerText") {
|
||||||
|
return (element as HTMLElement).innerText?.trim() || null;
|
||||||
|
} else if (attribute === "innerHTML") {
|
||||||
|
return element.innerHTML?.trim() || null;
|
||||||
|
} else if (attribute === "src" || attribute === "href") {
|
||||||
|
if (attribute === "href" && element.tagName !== "A") {
|
||||||
|
const parentElement = element.parentElement;
|
||||||
|
if (parentElement && parentElement.tagName === "A") {
|
||||||
|
const parentHref = parentElement.getAttribute("href");
|
||||||
|
if (parentHref) {
|
||||||
|
try {
|
||||||
|
return new URL(parentHref, baseURL).href;
|
||||||
|
} catch (e) {
|
||||||
|
return parentHref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attrValue = element.getAttribute(attribute);
|
||||||
|
const dataAttr = attrValue || element.getAttribute("data-" + attribute);
|
||||||
|
|
||||||
|
if (!dataAttr || dataAttr.trim() === "") {
|
||||||
|
if (attribute === "src") {
|
||||||
|
const style = window.getComputedStyle(element as HTMLElement);
|
||||||
|
const bgImage = style.backgroundImage;
|
||||||
|
if (bgImage && bgImage !== "none") {
|
||||||
|
const matches = bgImage.match(/url\(['"]?([^'")]+)['"]?\)/);
|
||||||
|
return matches ? new URL(matches[1], baseURL).href : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(dataAttr, baseURL).href;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Error creating URL from", dataAttr, e);
|
||||||
|
return dataAttr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return element.getAttribute(attribute);
|
||||||
|
};
|
||||||
|
|
||||||
|
private findTableAncestor = (
|
||||||
|
element: Element
|
||||||
|
): { type: string; element: Element } | null => {
|
||||||
|
let currentElement: Element | null = element;
|
||||||
|
const MAX_DEPTH = 5;
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
while (currentElement && depth < MAX_DEPTH) {
|
||||||
|
if (currentElement.getRootNode() instanceof ShadowRoot) {
|
||||||
|
currentElement = (currentElement.getRootNode() as ShadowRoot).host;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentElement.tagName === "TD") {
|
||||||
|
return { type: "TD", element: currentElement };
|
||||||
|
} else if (currentElement.tagName === "TR") {
|
||||||
|
return { type: "TR", element: currentElement };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentElement.tagName === "IFRAME" ||
|
||||||
|
currentElement.tagName === "FRAME"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const frameElement = currentElement as
|
||||||
|
| HTMLIFrameElement
|
||||||
|
| HTMLFrameElement;
|
||||||
|
currentElement = frameElement.contentDocument?.body || null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentElement = currentElement.parentElement;
|
||||||
|
}
|
||||||
|
depth++;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
private getCellIndex = (td: Element): number => {
|
||||||
|
if (td.getRootNode() instanceof ShadowRoot) {
|
||||||
|
const shadowRoot = td.getRootNode() as ShadowRoot;
|
||||||
|
const allCells = Array.from(shadowRoot.querySelectorAll("td"));
|
||||||
|
return allCells.indexOf(td as HTMLTableCellElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
let sibling = td;
|
||||||
|
while ((sibling = sibling.previousElementSibling as Element)) {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
private hasThElement = (
|
||||||
|
row: Element,
|
||||||
|
tableFields: Record<string, TableField>
|
||||||
|
): boolean => {
|
||||||
|
for (const [_, { selector }] of Object.entries(tableFields)) {
|
||||||
|
const element = this.queryElement(row, selector);
|
||||||
|
if (element) {
|
||||||
|
let current: Element | ShadowRoot | Document | null = element;
|
||||||
|
while (current && current !== row) {
|
||||||
|
if (current.getRootNode() instanceof ShadowRoot) {
|
||||||
|
current = (current.getRootNode() as ShadowRoot).host;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((current as Element).tagName === "TH") return true;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(current as Element).tagName === "IFRAME" ||
|
||||||
|
(current as Element).tagName === "FRAME"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const frameElement = current as
|
||||||
|
| HTMLIFrameElement
|
||||||
|
| HTMLFrameElement;
|
||||||
|
current = frameElement.contentDocument?.body || null;
|
||||||
|
} catch (e) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = (current as Element).parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private filterRowsBasedOnTag = (
|
||||||
|
rows: Element[],
|
||||||
|
tableFields: Record<string, TableField>
|
||||||
|
): Element[] => {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (this.hasThElement(row, tableFields)) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.filter((row) => {
|
||||||
|
const directTH = row.getElementsByTagName("TH").length === 0;
|
||||||
|
const shadowTH = row.shadowRoot
|
||||||
|
? row.shadowRoot.querySelector("th") === null
|
||||||
|
: true;
|
||||||
|
return directTH && shadowTH;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private calculateClassSimilarity = (
|
||||||
|
classList1: string[],
|
||||||
|
classList2: string[]
|
||||||
|
): number => {
|
||||||
|
const set1 = new Set(classList1);
|
||||||
|
const set2 = new Set(classList2);
|
||||||
|
const intersection = new Set([...set1].filter((x) => set2.has(x)));
|
||||||
|
const union = new Set([...set1, ...set2]);
|
||||||
|
return intersection.size / union.size;
|
||||||
|
};
|
||||||
|
|
||||||
|
private findSimilarElements = (
|
||||||
|
baseElement: Element,
|
||||||
|
document: Document,
|
||||||
|
similarityThreshold: number = 0.7
|
||||||
|
): Element[] => {
|
||||||
|
const baseClasses = Array.from(baseElement.classList);
|
||||||
|
if (baseClasses.length === 0) return [];
|
||||||
|
|
||||||
|
const allElements: Element[] = [];
|
||||||
|
|
||||||
|
allElements.push(
|
||||||
|
...Array.from(document.getElementsByTagName(baseElement.tagName))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (baseElement.getRootNode() instanceof ShadowRoot) {
|
||||||
|
const shadowHost = (baseElement.getRootNode() as ShadowRoot).host;
|
||||||
|
allElements.push(
|
||||||
|
...Array.from(shadowHost.getElementsByTagName(baseElement.tagName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const frames = [
|
||||||
|
...Array.from(document.getElementsByTagName("iframe")),
|
||||||
|
...Array.from(document.getElementsByTagName("frame")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const frame of frames) {
|
||||||
|
try {
|
||||||
|
const frameElement = frame as HTMLIFrameElement | HTMLFrameElement;
|
||||||
|
const frameDoc =
|
||||||
|
frameElement.contentDocument || frameElement.contentWindow?.document;
|
||||||
|
if (frameDoc) {
|
||||||
|
allElements.push(
|
||||||
|
...Array.from(frameDoc.getElementsByTagName(baseElement.tagName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot access ${frame.tagName.toLowerCase()} content:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allElements.filter((element) => {
|
||||||
|
if (element === baseElement) return false;
|
||||||
|
const similarity = this.calculateClassSimilarity(
|
||||||
|
baseClasses,
|
||||||
|
Array.from(element.classList)
|
||||||
|
);
|
||||||
|
return similarity >= similarityThreshold;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private convertFields = (
|
||||||
|
fields: any
|
||||||
|
): Record<string, { selector: string; attribute: string }> => {
|
||||||
|
const convertedFields: Record<
|
||||||
|
string,
|
||||||
|
{ selector: string; attribute: string }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const [key, field] of Object.entries(fields)) {
|
||||||
|
const typedField = field as TextStep;
|
||||||
|
convertedFields[typedField.label] = {
|
||||||
|
selector: typedField.selectorObj.selector,
|
||||||
|
attribute: typedField.selectorObj.attribute,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertedFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
public extractListData = (
|
||||||
|
iframeDocument: Document,
|
||||||
|
listSelector: string,
|
||||||
|
fields: any,
|
||||||
|
limit: number = 5
|
||||||
|
): ExtractedListData[] => {
|
||||||
|
try {
|
||||||
|
// Convert fields to the format expected by the extraction logic
|
||||||
|
const convertedFields = this.convertFields(fields);
|
||||||
|
|
||||||
|
// Get all container elements matching the list selector
|
||||||
|
let containers = this.queryElementAll(iframeDocument, listSelector);
|
||||||
|
|
||||||
|
if (containers.length === 0) {
|
||||||
|
console.warn("No containers found for listSelector:", listSelector);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced container discovery: find similar elements if we need more containers
|
||||||
|
if (limit > 1 && containers.length === 1) {
|
||||||
|
const baseContainer = containers[0];
|
||||||
|
const similarContainers = this.findSimilarElements(
|
||||||
|
baseContainer,
|
||||||
|
iframeDocument,
|
||||||
|
0.7
|
||||||
|
);
|
||||||
|
|
||||||
|
if (similarContainers.length > 0) {
|
||||||
|
const newContainers = similarContainers.filter(
|
||||||
|
(container) => !container.matches(listSelector)
|
||||||
|
);
|
||||||
|
containers = [...containers, ...newContainers];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📦 Found containers:", containers.length);
|
||||||
|
|
||||||
|
// Analyze fields for table vs non-table context
|
||||||
|
const containerFields: ContainerFields[] = containers.map(() => ({
|
||||||
|
tableFields: {},
|
||||||
|
nonTableFields: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
containers.forEach((container, containerIndex) => {
|
||||||
|
for (const [label, field] of Object.entries(convertedFields)) {
|
||||||
|
const sampleElement = this.queryElement(container, field.selector);
|
||||||
|
|
||||||
|
if (sampleElement) {
|
||||||
|
const ancestor = this.findTableAncestor(sampleElement);
|
||||||
|
if (ancestor) {
|
||||||
|
containerFields[containerIndex].tableFields[label] = {
|
||||||
|
...field,
|
||||||
|
tableContext: ancestor.type,
|
||||||
|
cellIndex:
|
||||||
|
ancestor.type === "TD"
|
||||||
|
? this.getCellIndex(ancestor.element)
|
||||||
|
: -1,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
containerFields[containerIndex].nonTableFields[label] = field;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
containerFields[containerIndex].nonTableFields[label] = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract table data
|
||||||
|
const tableData: ExtractedListData[] = [];
|
||||||
|
for (
|
||||||
|
let containerIndex = 0;
|
||||||
|
containerIndex < containers.length;
|
||||||
|
containerIndex++
|
||||||
|
) {
|
||||||
|
const container = containers[containerIndex];
|
||||||
|
const { tableFields } = containerFields[containerIndex];
|
||||||
|
|
||||||
|
if (Object.keys(tableFields).length > 0) {
|
||||||
|
const firstField = Object.values(tableFields)[0];
|
||||||
|
const firstElement = this.queryElement(
|
||||||
|
container,
|
||||||
|
firstField.selector
|
||||||
|
);
|
||||||
|
let tableContext: Element | null = firstElement;
|
||||||
|
|
||||||
|
// Find the table context
|
||||||
|
while (
|
||||||
|
tableContext &&
|
||||||
|
tableContext.tagName !== "TABLE" &&
|
||||||
|
tableContext !== container
|
||||||
|
) {
|
||||||
|
if (tableContext.getRootNode() instanceof ShadowRoot) {
|
||||||
|
tableContext = (tableContext.getRootNode() as ShadowRoot).host;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
tableContext.tagName === "IFRAME" ||
|
||||||
|
tableContext.tagName === "FRAME"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const frameElement = tableContext as
|
||||||
|
| HTMLIFrameElement
|
||||||
|
| HTMLFrameElement;
|
||||||
|
tableContext = frameElement.contentDocument?.body || null;
|
||||||
|
} catch (e) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tableContext = tableContext.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableContext) {
|
||||||
|
const rows: Element[] = [];
|
||||||
|
rows.push(...Array.from(tableContext.getElementsByTagName("TR")));
|
||||||
|
|
||||||
|
if (
|
||||||
|
tableContext.tagName === "IFRAME" ||
|
||||||
|
tableContext.tagName === "FRAME"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const frameElement = tableContext as
|
||||||
|
| HTMLIFrameElement
|
||||||
|
| HTMLFrameElement;
|
||||||
|
const frameDoc =
|
||||||
|
frameElement.contentDocument ||
|
||||||
|
frameElement.contentWindow?.document;
|
||||||
|
if (frameDoc) {
|
||||||
|
rows.push(...Array.from(frameDoc.getElementsByTagName("TR")));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot access ${tableContext.tagName.toLowerCase()} rows:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedRows = this.filterRowsBasedOnTag(rows, tableFields);
|
||||||
|
|
||||||
|
for (
|
||||||
|
let rowIndex = 0;
|
||||||
|
rowIndex < Math.min(processedRows.length, limit);
|
||||||
|
rowIndex++
|
||||||
|
) {
|
||||||
|
const record: ExtractedListData = {};
|
||||||
|
const currentRow = processedRows[rowIndex];
|
||||||
|
|
||||||
|
for (const [
|
||||||
|
label,
|
||||||
|
{ selector, attribute, cellIndex },
|
||||||
|
] of Object.entries(tableFields)) {
|
||||||
|
let element: Element | null = null;
|
||||||
|
|
||||||
|
if (cellIndex !== undefined && cellIndex >= 0) {
|
||||||
|
let td: Element | null =
|
||||||
|
currentRow.children[cellIndex] || null;
|
||||||
|
|
||||||
|
if (!td && currentRow.shadowRoot) {
|
||||||
|
const shadowCells = currentRow.shadowRoot.children;
|
||||||
|
if (shadowCells && shadowCells.length > cellIndex) {
|
||||||
|
td = shadowCells[cellIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (td) {
|
||||||
|
element = this.queryElement(td, selector);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!element &&
|
||||||
|
selector
|
||||||
|
.split(/(?:>>|:>>)/)
|
||||||
|
.pop()
|
||||||
|
?.includes("td:nth-child")
|
||||||
|
) {
|
||||||
|
element = td;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
const tagOnlySelector = selector.split(".")[0];
|
||||||
|
element = this.queryElement(td, tagOnlySelector);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
let currentElement: Element | null = td;
|
||||||
|
while (
|
||||||
|
currentElement &&
|
||||||
|
currentElement.children.length > 0
|
||||||
|
) {
|
||||||
|
let foundContentChild = false;
|
||||||
|
for (const child of Array.from(
|
||||||
|
currentElement.children
|
||||||
|
)) {
|
||||||
|
if (this.extractValue(child, attribute)) {
|
||||||
|
currentElement = child;
|
||||||
|
foundContentChild = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundContentChild) break;
|
||||||
|
}
|
||||||
|
element = currentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
element = this.queryElement(currentRow, selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
const value = this.extractValue(element, attribute);
|
||||||
|
if (value !== null && value !== "") {
|
||||||
|
record[label] = value;
|
||||||
|
console.log(`✅ Extracted ${label}:`, value);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`❌ No value for ${label} in row ${rowIndex + 1}`
|
||||||
|
);
|
||||||
|
record[label] = "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`❌ Element not found for ${label} with selector:`,
|
||||||
|
selector
|
||||||
|
);
|
||||||
|
record[label] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.values(record).some((value) => value !== "")) {
|
||||||
|
tableData.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract non-table data
|
||||||
|
const nonTableData: ExtractedListData[] = [];
|
||||||
|
for (
|
||||||
|
let containerIndex = 0;
|
||||||
|
containerIndex < containers.length;
|
||||||
|
containerIndex++
|
||||||
|
) {
|
||||||
|
if (nonTableData.length >= limit) break;
|
||||||
|
|
||||||
|
const container = containers[containerIndex];
|
||||||
|
const { nonTableFields } = containerFields[containerIndex];
|
||||||
|
|
||||||
|
if (Object.keys(nonTableFields).length > 0) {
|
||||||
|
const record: ExtractedListData = {};
|
||||||
|
|
||||||
|
for (const [label, { selector, attribute }] of Object.entries(
|
||||||
|
nonTableFields
|
||||||
|
)) {
|
||||||
|
const relativeSelector = selector.split(/(?:>>|:>>)/).slice(-1)[0];
|
||||||
|
const element = this.queryElement(container, relativeSelector);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
const value = this.extractValue(element, attribute);
|
||||||
|
if (value !== null && value !== "") {
|
||||||
|
record[label] = value;
|
||||||
|
console.log(`✅ Extracted ${label}:`, value);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`❌ No value for ${label} in container ${containerIndex + 1}`
|
||||||
|
);
|
||||||
|
record[label] = "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`❌ Element not found for ${label} with selector:`,
|
||||||
|
selector
|
||||||
|
);
|
||||||
|
record[label] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.values(record).some((value) => value !== "")) {
|
||||||
|
nonTableData.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine and limit results
|
||||||
|
const extractedData = [...tableData, ...nonTableData].slice(0, limit);
|
||||||
|
|
||||||
|
console.log("🎉 Client extraction complete:", {
|
||||||
|
totalRecords: extractedData.length,
|
||||||
|
tableRecords: tableData.length,
|
||||||
|
nonTableRecords: nonTableData.length,
|
||||||
|
data: extractedData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return extractedData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in client-side extractListData:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clientListExtractor = new ClientListExtractor();
|
||||||
3295
src/helpers/clientSelectorGenerator.ts
Normal file
3295
src/helpers/clientSelectorGenerator.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user